""" Tests for src.element_style — the shared per-element style resolver behind the x-style-elements system. The contract under test (defined by the plugin consumers: of-the-day, ledmatrix-music, football-scoreboard): - defaults_from_schema_file parses BOTH declaration forms — the compact x-style-elements map and hand-written customization blocks. - expand_style_elements turns an x-style-elements declaration into the full per-element blocks (plus layout offsets) the web-UI form renders. - A config value counts as user-forced only when it genuinely differs from the schema default; untouched (or schema-default-populated) configs resolve to EXACTLY the classic font/size/color, keeping rendering byte-identical. - style() never raises; malformed input degrades to the classic style. """ import json import os import pytest from PIL import ImageFont from src.element_style import ( ElementStyleResolver, _load_font_sized, native_bdf_size, defaults_from_schema, defaults_from_schema_file, expand_style_elements, load_font, resolve_font_path, ) # --------------------------------------------------------------------------- # Schema fixtures # --------------------------------------------------------------------------- # Compact declaration form (of-the-day's shape). STYLE_ELEMENTS_SCHEMA = { "type": "object", "properties": { "enabled": {"type": "boolean", "default": False}, "customization": { "type": "object", "x-style-elements": { "title_text": { "title": "Title", "font": {"default": "PressStart2P-Regular.ttf"}, "size": {"default": 8, "min": 4, "max": 16}, "color": {"default": [255, 255, 255]}, "offsets": True, }, "body_text": { "title": "Body Text", "font": {"default": "4x6-font.ttf"}, "size": {"default": 6, "min": 4, "max": 12}, "color": {"default": [200, 200, 200]}, "offsets": True, }, }, }, }, } # Manual declaration form (the scoreboards' / music's shape). MANUAL_SCHEMA = { "type": "object", "properties": { "customization": { "type": "object", "properties": { "status_text": { "type": "object", "properties": { "font": {"type": "string", "default": "4x6-font.ttf"}, "font_size": {"type": "integer", "default": 6}, }, }, "score_text": { "type": "object", "properties": { "font": {"type": "string", "default": "PressStart2P-Regular.ttf"}, "font_size": {"type": "integer", "default": 10}, "text_color": {"type": "array", "default": [255, 255, 0]}, }, }, "layout": {"type": "object", "properties": {}}, }, }, }, } @pytest.fixture def style_schema_path(tmp_path): path = tmp_path / "config_schema.json" path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA)) return str(path) @pytest.fixture def manual_schema_path(tmp_path): path = tmp_path / "config_schema.json" path.write_text(json.dumps(MANUAL_SCHEMA)) return str(path) def _resolver(config, schema_path): return ElementStyleResolver(config, defaults_from_schema_file(schema_path)) # --------------------------------------------------------------------------- # Schema parsing # --------------------------------------------------------------------------- class TestDefaultsFromSchema: def test_x_style_elements_defaults(self, style_schema_path): defaults = defaults_from_schema_file(style_schema_path) cust = defaults["customization"] assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf", "font_size": 8, "text_color": [255, 255, 255]} assert cust["body_text"]["font_size"] == 6 assert cust["body_text"]["text_color"] == [200, 200, 200] def test_manual_block_defaults(self, manual_schema_path): defaults = defaults_from_schema_file(manual_schema_path) cust = defaults["customization"] assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6} assert cust["score_text"]["text_color"] == [255, 255, 0] assert "layout" not in cust def test_missing_file_degrades_to_empty(self, tmp_path): defaults = defaults_from_schema_file(str(tmp_path / "nope.json")) assert defaults == {"customization": {}} def test_malformed_file_degrades_to_empty(self, tmp_path): path = tmp_path / "bad.json" path.write_text("{not json") assert defaults_from_schema_file(str(path)) == {"customization": {}} def test_schema_without_customization(self): assert defaults_from_schema({"properties": {}}) == {"customization": {}} class TestExpandStyleElements: def test_expansion_generates_blocks(self): expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA) cust = expanded["properties"]["customization"]["properties"] title = cust["title_text"] assert title["x-style-managed"] is True assert title["properties"]["font"]["default"] == \ "PressStart2P-Regular.ttf" assert title["properties"]["font_size"]["default"] == 8 assert title["properties"]["font_size"]["minimum"] == 4 assert title["properties"]["font_size"]["maximum"] == 16 assert cust["body_text"]["properties"]["text_color"]["default"] == \ [200, 200, 200] def test_expansion_generates_layout_offsets(self): expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA) layout = expanded["properties"]["customization"]["properties"]["layout"] assert "title_text" in layout["properties"] offsets = layout["properties"]["body_text"]["properties"] assert offsets["x_offset"]["default"] == 0 assert offsets["y_offset"]["default"] == 0 def test_input_schema_not_mutated(self): before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) expand_style_elements(STYLE_ELEMENTS_SCHEMA) assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before def test_no_declaration_returns_same_object(self): assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA empty = {"properties": {}} assert expand_style_elements(empty) is empty def test_garbage_input_never_raises(self): bad = {"properties": {"customization": {"x-style-elements": "nope"}}} assert expand_style_elements(bad) is bad # --------------------------------------------------------------------------- # Classic identity: untouched configs resolve to the classic style # --------------------------------------------------------------------------- class TestClassicIdentity: def test_bare_config_resolves_classic(self, style_schema_path): r = _resolver({}, style_schema_path) style = r.style("title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8, classic_color=(255, 255, 255)) assert style.font_name == "PressStart2P-Regular.ttf" assert style.font_size == 8 assert style.color == (255, 255, 255) assert style.offset == (0, 0) assert not style.user_forced assert not style.user_forced_color assert isinstance(style.font, ImageFont.FreeTypeFont) assert style.font.size == 8 def test_schema_populated_config_is_not_an_override(self, style_schema_path): # The web UI's save flow writes the full schema defaults into config # on every save — that must not count as a user override. config = {"customization": { "title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8, "text_color": [255, 255, 255]}, "layout": {"title_text": {"x_offset": 0, "y_offset": 0}}, }} style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8, classic_color=(255, 255, 255)) assert not style.user_forced assert not style.user_forced_color assert style.font_size == 8 assert style.color == (255, 255, 255) assert style.offset == (0, 0) def test_schema_default_falls_back_to_classic_not_schema_font( self, manual_schema_path): # Classic values and schema defaults can legitimately differ # (football's status_text: schema says 4x6, classic loader used # PressStart). A schema-default config value must yield the CLASSIC # font, byte-identical to the old loader. config = {"customization": {"status_text": {"font": "4x6-font.ttf", "font_size": 6}}} style = _resolver(config, manual_schema_path).style( "status_text", classic_font="PressStart2P-Regular.ttf", classic_size=6) assert not style.user_forced assert style.font_name == "PressStart2P-Regular.ttf" assert style.font_size == 6 def test_same_font_object_from_cache(self, style_schema_path): r = _resolver({}, style_schema_path) s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) s2 = ElementStyleResolver({}, {}).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) assert s1.font is s2.font # --------------------------------------------------------------------------- # User overrides engage # --------------------------------------------------------------------------- class TestUserOverrides: def test_font_override(self, style_schema_path): config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}} style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) assert style.user_forced assert style.font_name == "4x6-font.ttf" assert style.font_size == 8 # size untouched -> classic def test_size_override(self, style_schema_path): config = {"customization": {"title_text": { "font": "PressStart2P-Regular.ttf", "font_size": 16}}} style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) assert style.user_forced assert style.font_name == "PressStart2P-Regular.ttf" assert style.font_size == 16 assert style.font.size == 16 def test_size_override_detected_vs_schema_default(self, manual_schema_path): # font_size 8 differs from the schema default 6 -> forced. config = {"customization": {"status_text": {"font": "4x6-font.ttf", "font_size": 8}}} style = _resolver(config, manual_schema_path).style( "status_text", classic_font="PressStart2P-Regular.ttf", classic_size=6) assert style.user_forced assert style.font_size == 8 def test_color_override(self, style_schema_path): config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}} style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8, classic_color=(255, 255, 255)) assert style.user_forced_color assert not style.user_forced assert style.color == (255, 0, 0) def test_offsets(self, style_schema_path): config = {"customization": {"layout": { "title_text": {"x_offset": 4, "y_offset": -2}}}} r = _resolver(config, style_schema_path) assert r.offset("title_text") == (4, -2) assert r.offset("body_text") == (0, 0) style = r.style("title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) assert style.offset == (4, -2) def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path): # The scoreboards read non-standard axes (away_x_offset) and configs # can carry numeric strings/floats. config = {"customization": {"layout": {"records": { "away_x_offset": "3", "home_x_offset": 2.7}}}} r = _resolver(config, style_schema_path) assert r.offset_value("records", "away_x_offset", 0) == 3 assert r.offset_value("records", "home_x_offset", 0) == 2 assert r.offset_value("records", "missing_axis", 5) == 5 # --------------------------------------------------------------------------- # Defensive degradation # --------------------------------------------------------------------------- class TestDegradation: @pytest.mark.parametrize("config", [ None, {"customization": "not a dict"}, {"customization": {"title_text": "not a dict"}}, {"customization": {"title_text": {"font": 42, "font_size": "huge", "text_color": "red"}}}, {"customization": {"layout": {"title_text": {"x_offset": "junk"}}}}, ]) def test_bad_config_degrades_to_classic(self, config, style_schema_path): style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8, classic_color=(10, 20, 30)) assert not style.user_forced assert not style.user_forced_color assert style.font_name == "PressStart2P-Regular.ttf" assert style.font_size == 8 assert style.color == (10, 20, 30) assert style.offset == (0, 0) def test_unknown_font_falls_back(self, style_schema_path): config = {"customization": {"title_text": {"font": "no-such.ttf"}}} style = _resolver(config, style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) # The override IS honored as forced, but the face degrades safely. assert style.user_forced assert style.font is not None def test_empty_defaults_treats_config_as_reference_to_classic(self): # No schema defaults at all: a config value equal to the classic # value is not forced; a different one is. r = ElementStyleResolver( {"customization": {"e": {"font": "4x6-font.ttf"}}}, {}) assert not r.style("e", classic_font="4x6-font.ttf", classic_size=6).user_forced assert r.style("e", classic_font="PressStart2P-Regular.ttf", classic_size=6).user_forced # --------------------------------------------------------------------------- # Resolver plumbing the consumers rely on # --------------------------------------------------------------------------- class TestResolverPlumbing: def test_config_identity_exposed(self, style_schema_path): # Consumers rebuild the resolver when the config dict is swapped: # `resolver._config is not self.config`. config = {"customization": {}} r = _resolver(config, style_schema_path) assert r._config is config def test_font_path_resolution_is_cwd_independent(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) # no assets/fonts under cwd path = resolve_font_path("PressStart2P-Regular.ttf") assert path is not None and os.path.isfile(path) font = load_font("PressStart2P-Regular.ttf", 8) assert isinstance(font, ImageFont.FreeTypeFont) def test_bdf_font_loads_as_freetype_face(self): import freetype font = load_font("5x7.bdf", 7) assert isinstance(font, freetype.Face) @pytest.mark.parametrize("hostile", [ "../../config/config.json", "../secrets.txt", "sub/dir/font.ttf", "..", ]) def test_relative_font_name_with_path_components_is_rejected(self, hostile): # font_name comes from plugin config (web-UI writable); a relative name # carrying path separators would escape assets/fonts/ after os.path.join # and let a config probe arbitrary paths. Only bare filenames resolve. assert resolve_font_path(hostile) is None def test_bare_filename_still_resolves(self): # The guard must not reject legitimate bare names. assert resolve_font_path("PressStart2P-Regular.ttf") is not None def test_schema_manager_expands_on_load(self, tmp_path): # The web-UI form path: SchemaManager.load_schema serves the # expanded schema so the style blocks actually appear in the UI. from src.plugin_system.schema_manager import SchemaManager plugin_dir = tmp_path / "plugins" / "styled" plugin_dir.mkdir(parents=True) (plugin_dir / "config_schema.json").write_text( json.dumps(STYLE_ELEMENTS_SCHEMA)) (plugin_dir / "manifest.json").write_text(json.dumps({ "id": "styled", "config_schema": "config_schema.json"})) manager = SchemaManager(plugins_dir=tmp_path / "plugins", project_root=tmp_path) schema = manager.load_schema("styled") assert schema is not None cust = schema["properties"]["customization"]["properties"] assert cust["title_text"]["x-style-managed"] is True assert "title_text" in cust["layout"]["properties"] class TestBdfSizing: """A BDF is a fixed-size bitmap strike, not a scalable outline. FreeType accepts only the exact pixel size baked into the file and raises for anything else, and 32 of the 35 shipped fonts are BDF -- so a size picked in the web UI usually is not a valid strike. This used to fall through to the generic font-load except and return *PressStart2P*, so asking for 5x7.bdf at size 10 silently rendered a different typeface. It now falls back to the file's own size instead, matching what SportsCore._load_custom_font_from_element_config already did. """ def test_a_valid_strike_loads_at_that_size(self): import freetype font, realised = _load_font_sized("5x7.bdf", 7) assert isinstance(font, freetype.Face) assert realised == 7 @pytest.mark.parametrize("requested", [4, 10, 16, 32]) def test_a_wrong_size_keeps_the_font_and_snaps_the_size(self, requested): """The regression: the face must still be 5x7, not a substitute.""" import freetype font, realised = _load_font_sized("5x7.bdf", requested) assert isinstance(font, freetype.Face), ( "a BDF asked for a bad size used to come back as a PIL " "PressStart2P face -- a different font entirely") assert realised == 7 def test_the_reported_size_is_what_was_realised(self, style_schema_path): """ElementStyle.font_size drives caller layout, so it must not report a size nothing was drawn at.""" config = {"customization": {"title_text": {"font": "5x7.bdf", "font_size": 20}}} style = ElementStyleResolver( config, defaults_from_schema_file(style_schema_path) ).style("title_text", classic_font="PressStart2P-Regular.ttf", classic_size=8) assert style.font_name == "5x7.bdf" assert style.font_size == 7 def test_native_bdf_size_reads_the_file(self): assert native_bdf_size("5x7.bdf") == 7 @pytest.mark.parametrize("name", ["PressStart2P-Regular.ttf", "no-such-font.bdf", "", None]) def test_native_bdf_size_is_none_when_size_is_a_free_choice(self, name): """None is the web UI's signal that the size field stays editable.""" assert native_bdf_size(name) is None def test_a_scalable_font_realises_the_requested_size(self): for size in (6, 8, 13): font, realised = _load_font_sized("PressStart2P-Regular.ttf", size) assert realised == size assert not isinstance(font, type(None)) class TestFontCacheIsBounded: """The display process runs for weeks and every config save can add a new (font, size) pair; this cache was unbounded, against the house style of every other hot cache in the codebase.""" def test_the_cache_evicts_past_its_bound(self): import src.element_style as es es._font_cache.clear() try: for size in range(1, es._FONT_CACHE_MAX + 40): load_font("PressStart2P-Regular.ttf", size) assert len(es._font_cache) <= es._FONT_CACHE_MAX finally: es._font_cache.clear() def test_a_repeated_load_is_the_same_object(self): import src.element_style as es es._font_cache.clear() try: first = load_font("PressStart2P-Regular.ttf", 8) assert load_font("PressStart2P-Regular.ttf", 8) is first finally: es._font_cache.clear() def test_eviction_is_least_recently_used(self): import src.element_style as es es._font_cache.clear() try: oldest = load_font("PressStart2P-Regular.ttf", 1) for size in range(2, es._FONT_CACHE_MAX + 1): load_font("PressStart2P-Regular.ttf", size) # Touch the oldest so it is no longer the eviction candidate, # then overflow by one. assert load_font("PressStart2P-Regular.ttf", 1) is oldest load_font("PressStart2P-Regular.ttf", es._FONT_CACHE_MAX + 1) assert load_font("PressStart2P-Regular.ttf", 1) is oldest finally: es._font_cache.clear() class TestModes: """Per-mode overrides: one element styled differently per situation. The motivating case is a scoreboard, where the score wants a bigger font on a live card than on an upcoming one. Live/Upcoming/Recent are already separate instances with distinct SKIN_MODE values, so the mode is bound to the resolver rather than threaded through every call site. A mode layer is pure override: its fields default to None, meaning inherit. That is why None and 0 must stay distinct -- a mode y_offset of 0 means "sit at the base position", not "no preference". """ def _resolver(self, config, mode=None, schema=None): defaults = defaults_from_schema_file(schema) if schema else {} return ElementStyleResolver(config, defaults, mode=mode) def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path): config = {"customization": {"title_text": {"font_size": 12}}} plain = self._resolver(config, schema=style_schema_path) moded = self._resolver(config, mode="live", schema=style_schema_path) a = plain.style("title_text", classic_size=8) b = moded.style("title_text", classic_size=8) assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size, b.color) def test_a_mode_overrides_the_base_size(self, style_schema_path): config = {"customization": { "title_text": {"font_size": 12}, "modes": {"live": {"title_text": {"font_size": 16}}}}} r = self._resolver(config, mode="live", schema=style_schema_path) assert r.style("title_text", classic_size=8).font_size == 16 assert r.style("title_text", classic_size=8).user_forced is True def test_a_different_mode_is_unaffected(self, style_schema_path): config = {"customization": { "title_text": {"font_size": 12}, "modes": {"live": {"title_text": {"font_size": 16}}}}} r = self._resolver(config, mode="upcoming", schema=style_schema_path) assert r.style("title_text", classic_size=8).font_size == 12 def test_two_resolvers_share_a_config_and_differ_by_mode( self, style_schema_path): """The SportsUpcoming / SportsRecent case: same config dict, two instances, two answers.""" config = {"customization": { "title_text": {"font_size": 12}, "modes": {"live": {"title_text": {"font_size": 16}}, "recent": {"title_text": {"font_size": 6}}}}} live = self._resolver(config, mode="live", schema=style_schema_path) recent = self._resolver(config, mode="recent", schema=style_schema_path) assert live.style("title_text", classic_size=8).font_size == 16 assert recent.style("title_text", classic_size=8).font_size == 6 def test_a_mode_overrides_font_and_colour(self, style_schema_path): config = {"customization": { "modes": {"live": {"title_text": {"font": "4x6-font.ttf", "text_color": [1, 2, 3]}}}}} st = self._resolver(config, mode="live", schema=style_schema_path).style( "title_text", classic_font="PressStart2P-Regular.ttf", classic_color=(255, 255, 255)) assert st.font_name == "4x6-font.ttf" assert st.color == (1, 2, 3) assert st.user_forced and st.user_forced_color def test_an_unset_mode_field_inherits_rather_than_resetting( self, style_schema_path): """Only font_size is overridden; the colour must survive.""" config = {"customization": { "title_text": {"text_color": [9, 9, 9]}, "modes": {"live": {"title_text": {"font_size": 16}}}}} st = self._resolver(config, mode="live", schema=style_schema_path).style( "title_text", classic_color=(255, 255, 255)) assert st.font_size == 16 assert st.color == (9, 9, 9) def test_a_null_mode_field_means_inherit(self, style_schema_path): config = {"customization": { "title_text": {"font_size": 12}, "modes": {"live": {"title_text": {"font_size": None}}}}} st = self._resolver(config, mode="live", schema=style_schema_path).style("title_text") assert st.font_size == 12 def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path): config = {"customization": {"modes": { "live": {"title_text": {"font_size": 16}}, "recent": {"title_text": {"font_size": 6}}}}} r = self._resolver(config, mode="live", schema=style_schema_path) assert r.style("title_text").font_size == 16 assert r.style("title_text", mode="recent").font_size == 6 def test_the_memo_does_not_leak_between_modes(self, style_schema_path): config = {"customization": {"modes": { "live": {"title_text": {"font_size": 16}}, "recent": {"title_text": {"font_size": 6}}}}} r = self._resolver(config, mode="live", schema=style_schema_path) assert r.style("title_text").font_size == 16 assert r.style("title_text", mode="recent").font_size == 6 assert r.style("title_text").font_size == 16 def test_mode_is_exposed(self): assert ElementStyleResolver({}, {}, mode="live").mode == "live" assert ElementStyleResolver({}, {}).mode is None class TestModeOffsets: def _r(self, config, mode=None): return ElementStyleResolver(config, {}, mode=mode) def test_a_mode_offset_wins(self): config = {"customization": { "layout": {"score": {"y_offset": -2}}, "modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}} assert self._r(config, "live").offset_value("score", "y_offset") == 5 def test_an_absent_mode_offset_inherits_the_base(self): config = {"customization": { "layout": {"score": {"y_offset": -2}}, "modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}} r = self._r(config, "live") assert r.offset_value("score", "y_offset") == -2 assert r.offset_value("score", "x_offset") == 1 def test_an_explicit_zero_is_an_override_not_an_absence(self): """The reason None is the inherit sentinel: 0 has to mean something.""" config = {"customization": { "layout": {"score": {"y_offset": -2}}, "modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}} assert self._r(config, "live").offset_value("score", "y_offset") == 0 def test_a_null_mode_offset_inherits(self): config = {"customization": { "layout": {"score": {"y_offset": -2}}, "modes": {"live": {"layout": {"score": {"y_offset": None}}}}}} assert self._r(config, "live").offset_value("score", "y_offset") == -2 def test_offset_pair_is_mode_aware(self): config = {"customization": { "layout": {"score": {"x_offset": 1, "y_offset": 2}}, "modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}} assert self._r(config, "live").offset("score") == (1, 9) def test_an_arbitrary_axis_is_mode_aware(self): """The scoreboards use away_x_offset / home_x_offset.""" config = {"customization": { "layout": {"records": {"away_x_offset": 3}}, "modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}} assert self._r(config, "recent").offset_value( "records", "away_x_offset") == 7 @pytest.mark.parametrize("modes", [ None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}}, {"live": {"layout": {"score": {"y_offset": "bad"}}}}, ]) def test_garbage_modes_degrade_to_the_base(self, modes): config = {"customization": {"layout": {"score": {"y_offset": -2}}, "modes": modes}} assert self._r(config, "live").offset_value("score", "y_offset") == -2 @pytest.mark.parametrize("modes", [ None, "nonsense", {"live": "nonsense"}, {"live": {"title_text": "nonsense"}}, {"live": {"title_text": {"font_size": "huge", "text_color": "red"}}}, ]) def test_garbage_mode_styles_degrade(self, modes, style_schema_path): config = {"customization": {"title_text": {"font_size": 12}, "modes": modes}} st = ElementStyleResolver( config, defaults_from_schema_file(style_schema_path), mode="live").style("title_text", classic_size=8) assert st.font_size == 12