Files
LEDMatrix/test/test_sports_plugin_host.py
T
ChuckandClaude Opus 5.5 11a7ca9671 feat(common): sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, promoted from the scoreboards (sports consolidation stage 4)
The identical sweep: method families every scoreboard carrying them has as
an identical copy (executable AST, docstrings stripped, decorators compared),
re-measured at ledmatrix-plugins 56c4f15. Four new hardware-free modules;
the bodies are the plugins', with type annotations and type comments for
the mypy ratchet.

- src/common/sports_plugin_host.py: SportsPluginHostMixin, ten helpers of
  the scoreboard plugin class (manager.py) identical in all nine:
  _dispatch_switch_refresh (with _SWITCH_REFRESH_MIN_GAP_SECONDS),
  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. Listed before BasePlugin, whose defaults two of them
  override.
- src/common/sports_live_scroll.py: SportsLiveScrollMixin, the eight
  manager.py methods that rebuild a live scroll strip mid-cycle (all eight
  scoreboards with a strip; ufc has none), with the two rebuild-rate
  constants. LIVE_VOLATILE_FIELDS differs (afl/nrl/soccer add period_text)
  and stays in each plugin as host contract.
- src/common/sports_display_rules.py: SportsCardOptionsMixin (_card_option,
  _recent_date_text; the eight team scoreboards; must precede
  SportsCoreSharedMixin, whose _card_option it wraps) and
  SportsGameRulesMixin (_filtered_or_all, all but football;
  _effective_live_duration, all but ufc). Split by carrier so ufc does not
  gain a _card_option override it never had.
- src/common/sports_font_path.py: resolve_font_path, what the 17
  _resolve_font_path copies (nine sports.py, eight game_renderer.py) return
  on a core that ships it: the cwd first, then resolve_asset_path. Not
  resolve_asset_path alone, which skips the cwd.

Left in the plugins: _get_timezone, _extract_game_details and _fetch_data
(per-plugin import, abstract contract), _schema_font_size and
_resolve_font_size (each plugin's _SCHEMA_PATH), and the families carried by
seven plugins or fewer.

Tests: behaviour ported from the plugins' own tests against stub hosts with
exactly the documented contract, a host-contract test per mixin, and
test_sports_stage4_parity.py, which with LEDMATRIX_PLUGINS set compares
every promoted method and constant with every plugin copy using
scripts/sports_drift_report.py's normalisation plus decorators and values
(27 pass against 56c4f15; mutation-checked: a changed body, a swapped
decorator, a changed constant, a dropped @contextmanager and an unlisted
new method each fail it). test_sports_font_path.py runs both on the same
paths from a temporary cwd. All four modules are on the mypy ratchet, in
src/common/README.md, the CHANGELOG's Unreleased section (module entries
for the sunset floor; the version is not bumped) and SPORTS_UNIFICATION.
Nothing in core uses them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:56:51 -04:00

281 lines
11 KiB
Python

"""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)