feat(element-style): resolve elements under the names plugins actually use

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, detail_text), while the layout block mostly uses
the bare noun (score, date, time, 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.

A lookup now tries the exact name first and then the spellings that mean
the same thing. Exact-first is what makes this inert for any config that
already matches; the aliases only decide cases that resolved to nothing
before.

This is also what makes migrating to the compact declaration form safe.
That form uses one key for both blocks, so a scoreboard adopting it asks
for layout.score_text while its users have layout.score saved -- without
the aliases, every offset they had dialled in would silently become 0.

Applies to the style block, the layout block, the schema defaults and the
per-mode overrides, since the drift shows up in all four.

Not attempting to canonicalise on write: renaming keys in config.json
would break the plugins still reading the old spelling from their own
bundled code, and the drift costs a dict miss rather than correctness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 09:57:34 -04:00
co-authored by Claude Opus 5
parent 521f81272f
commit c5858f1e5e
2 changed files with 166 additions and 21 deletions
+74 -21
View File
@@ -706,6 +706,65 @@ def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
return None 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: def _coerce_bool(value: Any, default: bool) -> bool:
"""A real bool, or ``default``. Accepts the strings a form may post.""" """A real bool, or ``default``. Accepts the strings a form may post."""
if isinstance(value, bool): if isinstance(value, bool):
@@ -814,12 +873,10 @@ class ElementStyleResolver:
return customization if isinstance(customization, dict) else {} return customization if isinstance(customization, dict) else {}
def _element_config(self, element_key: str) -> Dict[str, Any]: def _element_config(self, element_key: str) -> Dict[str, Any]:
element = self._customization().get(element_key, {}) return _lookup_element(self._customization(), element_key)
return element if isinstance(element, dict) else {}
def _element_defaults(self, element_key: str) -> Dict[str, Any]: def _element_defaults(self, element_key: str) -> Dict[str, Any]:
defaults = self._defaults.get(element_key, {}) return _lookup_element(self._defaults, element_key)
return defaults if isinstance(defaults, dict) else {}
def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]: def _mode_block(self, mode: Optional[str]) -> Dict[str, Any]:
"""``customization.modes.<mode>``, or {} when there is no such block.""" """``customization.modes.<mode>``, or {} when there is no such block."""
@@ -833,8 +890,7 @@ class ElementStyleResolver:
def _mode_element_config(self, element_key: str, def _mode_element_config(self, element_key: str,
mode: Optional[str]) -> Dict[str, Any]: mode: Optional[str]) -> Dict[str, Any]:
element = self._mode_block(mode).get(element_key, {}) return _lookup_element(self._mode_block(mode), element_key)
return element if isinstance(element, dict) else {}
def _effective_mode(self, mode: Any) -> Optional[str]: def _effective_mode(self, mode: Any) -> Optional[str]:
"""A per-call mode overrides the bound one; anything else uses it.""" """A per-call mode overrides the bound one; anything else uses it."""
@@ -942,24 +998,21 @@ class ElementStyleResolver:
@staticmethod @staticmethod
def _layout_element(block: Dict[str, Any], def _layout_element(block: Dict[str, Any],
element_key: str) -> Dict[str, Any]: element_key: str) -> Dict[str, Any]:
"""``block['layout'][element]``, or {} if absent anywhere.""" """``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 {}
@staticmethod The layout block is where the naming drift lives, so the lookup
def _layout_axis(block: Dict[str, Any], element_key: str, 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: axis: str) -> Any:
"""``block['layout'][element][axis]``, or None if absent anywhere.""" """``block['layout'][element][axis]``, or None if absent anywhere."""
layout = block.get('layout', {}) return cls._layout_element(block, element_key).get(axis)
if not isinstance(layout, dict):
return None
element = layout.get(element_key, {})
if not isinstance(element, dict):
return None
return element.get(axis)
@staticmethod @staticmethod
def _coerce_offset(value: Any, default: int, element_key: str, def _coerce_offset(value: Any, default: int, element_key: str,
+92
View File
@@ -24,6 +24,7 @@ import pytest
from PIL import ImageFont from PIL import ImageFont
from src.element_style import ( from src.element_style import (
alias_keys,
ElementStyleResolver, ElementStyleResolver,
_load_font_sized, _load_font_sized,
native_bdf_size, native_bdf_size,
@@ -981,3 +982,94 @@ class TestExtraFieldSchema:
assert "null" in live["score_text"]["properties"]["visible"]["type"] assert "null" in live["score_text"]["properties"]["visible"]["type"]
scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"] scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"]
assert scale["default"] is None and "null" in scale["type"] 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