feat(common): sports consolidation stage 4 -- the identical sweep (plugin host, live scroll, display rules, font path) (#705)

Moves the code every scoreboard plugin carries identically into core: src.common.sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, with unit tests and a parity test against the ledmatrix-plugins copies (LEDMATRIX_PLUGINS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 10:10:22 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c6701ac00d
commit 3ad0438e75
14 changed files with 2004 additions and 1 deletions
+203
View File
@@ -0,0 +1,203 @@
"""src.common.sports_display_rules: behaviour and host contract.
Ported from the scoreboards' tests of the same methods (hockey's
test_switch_show_date_time.py, baseball's test_recent_game_date.py, the
test_non_favorite_live_duration.py copies), against stub hosts composed the
way the plugins compose ``SportsCore``: the new mixins first, then
``SportsCoreSharedMixin``.
"""
import ast
from pathlib import Path
import pytest
from src.common import sports_display_rules
from src.common.sports_display_rules import SportsCardOptionsMixin, SportsGameRulesMixin
from src.common.sports_shared import SportsCoreSharedMixin
class Core(SportsCardOptionsMixin, SportsGameRulesMixin, SportsCoreSharedMixin):
"""A SportsCore stand-in in the documented base order."""
def __init__(self, scroll_card=None, favorites=(), non_fav=0, duration=15,
passes=lambda g: True, quality="any"):
self.config = {"scroll_card": dict(scroll_card or {})}
self.favorite_teams = list(favorites)
self.non_favorite_live_game_duration = non_fav
self.game_display_duration = duration
self._passes = passes
self.other_games_min_quality = quality
self.coverage_checked = []
def _passes_other_filters(self, game):
return self._passes(game)
def _check_ranking_coverage(self, games):
self.coverage_checked.append(list(games))
def _is_favorite_game(self, game):
return game.get("home_abbr") in self.favorite_teams
# ---------------------------------------------------------------------------
# _card_option
# ---------------------------------------------------------------------------
class TestCardOption:
def test_ordinary_keys_read_through(self):
core = Core({"vs_text": "@"})
assert core._card_option("vs_text", "VS") == "@"
assert core._card_option("missing", 7) == 7
def test_both_lines_off_under_date_time_reads_as_both_on(self):
core = Core({"switch_show_date": False, "switch_show_time": False})
assert core._card_option("switch_show_date", True) is True
assert core._card_option("switch_show_time", True) is True
@pytest.mark.parametrize("center", ["vs", "none"])
def test_both_off_is_honoured_without_the_stack(self, center):
core = Core({"switch_show_date": False, "switch_show_time": False,
"switch_upcoming_center": center})
assert core._card_option("switch_show_date", True) is False
assert core._card_option("switch_show_time", True) is False
def test_one_line_off_is_honoured(self):
core = Core({"switch_show_date": False, "switch_show_time": True})
assert core._card_option("switch_show_date", True) is False
assert core._card_option("switch_show_time", True) is True
def test_inherit_follows_the_card_center(self):
core = Core({"switch_show_date": False, "switch_show_time": False,
"switch_upcoming_center": "inherit", "upcoming_center": "vs"})
assert core._card_option("switch_show_date", True) is False
def test_it_must_come_before_the_shared_mixin(self):
"""In the other order the shared reader wins and the rescue is lost."""
class Wrong(SportsCoreSharedMixin, SportsCardOptionsMixin):
pass
wrong = Wrong()
wrong.config = {"scroll_card": {"switch_show_date": False, "switch_show_time": False}}
assert wrong._card_option("switch_show_date", True) is False
assert Core._card_option is SportsCardOptionsMixin._card_option
def test_works_lifted_onto_a_stand_in(self):
"""Plugin tests lift it onto classes that are not SportsCore subclasses."""
class StandIn:
config = {"scroll_card": {"switch_show_date": False, "switch_show_time": False}}
_card_option = SportsCardOptionsMixin._card_option
_switch_upcoming_center = SportsCoreSharedMixin._switch_upcoming_center
assert StandIn()._card_option("switch_show_time", True) is True
class TestRecentDateText:
def test_numeric_default_is_the_extractors_text(self):
assert Core()._recent_date_text({"game_date": "9/23"}) == "9/23"
def test_follows_switch_date_format(self):
core = Core({"switch_date_format": "abbrev"})
assert core._recent_date_text({"game_date": "9/23"}) == "Sep 23"
def test_the_off_switch(self):
core = Core({"switch_recent_show_date": False})
assert core._recent_date_text({"game_date": "9/23"}) == ""
@pytest.mark.parametrize("game", [None, {}, {"game_date": None}])
def test_no_date_is_empty(self, game):
assert Core()._recent_date_text(game) == ""
# ---------------------------------------------------------------------------
# _filtered_or_all
# ---------------------------------------------------------------------------
class TestFilteredOrAll:
def test_keeps_what_passes(self):
games = [{"id": 1, "ok": True}, {"id": 2, "ok": False}]
core = Core(passes=lambda g: g["ok"])
assert core._filtered_or_all(games) == [games[0]]
def test_fails_open_when_nothing_passes(self):
games = [{"id": 1}, {"id": 2}]
assert Core(passes=lambda g: False)._filtered_or_all(games) == games
def test_ranking_coverage_is_checked_on_every_game(self):
games = [{"id": 1}, {"id": 2}]
core = Core(passes=lambda g: g["id"] == 1)
core._filtered_or_all(games)
assert core.coverage_checked == [games]
# ---------------------------------------------------------------------------
# _effective_live_duration
# ---------------------------------------------------------------------------
class TestEffectiveLiveDuration:
FAV = {"home_abbr": "DAL"}
OTHER = {"home_abbr": "NYG"}
def test_non_favourite_gets_the_shorter_dwell(self):
core = Core(favorites=["DAL"], non_fav=5, duration=20)
assert core._effective_live_duration(self.OTHER) == 5
assert core._effective_live_duration(self.FAV) == 20
def test_no_favourites_means_one_duration(self):
assert Core(non_fav=5, duration=20)._effective_live_duration(self.OTHER) == 20
@pytest.mark.parametrize("knob", [0, None])
def test_the_knob_off(self, knob):
core = Core(favorites=["DAL"], non_fav=knob, duration=20)
assert core._effective_live_duration(self.OTHER) == 20
def test_no_game(self):
assert Core(favorites=["DAL"], non_fav=5, duration=20)._effective_live_duration(None) == 20
def test_a_host_without_the_knob(self):
core = Core(favorites=["DAL"], duration=20)
del core.non_favorite_live_game_duration
assert core._effective_live_duration(self.OTHER) == 20
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads(class_name):
tree = ast.parse(Path(sports_display_rules.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name)
names = set()
for node in ast.walk(cls):
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"):
names.add(node.attr)
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "getattr" and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)):
names.add(node.args[1].value)
return names
class TestHostContract:
@pytest.mark.parametrize("mixin", [SportsCardOptionsMixin, SportsGameRulesMixin])
def test_every_host_read_is_documented(self, mixin):
needed = _self_reads(mixin.__name__) - set(dir(mixin))
undocumented = sorted(n for n in needed if f"``{n}" not in sports_display_rules.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixins_create_no_attributes(self):
for name in ("_format_game_date", "favorite_teams", "game_display_duration",
"_passes_other_filters", "_check_ranking_coverage", "_is_favorite_game"):
assert not hasattr(SportsCardOptionsMixin, name)
assert not hasattr(SportsGameRulesMixin, name)
for mixin in (SportsCardOptionsMixin, SportsGameRulesMixin):
assert "__init__" not in vars(mixin)
def test_the_two_define_no_name_in_common(self):
a = {n for n in vars(SportsCardOptionsMixin) if not n.startswith("__")}
b = {n for n in vars(SportsGameRulesMixin) if not n.startswith("__")}
assert a & b == set()
+120
View File
@@ -0,0 +1,120 @@
"""src.common.sports_font_path: the plugins' ``_resolve_font_path``, path for path.
The plugins' copy probes the core for ``FontManager._resolve_asset_path``
and falls back to its own install-root join; ``resolve_font_path`` is what
that comes to on a core that ships it. The bodies differ, so instead of an
AST comparison this runs both on the same paths -- found in the cwd only,
under the install root only, in both, absolute, and nowhere -- from a
temporary cwd, and requires the same string back. The plugin copies are read
from LEDMATRIX_PLUGINS (every sports.py and game_renderer.py that still has
one); without it, the comparison is against the copy transcribed below.
"""
import ast
import os
from pathlib import Path
import pytest
from src.common.font_layout import resolve_asset_path
from src.common.sports_font_path import resolve_font_path
REPO = Path(__file__).resolve().parents[1]
BUNDLED = "assets/fonts/PressStart2P-Regular.ttf"
#: ledmatrix-plugins 56c4f15, plugins/*-scoreboard/sports.py (docstring and
#: comments dropped). The same body is in every sports.py and game_renderer.py.
TRANSCRIBED = '''
def _resolve_font_path(path: str) -> str:
if os.path.exists(path):
return path
try:
import src.font_manager as _core_fonts
manager = getattr(_core_fonts, "FontManager", None)
resolver = getattr(manager, "_resolve_asset_path", None)
if resolver is not None:
resolved = resolver(path)
if resolved and os.path.exists(resolved):
return resolved
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
candidate = os.path.join(root, path)
if os.path.exists(candidate):
return candidate
except (ImportError, AttributeError, OSError):
return path
return path
'''
def _compile(source: str):
namespace = {"os": os}
exec(compile(source, "<plugin copy>", "exec"), namespace) # nosec B102 - test-only, source is a plugin file # nosemgrep
return namespace["_resolve_font_path"]
def _plugin_copies():
"""(label, function) for every plugin copy, or the transcription."""
raw = os.environ.get("LEDMATRIX_PLUGINS")
root = Path(raw) if raw else None
if root is not None and (root / "plugins").is_dir():
root = root / "plugins"
copies = []
if root is not None and root.is_dir():
for path in sorted(root.glob("*-scoreboard/*.py")):
if path.name not in ("sports.py", "game_renderer.py"):
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "_resolve_font_path":
copies.append((f"{path.parent.name}/{path.name}",
_compile(ast.unparse(node))))
if not copies:
copies.append(("transcribed", _compile(TRANSCRIBED)))
return copies
COPIES = _plugin_copies()
@pytest.fixture
def elsewhere(tmp_path, monkeypatch):
"""A cwd that is not the install root, holding one font of its own and a
shadow of a bundled one."""
(tmp_path / "cwd_only.ttf").write_bytes(b"x")
shadow = tmp_path / BUNDLED
shadow.parent.mkdir(parents=True)
shadow.write_bytes(b"x")
monkeypatch.chdir(tmp_path)
return tmp_path
def _cases(cwd: Path):
return [
"cwd_only.ttf", # in the cwd only
BUNDLED, # in both: the cwd wins
"assets/fonts/4x6-font.ttf", # under the install root only
str(REPO / BUNDLED), # absolute, exists
str(cwd / "missing.ttf"), # absolute, missing
"assets/fonts/no-such-font.ttf", # relative, nowhere
"", # empty
]
@pytest.mark.parametrize("label,copy", COPIES, ids=[c[0] for c in COPIES])
def test_same_answer_as_the_plugin_copy(label, copy, elsewhere):
for path in _cases(elsewhere):
assert resolve_font_path(path) == copy(path), (label, path)
def test_the_cwd_comes_first(elsewhere):
assert resolve_font_path(BUNDLED) == BUNDLED
assert resolve_asset_path(BUNDLED) != BUNDLED # what dropping it would change
def test_the_install_root_after_it(elsewhere):
found = resolve_font_path("assets/fonts/4x6-font.ttf")
assert Path(found).is_absolute() and Path(found).is_file()
def test_nowhere_comes_back_unchanged(elsewhere):
assert resolve_font_path("assets/fonts/no-such-font.ttf") == "assets/fonts/no-such-font.ttf"
+349
View File
@@ -0,0 +1,349 @@
"""src.common.sports_live_scroll: behaviour and host contract.
Ported from the eight scoreboards' test_live_scroll_refresh.py, against a
stub host carrying only the documented contract: what counts as a change
(the clock and the display pipeline's decoration do not), the rate limit and
its duty-cycle scaling, the marquee keeping its place across a rebuild, both
manager shapes (a league registry; afl/nrl's ``_get_manager``), and the
refresh that runs before the fingerprint.
"""
import ast
import threading
import time
from pathlib import Path
import pytest
from src.common import sports_live_scroll
from src.common.sports_live_scroll import SportsLiveScrollMixin
from src.common.sports_plugin_host import SportsPluginHostMixin
KEY = "live"
def game(gid="1", home="2", away="1", **extra):
g = {"id": gid, "home_score": home, "away_score": away,
"period": 3, "period_text": "3rd",
"clock": "12:04", "status_text": "12:04 - 3rd",
"is_final": False, "is_halftime": False,
"home_abbr": "AAA", "away_abbr": "BBB"}
g.update(extra)
return g
class _Manager:
def __init__(self, games=()):
self.live_games = list(games)
class _Helper:
"""Stands in for ScrollHelper, including the reset that makes this hard."""
def __init__(self):
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
self.total_scroll_width = 5000
self.scroll_complete = True
def set_scrolling_image(self, width=5000):
self.total_scroll_width = width
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
self.scroll_complete = False
class _Log:
def __init__(self):
self.lines = []
def info(self, msg, *args):
self.lines.append(msg % args)
def debug(self, msg, *args):
self.lines.append(msg % args)
class Host(SportsLiveScrollMixin):
"""The documented contract (registry shape)."""
LIVE_VOLATILE_FIELDS = frozenset({"clock", "status_text", "display_clock",
"league", "status"})
def __init__(self, games=(), helper=None, second_league_games=()):
self._league_registry = {
"primary": {"enabled": True, "managers": {"live": _Manager(games)}},
"disabled": {"enabled": False,
"managers": {"live": _Manager(second_league_games)}},
}
self._live_scroll_fingerprints = {}
self._live_scroll_rebuilt_at = {}
self._live_scroll_rebuild_cost = {}
self.logger = _Log()
self.dispatched = []
class _SM:
def get_scroll_display(self, mode_type):
return type("SD", (), {"scroll_helper": helper})()
self._scroll_manager = _SM() if helper else None
def _dispatch_switch_refresh(self, manager):
self.dispatched.append(manager)
def _games(self):
return self._league_registry["primary"]["managers"]["live"].live_games
def _set(self, games):
self._league_registry["primary"]["managers"]["live"].live_games = list(games)
def fresh(games=(), **kw):
host = Host(games, **kw)
host._note_live_scroll_built(KEY, "live", host._live_scroll_fingerprint())
host._live_scroll_rebuilt_at[KEY] = 0.0 # past the rate-limit floor
return host
class TestManagers:
def test_the_enabled_league_only(self):
host = Host([game()], second_league_games=[game(gid="9")])
assert [m.live_games for m in host._live_scroll_managers()] == [host._games()]
def test_one_league_by_name(self):
host = Host([game()])
assert len(host._live_scroll_managers("primary")) == 1
assert host._live_scroll_managers("nope") == []
def test_the_single_league_shape(self):
host = Host([game()])
host._league_registry = None
only = _Manager([game()])
host._get_manager = lambda mode: only if mode == "live" else None
assert host._live_scroll_managers() == [only]
def test_a_failing_accessor_is_no_managers(self):
host = Host()
host._league_registry = {}
def broken(mode):
raise KeyError(mode)
host._get_manager = broken
assert host._live_scroll_managers() == []
def test_neither_shape_is_inert(self):
host = Host()
host._league_registry = None
assert host._live_scroll_managers() == []
class TestWhatCountsAsAChange:
def test_nothing_changed(self):
assert not fresh([game()])._live_scroll_needs_rebuild(KEY, "live")
def test_the_clock_ticking_is_not_a_rebuild(self):
host = fresh([game()])
host._set([game(clock="11:58", status_text="11:58 - 3rd")])
assert not host._live_scroll_needs_rebuild(KEY, "live")
@pytest.mark.parametrize("change", [
{"home": "3"}, {"period_text": "OT", "period": 4}, {"is_final": True},
{"is_halftime": True}, {"situation": "power play"},
{"some_new_field_a_card_draws": "x"}])
def test_anything_else_is(self, change):
host = fresh([game()])
host._set([game(**change)])
assert host._live_scroll_needs_rebuild(KEY, "live")
def test_a_second_game_going_live(self):
host = fresh([game()])
host._set([game(), game(gid="2")])
assert host._live_scroll_needs_rebuild(KEY, "live")
def test_the_pipelines_decoration_is_not_a_change(self):
host = fresh([game()])
host._set([dict(game(), league="nhl", status={"state": "in"})])
assert not host._live_scroll_needs_rebuild(KEY, "live")
host = fresh([dict(game(), league="nhl", status={"state": "in"})])
host._set([game()])
assert not host._live_scroll_needs_rebuild(KEY, "live")
def test_the_hosts_volatile_fields_are_the_ones_read(self):
"""afl, nrl and soccer also ignore period_text; that stays theirs."""
class ClockInLabel(Host):
LIVE_VOLATILE_FIELDS = Host.LIVE_VOLATILE_FIELDS | {"period_text"}
host = ClockInLabel([game()])
host._note_live_scroll_built(KEY, "live")
host._live_scroll_rebuilt_at[KEY] = 0.0
host._set([game(period_text="3rd 11:58")])
assert not host._live_scroll_needs_rebuild(KEY, "live")
@pytest.mark.parametrize("mode", ["recent", "upcoming"])
def test_other_modes_never_rebuild(self, mode):
host = fresh([game()])
host._set([game(home="5")])
assert not host._live_scroll_needs_rebuild(KEY, mode)
def test_a_first_build_is_not_a_change(self):
assert not Host([game()])._live_scroll_needs_rebuild(KEY, "live")
def test_a_non_dict_game_still_fingerprints(self):
assert Host._fingerprint_games(["odd", None]) == tuple(sorted(
((("<not-a-dict>", "odd"),), (("<not-a-dict>", "None"),))))
class TestRateLimit:
def test_a_change_inside_the_floor_is_deferred_not_lost(self):
host = Host([game()])
host._note_live_scroll_built(KEY, "live", host._live_scroll_fingerprint())
host._set([game(home="3")])
assert not host._live_scroll_needs_rebuild(KEY, "live")
host._live_scroll_rebuilt_at[KEY] = 0.0
assert host._live_scroll_needs_rebuild(KEY, "live")
def test_an_expensive_rebuild_raises_the_floor(self):
host = fresh([game()])
host._live_scroll_rebuild_cost[KEY] = 0.463 # 0.463 x 20 = 9.3s
host._live_scroll_rebuilt_at[KEY] = time.time() - 6.0
host._set([game(home="9")])
assert not host._live_scroll_needs_rebuild(KEY, "live")
host._live_scroll_rebuilt_at[KEY] = time.time() - 10.0
assert host._live_scroll_needs_rebuild(KEY, "live")
def test_a_cheap_rebuild_stays_on_the_minimum(self):
host = fresh([game()])
host._live_scroll_rebuild_cost[KEY] = 0.029
host._live_scroll_rebuilt_at[KEY] = time.time() - 6.0
host._set([game(home="9")])
assert host._live_scroll_needs_rebuild(KEY, "live")
def test_the_constants(self):
assert SportsLiveScrollMixin.LIVE_SCROLL_REBUILD_MIN_SECONDS == 5.0
assert SportsLiveScrollMixin.LIVE_SCROLL_REBUILD_DUTY_DIVISOR == 20.0
def test_noting_a_non_live_build_records_nothing(self):
host = Host([game()])
host._note_live_scroll_built(KEY, "recent")
assert host._live_scroll_fingerprints == {} and host._live_scroll_rebuilt_at == {}
class TestPreservingScrollPosition:
def test_position_and_progress_survive(self):
helper = _Helper()
host = Host([game()], helper=helper)
helper.scroll_position = helper.total_distance_scrolled = 812.0
with host._preserving_scroll_position("live", active=True):
helper.set_scrolling_image()
assert helper.scroll_position == 812.0
assert helper.total_distance_scrolled == 812.0
assert helper.scroll_complete is False
assert any("rebuilt the live strip in place at position 812" in line
for line in host.logger.lines)
def test_clamped_to_a_shorter_strip(self):
helper = _Helper()
host = Host([game()], helper=helper)
helper.scroll_position = 1300.0
with host._preserving_scroll_position("live", active=True):
helper.set_scrolling_image(width=1200)
assert helper.scroll_position == 1199
def test_a_first_build_starts_at_zero(self):
helper = _Helper()
host = Host([game()], helper=helper)
helper.scroll_position = 500.0
with host._preserving_scroll_position("live", active=False):
helper.set_scrolling_image()
assert helper.scroll_position == 0.0
def test_no_scroll_manager_is_survivable_and_still_costed(self):
host = Host([game()], helper=None)
with host._preserving_scroll_position("live", active=True, scroll_key="nhl_live"):
pass
assert "nhl_live" in host._live_scroll_rebuild_cost
def test_the_cost_is_keyed_by_mode_without_a_scroll_key(self):
host = Host([game()])
with host._preserving_scroll_position("live", active=False):
pass
assert set(host._live_scroll_rebuild_cost) == {"live"}
class TestRefresh:
def test_every_live_manager_is_dispatched(self):
host = Host([game()])
host._refresh_live_scroll_managers()
assert host.dispatched == [host._league_registry["primary"]["managers"]["live"]]
def test_a_dispatch_error_is_logged_not_raised(self):
host = Host([game()])
def broken(manager):
raise RuntimeError("can't start new thread")
host._dispatch_switch_refresh = broken
host._refresh_live_scroll_managers()
assert any("Live scroll refresh skipped" in line for line in host.logger.lines)
def test_with_the_host_mixin_it_runs_off_thread(self):
"""The real pairing: _dispatch_switch_refresh from sports_plugin_host."""
class Plugin(SportsPluginHostMixin, SportsLiveScrollMixin):
LIVE_VOLATILE_FIELDS = Host.LIVE_VOLATILE_FIELDS
def __init__(self):
self.manager = _Manager([game()])
self._league_registry = {"a": {"enabled": True,
"managers": {"live": self.manager}}}
self.logger = _Log()
self.updated = threading.Event()
def _ensure_manager_updated(self, manager):
self.updated.set()
plugin = Plugin()
plugin._refresh_live_scroll_managers()
assert plugin.updated.wait(5)
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
tree = ast.parse(Path(sports_live_scroll.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsLiveScrollMixin")
names = set()
for node in ast.walk(cls):
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")):
names.add(node.attr)
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "getattr" and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)):
names.add(node.args[1].value)
return names
class TestHostContract:
def test_every_host_read_is_documented(self):
needed = _self_reads() - set(dir(SportsLiveScrollMixin))
undocumented = sorted(n for n in needed if f"``{n}" not in sports_live_scroll.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_attributes_of_its_own(self):
for name in ("logger", "LIVE_VOLATILE_FIELDS", "_live_scroll_fingerprints",
"_live_scroll_rebuilt_at", "_live_scroll_rebuild_cost",
"_dispatch_switch_refresh"):
assert not hasattr(SportsLiveScrollMixin, name)
assert "__init__" not in vars(SportsLiveScrollMixin)
def test_no_name_in_common_with_the_host_mixin(self):
ours = {n for n in vars(SportsLiveScrollMixin) if not n.startswith("__")}
theirs = {n for n in vars(SportsPluginHostMixin) if not n.startswith("__")}
assert ours & theirs == set()
+280
View File
@@ -0,0 +1,280 @@
"""src.common.sports_plugin_host: behaviour and host contract.
Ported from the scoreboards' own tests of the same methods
(test_vegas_priority_weight.py in each, test_switch_refresh_off_render_thread.py
in baseball, basketball, football, hockey, lacrosse and ufc), against a stub
host carrying only the documented contract. Each plugin's data shape is
covered: managers as attributes and in a dict (nrl, afl), favourite fighters
(ufc), team ids (nrl), cricket's nested sides, and the celebration snapshot.
"""
import ast
import threading
import time
from pathlib import Path
import pytest
from src.common import sports_plugin_host
from src.common.sports_plugin_host import SportsPluginHostMixin
class Host(SportsPluginHostMixin):
"""The documented contract, and nothing else the mixin could lean on."""
def __init__(self, live_priority=True, live_content=True, vegas=None,
enabled=True, dynamic=True):
self.has_live_priority = lambda: live_priority
self.has_live_content = lambda: live_content
self.global_config = {"display": {"vegas_scroll": vegas or {}}}
self.is_enabled = enabled
self.supports_dynamic_duration = lambda: dynamic
self.refreshed = []
self.release = threading.Event()
self.release.set()
def _ensure_manager_updated(self, manager):
self.release.wait(5)
self.refreshed.append(manager)
class LiveManager:
def __init__(self, games=(), favorites=(), attr="live_games",
fav_attr="favorite_teams", celebrating=None):
setattr(self, attr, list(games))
setattr(self, fav_attr, list(favorites))
if celebrating is not None:
self.active_celebration = {"game": celebrating, "started_at": 0}
def _game(home="DAL", away="PHI", **extra):
return {"home_abbr": home, "away_abbr": away, **extra}
# ---------------------------------------------------------------------------
# get_vegas_priority_weight
# ---------------------------------------------------------------------------
class TestVegasPriorityWeight:
def test_nothing_live_has_no_opinion(self):
assert Host(live_content=False).get_vegas_priority_weight() is None
assert Host(live_priority=False).get_vegas_priority_weight() is None
def test_a_live_game_without_a_favourite_gets_the_live_weight(self):
host = Host(vegas={"live_weight": 3, "favorite_live_weight": 5})
host.nfl_live = LiveManager([_game()], ["NYG"])
assert host.get_vegas_priority_weight() == 3
def test_a_favourite_playing_gets_the_favourite_weight(self):
host = Host(vegas={"live_weight": 3, "favorite_live_weight": 7})
host.nfl_live = LiveManager([_game()], ["dal"])
assert host.get_vegas_priority_weight() == 7
def test_defaults_when_the_config_says_nothing(self):
host = Host()
host.nfl_live = LiveManager([_game()], ["NYG"])
assert host.get_vegas_priority_weight() == 3
host.nfl_live.favorite_teams = ["DAL"]
assert host.get_vegas_priority_weight() == 5
def test_matching_ignores_case_and_space(self):
host = Host()
host.nfl_live = LiveManager([_game()], [" dAl "])
assert host.get_vegas_priority_weight() == 5
def test_managers_inside_a_dict_are_found(self):
"""nrl and afl keep their managers in ``self._managers``."""
host = Host()
host._managers = {"live": LiveManager([_game()], ["PHI"])}
assert host.get_vegas_priority_weight() == 5
def test_a_later_manager_is_still_found(self):
host = Host()
host.a = LiveManager([], ["DAL"])
host.b = LiveManager([_game()], ["DAL"])
assert host.get_vegas_priority_weight() == 5
def test_junk_in_the_game_list_is_skipped(self):
host = Host()
host.nfl_live = LiveManager(["not-a-dict", None], ["DAL"])
assert host.get_vegas_priority_weight() == 3
def test_an_exception_is_none_not_a_raise(self):
host = Host()
host.has_live_content = lambda: (_ for _ in ()).throw(RuntimeError("boom"))
assert host.get_vegas_priority_weight() is None
class TestFavoriteTeamIsLive:
def test_ufc_fighters(self):
host = Host()
host.ufc_live = LiveManager(
[{"fighter1_name": "Jon Jones", "fighter2_name": "Stipe Miocic"}],
["jon jones"], fav_attr="favorite_fighters")
assert host._favorite_team_is_live() is True
def test_nrl_team_ids(self):
host = Host()
host._managers = {"live": LiveManager([_game(home_id="17", away_id="9")], ["9"])}
assert host._favorite_team_is_live() is True
def test_cricket_nested_sides_match_by_substring(self):
host = Host()
host.cricket = LiveManager(
[{"teams": [{"name": "India Women", "abbr": "INDW"}, "junk"]}],
["india"], attr="live_matches")
assert host._favorite_team_is_live() is True
def test_a_celebrating_game_counts_after_it_leaves_live_games(self):
host = Host()
host.nfl_live = LiveManager([], ["DAL"], celebrating=_game())
assert host._favorite_team_is_live() is True
def test_no_favourites_or_only_blank_ones(self):
host = Host()
host.nfl_live = LiveManager([_game()], ["", None])
assert host._favorite_team_is_live() is False
host.nfl_live.favorite_teams = []
assert host._favorite_team_is_live() is False
def test_scan_targets_walk_attributes_and_dict_values(self):
host = Host()
inner = object()
host.plain = 1
host.bag = {"x": inner}
targets = list(host._favorite_scan_targets())
assert 1 in targets and host.bag in targets and inner in targets
# ---------------------------------------------------------------------------
# _dispatch_switch_refresh
# ---------------------------------------------------------------------------
class TestDispatchSwitchRefresh:
def test_runs_off_the_calling_thread(self):
host = Host()
host.release.clear()
manager = object()
started = time.monotonic()
host._dispatch_switch_refresh(manager)
assert time.monotonic() - started < 1.0 # did not wait on the update
assert host.refreshed == []
host.release.set()
host._switch_refresh_threads[id(manager)].join(5)
assert host.refreshed == [manager]
def test_one_refresh_per_manager_at_a_time(self):
host = Host()
host.release.clear()
manager = object()
host._dispatch_switch_refresh(manager)
first = host._switch_refresh_threads[id(manager)]
host._switch_refresh_at[id(manager)] = 0.0 # past the gap, still running
host._dispatch_switch_refresh(manager)
assert host._switch_refresh_threads[id(manager)] is first
host.release.set()
first.join(5)
assert host.refreshed == [manager]
def test_dispatches_are_rate_limited(self):
host = Host()
manager = object()
host._dispatch_switch_refresh(manager)
host._switch_refresh_threads[id(manager)].join(5)
host._dispatch_switch_refresh(manager) # inside the gap
assert host.refreshed == [manager]
host._switch_refresh_at[id(manager)] -= host._SWITCH_REFRESH_MIN_GAP_SECONDS
host._dispatch_switch_refresh(manager)
host._switch_refresh_threads[id(manager)].join(5)
assert host.refreshed == [manager, manager]
def test_threads_are_daemons_named_for_the_manager(self):
host = Host()
class NFLLiveManager:
pass
manager = NFLLiveManager()
host._dispatch_switch_refresh(manager)
thread = host._switch_refresh_threads[id(manager)]
thread.join(5)
assert thread.daemon and thread.name == "SwitchRefresh-NFLLiveManager"
def test_the_gap_is_a_class_setting(self):
assert SportsPluginHostMixin._SWITCH_REFRESH_MIN_GAP_SECONDS == 5.0
# ---------------------------------------------------------------------------
# The small ones
# ---------------------------------------------------------------------------
class TestSmallHelpers:
def test_content_type_is_multi(self):
assert Host().get_vegas_content_type() == "multi"
@pytest.mark.parametrize("enabled,dynamic,expected", [
(True, True, True), (True, False, False), (False, True, False)])
def test_dynamic_feature_enabled(self, enabled, dynamic, expected):
assert Host(enabled=enabled, dynamic=dynamic)._dynamic_feature_enabled() is expected
def test_total_games_takes_the_first_list(self):
manager = type("M", (), {"live_games": None, "games_list": [1, 2],
"recent_games": [1, 2, 3]})()
assert Host._get_total_games_for_manager(manager) == 2
assert Host._get_total_games_for_manager(None) == 0
assert Host._get_total_games_for_manager(object()) == 0
def test_manager_key(self):
class NHLRecentManager:
pass
assert Host._build_manager_key("nhl_recent", NHLRecentManager()) == "nhl_recent:NHLRecentManager"
assert Host._build_manager_key("nhl_recent", None) == "nhl_recent:None"
def test_it_overrides_base_plugin_when_listed_first():
from src.plugin_system.base_plugin import BasePlugin
class Plugin(SportsPluginHostMixin, BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
assert Plugin.get_vegas_content_type is SportsPluginHostMixin.get_vegas_content_type
assert Plugin.get_vegas_priority_weight is SportsPluginHostMixin.get_vegas_priority_weight
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads(module, class_name):
"""Every ``self.X`` / ``cls.X`` / ``getattr(self, "X")`` a mixin reads."""
tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == class_name)
names = set()
for node in ast.walk(cls):
if (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id in ("self", "cls")):
names.add(node.attr)
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "getattr" and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "self"
and isinstance(node.args[1], ast.Constant)):
names.add(node.args[1].value)
return names
class TestHostContract:
def test_every_host_read_is_documented(self):
needed = _self_reads(sports_plugin_host, "SportsPluginHostMixin") - set(dir(SportsPluginHostMixin))
undocumented = sorted(n for n in needed if f"``{n}" not in sports_plugin_host.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_attributes_of_its_own(self):
for name in ("logger", "is_enabled", "global_config", "_ensure_manager_updated",
"has_live_priority", "has_live_content", "supports_dynamic_duration"):
assert not hasattr(SportsPluginHostMixin, name)
assert "__init__" not in vars(SportsPluginHostMixin)
+176
View File
@@ -0,0 +1,176 @@
"""The stage 4 sports modules still match every plugin copy that remains.
``sports_plugin_host``, ``sports_live_scroll`` and ``sports_display_rules``
were copied from the scoreboard plugins, which delete their copies once they
floor on the release that ships these. Until each has, a copy that changes on
its own is a fix one side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and every method here
is compared with every plugin copy using ``scripts/sports_drift_report.py``'s
own normalisation -- the AST with docstrings, decorators and annotations
dropped, which is how the report decided these families are identical -- and,
because that normalisation drops them, the decorators are compared as well
(``@staticmethod`` vs ``@classmethod`` vs ``@contextmanager`` is behaviour).
Class constants are compared by value. A copy that is gone counts as adopted
when the plugin's file names the module. Without the variable this skips:
core CI has no plugins checkout.
``sports_font_path`` is compared by behaviour instead (its body is the
plugins' probe with the dead branches removed); see test_sports_font_path.py.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_display_rules, sports_live_scroll, sports_plugin_host
REPO = Path(__file__).resolve().parents[1]
ALL = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
NO_UFC = tuple(s for s in ALL if s != "ufc")
def _is_plugin_class(name: str) -> bool:
return name.endswith("ScoreboardPlugin")
#: (module, mixin, plugin file, which plugin classes may hold a copy,
#: {promoted name: the plugins that carry it}).
#: A name's carriers are the plugins whose copy was compared when it moved;
#: the others never had one, and must not grow one either.
PROMOTED = [
(sports_plugin_host, "SportsPluginHostMixin", "manager.py", _is_plugin_class,
{name: ALL for name in (
"_SWITCH_REFRESH_MIN_GAP_SECONDS", "_dispatch_switch_refresh",
"get_vegas_priority_weight", "_favorite_team_is_live",
"_favorite_scan_targets", "_favorite_scan_games", "_game_involves",
"get_vegas_content_type", "_dynamic_feature_enabled",
"_get_total_games_for_manager", "_build_manager_key")}),
(sports_live_scroll, "SportsLiveScrollMixin", "manager.py", _is_plugin_class,
{name: NO_UFC for name in (
"LIVE_SCROLL_REBUILD_MIN_SECONDS", "LIVE_SCROLL_REBUILD_DUTY_DIVISOR",
"_live_scroll_managers", "_refresh_live_scroll_managers",
"_live_scroll_fields", "_fingerprint_games", "_live_scroll_fingerprint",
"_live_scroll_needs_rebuild", "_note_live_scroll_built",
"_preserving_scroll_position")}),
(sports_display_rules, "SportsCardOptionsMixin", "sports.py",
lambda name: name == "SportsCore",
{"_card_option": NO_UFC, "_recent_date_text": NO_UFC}),
(sports_display_rules, "SportsGameRulesMixin", "sports.py",
lambda name: name in ("SportsCore", "SportsLive"),
{"_filtered_or_all": tuple(s for s in ALL if s != "football"),
"_effective_live_duration": NO_UFC}),
]
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare these modules against the plugin copies")
return root
def _members(tree, wanted):
"""{name: node} for the functions and constants of the classes ``wanted`` accepts."""
found = {}
for node in tree.body:
if not (isinstance(node, ast.ClassDef) and wanted(node.name)):
continue
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
found.setdefault(item.name, []).append(item)
elif isinstance(item, (ast.Assign, ast.AnnAssign)) and item.value is not None:
target = item.targets[0] if isinstance(item, ast.Assign) else item.target
if isinstance(target, ast.Name):
found.setdefault(target.id, []).append(item)
return found
def _fingerprint(node):
"""What must agree: the drift report's body digest plus the decorators,
or a constant's value."""
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return ("def", DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
return ("value", ast.dump(node.value))
def _core_members(module, mixin):
tree = ast.parse(Path(module.__file__).read_text(encoding="utf-8"))
return {name: nodes[0] for name, nodes in _members(tree, lambda n: n == mixin).items()}
CASES = [(module.__name__.rsplit(".", 1)[1], mixin, name)
for module, mixin, _file, _cls, carriers in PROMOTED
for name in sorted(carriers)]
def test_every_promoted_name_has_a_parity_case():
"""A method added to a mixin without a row above would go unchecked."""
for module, mixin, _file, _cls, carriers in PROMOTED:
assert sorted(_core_members(module, mixin)) == sorted(carriers), mixin
@pytest.mark.parametrize("module_name,mixin,name", CASES, ids=lambda v: str(v))
def test_every_remaining_plugin_copy_matches(module_name, mixin, name):
root = _plugins_root()
module, _mixin, filename, wanted, by_name = next(
row for row in PROMOTED if row[1] == mixin)
carriers = by_name[name]
ours = _fingerprint(_core_members(module, mixin)[name])
drifted, missing, extra = [], [], []
for sport in ALL:
path = root / f"{sport}-scoreboard" / filename
source = path.read_text(encoding="utf-8")
copies = _members(ast.parse(source), wanted).get(name, [])
if sport not in carriers:
if copies:
extra.append(sport)
continue
if not copies:
# Gone is fine once the plugin uses the module; otherwise the
# finder is not seeing its copy.
if module.__name__ not in source:
missing.append(sport)
continue
drifted += [sport for c in copies if _fingerprint(c) != ours]
assert missing == [], f"{name} not found in: {missing}"
assert extra == [], (
f"{name} appeared in {extra}, which had no copy when it moved; "
f"decide whether {module_name} should cover it")
assert drifted == [], (
f"{name} in {module_name} differs from the copy in: {drifted}. "
f"Port the change to both, or stop treating it as shared.")
def test_the_drift_report_still_calls_them_identical():
"""The report's own verdict, per family, while any copy is left."""
root = _plugins_root()
families = DRIFT.build(root, ("sports.py", "manager.py"))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
not_identical = []
for _module, _mixin, filename, _cls, carriers in PROMOTED:
for name in carriers:
row = rows.get((filename, name))
if row is not None and row["worst_class_variants"] != 1:
not_identical.append(f"{filename}::{name}")
assert not_identical == []