mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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:
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user