diff --git a/src/element_style.py b/src/element_style.py index c073b1d8..bc7f8968 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -706,6 +706,65 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]: return None +#: Element names that drifted between plugins, beyond what the ``_text`` +#: suffix rule below covers. Counted across the published schemas: the +#: layout block spells it ``records`` in seven plugins and ``record`` in +#: two, ``status_text`` in seven and ``status`` in two. +_ELEMENT_ALIASES: Dict[str, Tuple[str, ...]] = { + 'records': ('record',), + 'record': ('records',), + 'rank_text': ('ranking', 'rank'), + 'ranking': ('rank_text', 'rank'), + 'team_name': ('team',), + 'team': ('team_name',), +} + + +def alias_keys(element_key: str) -> Tuple[str, ...]: + """The names one element may be stored under, exact match first. + + Two conventions collided as the scoreboards grew. The style block names + elements with a ``_text`` suffix (``score_text``, ``status_text``) while + the layout block mostly uses the bare noun (``score``, ``date``, + ``odds``) -- except ``status_text``, which kept the suffix in seven + plugins and lost it in two. Plugins also disagree on ``records`` vs + ``record``. + + Rather than make every plugin rename its config keys -- which would + orphan whatever offsets its users had already dialled in -- a lookup + tries the exact name first and then the spellings that mean the same + thing. Exact-first is what keeps this from changing any behaviour for a + config that already matches. + + This also covers the compact declaration form, which uses one key for + both blocks: a plugin moving to it can still find offsets its users + saved under the old bare-noun layout key. + """ + if not isinstance(element_key, str) or not element_key: + return () + explicit = _ELEMENT_ALIASES.get(element_key) + if explicit: + # An explicit entry replaces the suffix rule rather than adding to + # it, so 'records' does not also generate 'records_text'. + return (element_key,) + tuple(a for a in explicit if a != element_key) + + if element_key.endswith('_text'): + stem = element_key[:-len('_text')] + return (element_key, stem) if stem else (element_key,) + return (element_key, element_key + '_text') + + +def _lookup_element(block: Any, element_key: str) -> Dict[str, Any]: + """``block[element]`` under any of its names, or {}.""" + if not isinstance(block, dict): + return {} + for key in alias_keys(element_key): + value = block.get(key) + if isinstance(value, dict): + return value + return {} + + def _coerce_bool(value: Any, default: bool) -> bool: """A real bool, or ``default``. Accepts the strings a form may post.""" if isinstance(value, bool): @@ -814,12 +873,10 @@ class ElementStyleResolver: return customization if isinstance(customization, dict) else {} def _element_config(self, element_key: str) -> Dict[str, Any]: - element = self._customization().get(element_key, {}) - return element if isinstance(element, dict) else {} + return _lookup_element(self._customization(), element_key) def _element_defaults(self, element_key: str) -> Dict[str, Any]: - defaults = self._defaults.get(element_key, {}) - return defaults if isinstance(defaults, dict) else {} + return _lookup_element(self._defaults, element_key) def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]: """``customization.modes.``, or {} when there is no such block.""" @@ -833,8 +890,7 @@ class ElementStyleResolver: def _mode_element_config(self, element_key: str, mode: Optional[str]) -> Dict[str, Any]: - element = self._mode_block(mode).get(element_key, {}) - return element if isinstance(element, dict) else {} + return _lookup_element(self._mode_block(mode), element_key) def _effective_mode(self, mode: Any) -> Optional[str]: """A per-call mode overrides the bound one; anything else uses it.""" @@ -942,24 +998,21 @@ class ElementStyleResolver: @staticmethod def _layout_element(block: Dict[str, Any], element_key: str) -> Dict[str, Any]: - """``block['layout'][element]``, or {} if absent anywhere.""" - layout = block.get('layout', {}) - if not isinstance(layout, dict): - return {} - element = layout.get(element_key, {}) - return element if isinstance(element, dict) else {} + """``block['layout'][element]``, or {} if absent anywhere. - @staticmethod - def _layout_axis(block: Dict[str, Any], element_key: str, + The layout block is where the naming drift lives, so the lookup + goes through the aliases: a plugin asking for ``score_text`` + offsets still finds the ``score`` its users configured. + """ + if not isinstance(block, dict): + return {} + return _lookup_element(block.get('layout'), element_key) + + @classmethod + def _layout_axis(cls, block: Dict[str, Any], element_key: str, axis: str) -> Any: """``block['layout'][element][axis]``, or None if absent anywhere.""" - layout = block.get('layout', {}) - if not isinstance(layout, dict): - return None - element = layout.get(element_key, {}) - if not isinstance(element, dict): - return None - return element.get(axis) + return cls._layout_element(block, element_key).get(axis) @staticmethod def _coerce_offset(value: Any, default: int, element_key: str, diff --git a/test/test_element_style.py b/test/test_element_style.py index b69afed4..692c5e64 100644 --- a/test/test_element_style.py +++ b/test/test_element_style.py @@ -24,6 +24,7 @@ import pytest from PIL import ImageFont from src.element_style import ( + alias_keys, ElementStyleResolver, _load_font_sized, native_bdf_size, @@ -981,3 +982,94 @@ class TestExtraFieldSchema: assert "null" in live["score_text"]["properties"]["visible"]["type"] scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"] assert scale["default"] is None and "null" in scale["type"] + +class TestAliasKeys: + """Two naming conventions collided as the scoreboards grew. + + Counted across the published schemas: the style block names elements + with a _text suffix (score_text, status_text), while the layout block + mostly uses the bare noun (score, date, odds) -- except status_text, + which kept the suffix in seven plugins and lost it in two. records vs + record splits seven to two the same way. + + Renaming config keys to fix that would orphan whatever offsets users had + already dialled in, so lookups try the alternatives instead. + """ + + @pytest.mark.parametrize("key,expected", [ + ("score_text", ("score_text", "score")), + ("status", ("status", "status_text")), + ("status_text", ("status_text", "status")), + ("records", ("records", "record")), + ("record", ("record", "records")), + ("rank_text", ("rank_text", "ranking", "rank")), + ("team_name", ("team_name", "team")), + ]) + def test_the_spellings_tried(self, key, expected): + assert alias_keys(key) == expected + + def test_the_exact_name_is_always_first(self): + for key in ("score_text", "status", "records", "home_logo"): + assert alias_keys(key)[0] == key + + def test_an_explicit_entry_replaces_the_suffix_rule(self): + """'records' must not also generate the meaningless 'records_text'.""" + assert "records_text" not in alias_keys("records") + + @pytest.mark.parametrize("key", ["", None, 5, [], {}]) + def test_nonsense_keys_yield_nothing(self, key): + assert alias_keys(key) == () + + +class TestAliasedLookup: + def _r(self, config, mode=None): + return ElementStyleResolver(config, {}, mode=mode) + + def test_a_compact_plugin_finds_offsets_saved_under_the_bare_noun(self): + """The migration case: a scoreboard moving to the compact form asks + for score_text offsets, and its users wrote layout.score.""" + config = {"customization": {"layout": {"score": {"y_offset": -3}}}} + assert self._r(config).offset("score_text") == (0, -3) + + def test_status_and_status_text_find_each_other(self): + written_long = {"customization": {"layout": {"status_text": {"x_offset": 2}}}} + written_short = {"customization": {"layout": {"status": {"x_offset": 4}}}} + assert self._r(written_long).offset("status") == (2, 0) + assert self._r(written_short).offset("status_text") == (4, 0) + + def test_record_and_records_find_each_other(self): + config = {"customization": {"layout": {"records": {"away_x_offset": 5}}}} + assert self._r(config).offset_value("record", "away_x_offset") == 5 + + def test_an_exact_match_beats_an_alias(self): + """Nothing changes for a config that already uses the right name.""" + config = {"customization": {"layout": { + "score": {"y_offset": 1}, "score_text": {"y_offset": 9}}}} + assert self._r(config).offset("score_text") == (0, 9) + assert self._r(config).offset("score") == (0, 1) + + def test_style_blocks_alias_too(self, style_schema_path): + config = {"customization": {"title": {"font_size": 13}}} + st = ElementStyleResolver( + config, defaults_from_schema_file(style_schema_path) + ).style("title_text", classic_size=8) + assert st.font_size == 13 + + def test_a_mode_block_aliases_too(self): + config = {"customization": { + "layout": {"score": {"y_offset": -3}}, + "modes": {"live": {"layout": {"score": {"y_offset": 7}}}}}} + assert self._r(config, "live").offset("score_text") == (0, 7) + + def test_an_unrelated_element_is_unaffected(self): + config = {"customization": {"layout": {"home_logo": {"x_offset": 3}}}} + r = self._r(config) + assert r.offset("home_logo") == (3, 0) + assert r.offset("away_logo") == (0, 0) + + def test_scale_is_found_through_an_alias(self, extra_schema_path): + config = {"customization": {"layout": {"score": {"scale": 2}}}} + st = ElementStyleResolver( + config, defaults_from_schema_file(extra_schema_path) + ).style("score_text") + assert st.scale == 2.0