Files
LEDMatrix/test/test_sports_shared.py
ChuckandClaude Opus 5 81e1bc596f fix(sports): stop the idle back-off sleeping through a kickoff (#599)
* fix(sports): stop the idle back-off sleeping through a kickoff

A league with no live games backs its poll off as empty checks mount,
capped by live_idle_max_interval. The escalation counts empty looks and
nothing else, so a league three hours before kickoff is indistinguishable
from one three months out of season. Both reach the ceiling -- and the
ceiling then *is* the blind spot.

Measured on two rigs on 2026-09-19: gaps of up to 928s between looks, ten
of them at or above 900s. Reproduced in the wild on 2026-09-20, where an
unpatched rig sat for fifteen minutes with eight NFL games in progress and
had not noticed any of them. That is the "it doesn't pick up new live
games until I restart it" report -- restarting being the one thing that
forces an immediate look.

The clamp costs no extra request: the live fetch already downloads the
whole day's scoreboard, upcoming games included, so the earliest start
still ahead of us falls out of the payload the manager already has.
Before a kickoff the wait is shortened so it cannot run past it; just
after one, the live cadence is held for _KICKOFF_GRACE_SECONDS, because a
provider that has not yet flipped the status would otherwise look like
another empty check and escalate the back-off again, right when the game
is starting.

The grace window needed a second pass. A soak caught it as dead code: the
just-passed kickoff was replaced by the next fixture on the card the
instant it passed, `now < start` went true again, and the back-off
returned to its ceiling. Observed live -- the rig polled at 13:00:45,
found nothing because ESPN had not flipped the status, then went quiet for
a quarter of an hour. A kickoff inside the grace window is now kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(sports): pin absolute tolerances and correct a wrong grace expectation

pytest.approx defaults to a relative tolerance. On a unix timestamp that is
roughly 1790 seconds, so every kickoff assertion here was effectively
vacuous -- it called a kickoff half an hour away "equal". All seven now
pin abs=1.

That hid a wrong expectation. test_an_earlier_kickoff_still_wins_during_the_grace
asserted a game ten minutes out should displace one that kicked off moments
ago. It should not, and the code does not: while the grace holds, the wait
is the live cadence (30s), which is strictly tighter than clamping to the
nearer kickoff would give (~600s). Letting the candidate win would set a
ten-minute wait at the exact moment games are starting -- the dead grace
window this branch exists to fix.

The test now pins the real behaviour plus the safety property that makes it
correct, and is renamed to say what it checks.

Reported by CodeRabbit on the PR. The finding was right that code and test
disagreed; the suggested fix was the wrong way to resolve it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:25:33 -04:00

625 lines
28 KiB
Python

"""The shared sports.py mixins: their host contract, and _plugin_dir.
Two things are worth testing here and the rest is not. The 45 method bodies
moved verbatim from the plugins, so they are covered by the plugins' own tests
and by 176 byte-identical safety-harness renders. What is genuinely new is:
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
mixin, or a host that does not happen to declare it raises AttributeError at
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
failed -- it would only have bitten a ninth. The test derives the list rather
than restating it, so the next omission fails here instead of in the field.
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
sports.py these methods found config_schema.json with ``__file__``; here that
is src/common/, so the plugin directory has to be recovered from the
instance -- and getting it wrong is silent, costing grid-snapped font sizes
(measured at 81% anti-aliased edges) rather than raising.
"""
import ast
import os
import sys
import time
import types
from abc import ABC
from datetime import datetime, timedelta, timezone
import pytest
from src.common import sports_shared
from src.common.sports_shared import (
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
def _constants_read_by_mixins():
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
tree = ast.parse(open(sports_shared.__file__).read())
names = set()
for node in ast.walk(tree):
if (isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr.upper() == node.attr):
names.add(node.attr)
return names
class TestHostContract:
def test_every_constant_read_is_also_defined(self):
# Otherwise a host that does not declare it raises AttributeError the
# first time the code path runs -- which for these is mid-render.
missing = sorted(
name for name in _constants_read_by_mixins()
if not any(hasattr(m, name) for m in MIXINS))
assert missing == [], (
f"read but never defined on a mixin: {missing}. Give each a default "
f"on SportsCoreSharedMixin and document it in the module docstring.")
@pytest.mark.parametrize("name,expected", [
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
("_RANKING_COVERAGE_SECONDS", 3600),
("_SCORE_PROBE_TEXT", "00-00"),
("_FONT_DESIGN_HEIGHT", 32),
])
def test_defaults_match_what_the_plugins_ship(self, name, expected):
# The eight plugins declare their own copies, which shadow these. The
# values must still agree, or a ninth plugin inheriting the default
# behaves differently from the eight.
assert getattr(SportsCoreSharedMixin, name) == expected
def test_only_the_recent_mixin_carries_a_constructor(self):
# SportsCore and SportsLive keep their own __init__ -- those differ per
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
# so it moved with the rest; that is deliberate, not an oversight.
assert "__init__" not in SportsCoreSharedMixin.__dict__
assert "__init__" not in SportsLiveSharedMixin.__dict__
assert "__init__" in SportsRecentSharedMixin.__dict__
def test_the_recent_constructor_still_chains_to_the_host(self):
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
Moving a body containing bare ``super()`` is the one move that can
change meaning: the compiler closes over __class__ = the defining class,
so after the move that is SportsRecentSharedMixin rather than the
plugin's SportsRecent. It still works only because the mixin is listed
first, leaving the host class next in the MRO -- adopt it in the other
order and the chain silently skips the host's __init__.
"""
calls = []
class Host:
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
calls.append(sport_key)
self.mode_config = {}
class Recent(SportsRecentSharedMixin, Host):
pass
inst = Recent({}, None, None, None, "nhl")
assert calls == ["nhl"], "the host constructor must still run"
assert inst.current_game_index == 0
assert inst.update_interval == 3600
assert inst._zero_clock_timestamps == {}
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
# The failure mode the ordering above prevents, pinned so nobody
# "tidies" the base list.
calls = []
class Host:
def __init__(self, *a):
calls.append(a)
self.mode_config = {}
class Wrong(Host, SportsRecentSharedMixin):
pass
Wrong({}, None, None, None, "nhl")
# Host.__init__ wins and the mixin's setup never runs at all.
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
class _Host(SportsCoreSharedMixin):
pass
def _write_plugin(tmp_path, name="fakeplug", schema=True):
"""A throwaway package on sys.path, with or without a config_schema.json."""
d = tmp_path / name
d.mkdir()
(d / "__init__.py").write_text("")
(d / "mod.py").write_text("class Leaf:\n pass\n")
if schema:
(d / "config_schema.json").write_text(
'{"properties": {"customization": {"properties": '
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
return d
class TestPluginDir:
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._plugin_dir() == str(d)
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
# "abc" rather than the plugin -- which is exactly what the plugins'
# own tests build. Walking the MRO is what steps past it.
d = _write_plugin(tmp_path, "abcplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("abcplug.mod", fromlist=["Leaf"])
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
pass
synthetic = type("Probe", (Base,), {})
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "noschema", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("noschema.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
# None rather than a wrong guess: _schema_font_size then caches empty
# and every element keeps its own default.
assert host._plugin_dir() is None
def test_it_never_returns_the_core_module_directory(self):
# The bug this replaced: __file__ pointed at src/common/, so the schema
# was never found and font sizes silently stopped snapping to the grid.
host = _Host()
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
assert host._plugin_dir() != core_common
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
# Namespace packages and some frozen/dynamic modules have no __file__.
ghost = types.ModuleType("ghost_no_file")
if hasattr(ghost, "__file__"):
del ghost.__file__
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
assert cls.__new__(cls)._plugin_dir() is None
class TestSchemaFontSize:
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "sizeplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") == 16
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "unkplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("unkplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("nonesuch") is None
def test_an_empty_key_is_none_without_touching_the_disk(self):
assert _Host()._schema_font_size("") is None
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "bareplug", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("bareplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") is None
class _LiveHost(SportsLiveSharedMixin):
"""The documented contract for the live mixin, and nothing else."""
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
self.no_data_interval = no_data_interval
self.stale_game_timeout = stale_game_timeout
self.game_update_timestamps = {}
self._over = set(over)
class _L:
def __getattr__(self, _n):
return lambda *a, **k: None
self.logger = _L()
def _is_game_really_over(self, game):
return game.get("id") in self._over
class TestLiveMixin:
"""These three moved to the core, so they are tested here.
They were already covered by hockey's and lacrosse's own tests, but those
two plugins disable live mode in their safety-harness fixtures, so the 176
renders never exercise this path. Testing the mixin directly means the
coverage no longer depends on which plugin happens to have a unit test.
"""
def test_a_stale_game_is_dropped_and_forgotten(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
h._detect_stale_games(games)
assert games == []
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
def test_a_fresh_game_survives(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_never_seen_is_not_treated_as_stale(self):
# last_seen 0 means "no reading", not "seen at the epoch".
h = _LiveHost()
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_with_no_id_is_left_alone(self):
h = _LiveHost()
games = [{"home_abbr": "H"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_finished_game_is_dropped_even_when_fresh(self):
h = _LiveHost(over=("g2",))
games = [{"id": "g1"}, {"id": "g2"}]
h._detect_stale_games(games)
assert [g["id"] for g in games] == ["g1"]
def test_removing_several_does_not_skip_any(self):
# It iterates a copy for exactly this reason; mutating the live list
# while looping would step over the element after each removal.
h = _LiveHost(over=("g1", "g2", "g3"))
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
h._detect_stale_games(games)
assert games == []
def test_the_idle_interval_escalates_with_the_empty_streak(self):
h = _LiveHost(no_data_interval=60)
h.live_idle_max_interval = 100000
base = h._idle_live_interval()
h._empty_live_streak = 6
short = h._idle_live_interval()
h._empty_live_streak = 24
long = h._idle_live_interval()
assert base < short < long
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
# base > ceiling is a reachable config: the two settings are
# independent integers with no cross-validation. Returning base
# unclamped made the wait SHRINK as the streak grew.
h = _LiveHost(no_data_interval=3600)
h.live_idle_max_interval = 900
h._empty_live_streak = 0
assert h._idle_live_interval() == 900
h._empty_live_streak = 24
assert h._idle_live_interval() == 900
# ---- the back-off must not sleep through a kickoff -------------------
#
# The escalation counts empty looks and nothing else, so an in-season
# league a few hours before kickoff looks identical to one months out of
# season. Both reach the ceiling, and the ceiling then becomes the blind
# spot. Measured on two rigs on 2026-09-19: gaps of up to 928s between
# looks, ten of them at or above 900s. That is the "it doesn't pick up new
# live games until I restart it" report -- restarting being the one thing
# that forces an immediate look.
def _idle_host(self, streak=24, no_data_interval=300, ceiling=900):
h = _LiveHost(no_data_interval=no_data_interval)
h.live_idle_max_interval = ceiling
h.update_interval = 30
h._empty_live_streak = streak
return h
def test_without_a_known_kickoff_the_escalation_is_unchanged(self):
h = self._idle_host()
assert h._idle_live_interval() == 900
def test_the_wait_never_runs_past_a_known_kickoff(self):
h = self._idle_host()
h._next_scheduled_start_ts = time.time() + 120
# 900s would sleep straight through a kickoff 2 minutes out.
assert 30 <= h._idle_live_interval() <= 120
def test_a_distant_kickoff_does_not_shorten_the_wait(self):
h = self._idle_host()
h._next_scheduled_start_ts = time.time() + 6 * 3600
assert h._idle_live_interval() == 900
def test_the_wait_never_drops_below_the_live_cadence(self):
# A kickoff one second away must not turn into a one-second poll.
h = self._idle_host()
h._next_scheduled_start_ts = time.time() + 1
assert h._idle_live_interval() == h.update_interval
def test_just_after_kickoff_it_holds_the_live_cadence(self):
# ESPN does not flip a game to in-progress exactly at kickoff, and
# each early look would otherwise escalate the back-off further,
# precisely when the game is starting.
h = self._idle_host()
h._next_scheduled_start_ts = time.time() - 60
assert h._idle_live_interval() == h.update_interval
def test_long_after_a_kickoff_the_escalation_returns(self):
h = self._idle_host()
h._next_scheduled_start_ts = time.time() - (sports_shared._KICKOFF_GRACE_SECONDS + 60)
assert h._idle_live_interval() == 900
def test_a_pending_game_is_recorded_as_the_next_kickoff(self):
h = self._idle_host()
soon = datetime.now(timezone.utc) + timedelta(minutes=20)
h._note_scheduled_start_candidate({"is_live": False, "start_time_utc": soon})
assert h._next_scheduled_start_ts == pytest.approx(soon.timestamp(), abs=1)
def test_the_earliest_pending_game_wins(self):
h = self._idle_host()
later = datetime.now(timezone.utc) + timedelta(hours=4)
sooner = datetime.now(timezone.utc) + timedelta(minutes=30)
h._note_scheduled_start_candidate({"start_time_utc": later})
h._note_scheduled_start_candidate({"start_time_utc": sooner})
assert h._next_scheduled_start_ts == pytest.approx(sooner.timestamp(), abs=1)
# ... and a later one does not push the stored kickoff back out.
h._note_scheduled_start_candidate({"start_time_utc": later})
assert h._next_scheduled_start_ts == pytest.approx(sooner.timestamp(), abs=1)
def test_a_live_game_is_not_a_kickoff_to_wait_for(self):
h = self._idle_host()
start = datetime.now(timezone.utc) + timedelta(minutes=5)
h._note_scheduled_start_candidate({"is_live": True, "start_time_utc": start})
h._note_scheduled_start_candidate({"is_halftime": True, "start_time_utc": start})
assert getattr(h, "_next_scheduled_start_ts", None) is None
def test_a_start_already_past_is_not_recorded(self):
h = self._idle_host()
gone = datetime.now(timezone.utc) - timedelta(minutes=5)
h._note_scheduled_start_candidate({"start_time_utc": gone})
assert getattr(h, "_next_scheduled_start_ts", None) is None
def test_a_stored_kickoff_that_passed_is_replaced_not_pinned(self):
# A postponed game must not hold the cadence to a kickoff that never
# happens; the next candidate offered replaces it.
h = self._idle_host()
h._next_scheduled_start_ts = time.time() - 10_000
later = datetime.now(timezone.utc) + timedelta(hours=3)
h._note_scheduled_start_candidate({"start_time_utc": later})
assert h._next_scheduled_start_ts == pytest.approx(later.timestamp(), abs=1)
def test_junk_candidates_are_ignored_rather_than_raising(self):
h = self._idle_host()
for junk in (None, "not-a-dict", 42, {}, {"start_time_utc": None},
{"start_time_utc": "2026-09-20T13:00Z"},
{"start_time_utc": object()}):
h._note_scheduled_start_candidate(junk)
assert getattr(h, "_next_scheduled_start_ts", None) is None
def test_a_host_without_update_interval_still_clamps(self):
# The mixin documents no update_interval requirement.
h = _LiveHost(no_data_interval=300)
h.live_idle_max_interval = 900
h._empty_live_streak = 24
h._next_scheduled_start_ts = time.time() - 60
assert h._idle_live_interval() == sports_shared._KICKOFF_POLL_FLOOR
# The regression the 2026-09-20 soak caught: these two methods have to be
# tested together. Set _next_scheduled_start_ts by hand and the grace
# window looks fine; drive it through the candidate logic the way the live
# loop does, and the just-passed kickoff was replaced by the next one on
# the card, `now < start` went true again, and the back-off returned to its
# ceiling at exactly the wrong moment.
def test_a_just_passed_kickoff_is_not_replaced_by_the_next_one(self):
h = self._idle_host()
now = time.time()
h._next_scheduled_start_ts = now - 45 # kicked off 45s ago
later = datetime.now(timezone.utc) + timedelta(hours=3)
h._note_scheduled_start_candidate({"start_time_utc": later})
assert h._next_scheduled_start_ts == pytest.approx(now - 45, abs=1)
def test_and_so_the_grace_window_actually_fires(self):
# The end-to-end property: a kickoff moments ago keeps the live cadence
# even while the rest of the day's card is being offered.
h = self._idle_host()
h._next_scheduled_start_ts = time.time() - 45
for hours in (3, 4, 7):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.now(timezone.utc) + timedelta(hours=hours)})
assert h._idle_live_interval() == h.update_interval
def test_once_the_grace_expires_the_next_kickoff_takes_over(self):
h = self._idle_host()
h._next_scheduled_start_ts = time.time() - (sports_shared._KICKOFF_GRACE_SECONDS + 60)
later = datetime.now(timezone.utc) + timedelta(hours=3)
h._note_scheduled_start_candidate({"start_time_utc": later})
assert h._next_scheduled_start_ts == pytest.approx(later.timestamp(), abs=1)
def test_a_nearer_kickoff_does_not_dislodge_the_grace(self):
# A game ten minutes out does NOT displace one that kicked off moments
# ago, and that is deliberate: while the grace holds we poll at the live
# cadence, which is strictly tighter than anything clamping to the
# nearer kickoff would give. Letting the candidate win here would set
# the wait to ~600s at the exact moment games are starting -- the dead
# grace window this change exists to fix.
#
# The original form of this test asserted the opposite and still passed,
# because pytest.approx's default tolerance on a unix timestamp is about
# 1790 seconds. Every timestamp assertion here now pins abs=1.
h = self._idle_host()
passed = time.time() - 45
h._next_scheduled_start_ts = passed
soon = datetime.now(timezone.utc) + timedelta(minutes=10)
h._note_scheduled_start_candidate({"start_time_utc": soon})
assert h._next_scheduled_start_ts == pytest.approx(passed, abs=1)
# The safety property that makes it correct: 30s beats 600s.
assert h._idle_live_interval() == h.update_interval
def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost()
h._note_live_fetch(False)
h._note_live_fetch(False)
assert h._empty_live_streak == 2
h._note_live_fetch(True)
assert h._empty_live_streak == 0
def test_the_streak_starts_from_absent_state(self):
# The host is not required to pre-declare _empty_live_streak.
h = _LiveHost()
assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False)
assert h._empty_live_streak == 1
class TestPluginDirIsToldNotDeduced:
"""The regression that shipped: _plugin_dir returned None in production.
The first version walked the MRO for a class whose module sits beside a
config_schema.json. That passes when a test imports the plugin directly --
which is how it was verified -- and returns None under the real plugin
loader, which imports modules by a path that leaves no such entry on the
MRO.
Silent, and expensive: no schema means _schema_font_size returns None for
every element, so a configured size equal to the schema default stops
looking like a default, is treated as a deliberate choice, and skips the
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
instead of 4px. On a 256x64 panel that made the odds, the records and the
date row illegible. A user counted the pixels; no gate here caught it.
"""
def test_a_declared_directory_is_used(self, tmp_path):
d = _write_plugin(tmp_path, "declared")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._plugin_dir() == str(d)
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
"""The production case: nothing on the MRO sits beside a schema."""
d = _write_plugin(tmp_path, "loaderstyle")
# A class whose module is not importable by name, as the loader produces.
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
assert cls.__new__(cls)._plugin_dir() == str(d), (
"the declared directory must win when the MRO walk cannot help")
def test_without_it_the_mro_walk_would_have_failed(self):
# Pin the precondition, so this test still means something if the
# fallback is ever changed.
cls = type("Orphan", (SportsCoreSharedMixin,), {})
cls.__module__ = "not.a.real.module"
assert cls.__new__(cls)._plugin_dir() is None
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
# A stale or wrong path must not shadow the fallback.
empty = tmp_path / "noschema"
empty.mkdir()
d = _write_plugin(tmp_path, "realone")
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
def test_the_font_size_consequence(self, tmp_path):
"""End to end: a declared directory restores the schema lookup."""
d = _write_plugin(tmp_path, "sizeconseq")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._schema_font_size("score") == 16, (
"without the schema this is None, which is what made a configured "
"size look user-chosen and skipped the grid snap")
class TestUnshareKeepsTheLayoutEngine:
"""``_unshare_element_fonts`` rebuilds a face; the rebuild must be pinned.
This body did move verbatim from the plugins, but it is the one that
rebuilds a font, and it rebuilt through bare ``ImageFont.truetype`` --
taking PIL's default layout engine rather than the one
``src.common.font_layout`` pins. Raqm and Basic disagree on fractional
advances, so a re-instantiated face could measure differently from the
shared face it replaced, on any machine where Raqm is installed. That is
invisible on a Raqm-less runner, which is why this asserts on the loader
rather than on the resulting engine value.
"""
def _host(self):
import logging
return type("H", (SportsCoreSharedMixin,), {
"logger": logging.getLogger("test_sports_shared")})()
def _face(self, size=10):
from src.common.font_layout import load_truetype
return load_truetype(
os.path.join("assets", "fonts", "PressStart2P-Regular.ttf"), size)
def test_rebuild_goes_through_the_pinned_loader(self, monkeypatch):
import src.common.font_layout as fl
# Built before patching: _face() uses the same loader, so patching
# first would let the fixture's own call satisfy the assertion.
shared = self._face()
calls = []
real = fl.load_truetype
def spy(font, size, **kwargs):
calls.append((font, size))
return real(font, size, **kwargs)
monkeypatch.setattr(fl, "load_truetype", spy)
fonts = {"score": shared, "time": shared}
self._host()._unshare_element_fonts(fonts)
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
assert calls, "the rebuild must go through the pinned loader"
def test_rebuilt_face_measures_like_the_one_it_replaced(self):
shared = self._face()
fonts = {"score": shared, "time": shared}
self._host()._unshare_element_fonts(fonts)
assert fonts["time"].getlength("88-88") == shared.getlength("88-88")
class TestPromotedLayoutOffset:
"""_get_layout_offset moved here so every scoreboard reads offsets the
way the scroll card does.
Each plugin still carries its own copy in its bundled sports.py, which
wins by MRO. That is the migration property: adopting this is a
deletion in the plugin, and until that deletion nothing changes.
"""
CONFIG = {"customization": {
"layout": {"score": {"y_offset": -3}},
"modes": {"recent": {"layout": {"score": {"y_offset": 9}}}},
}}
def _host(self, **attrs):
return type("H", (SportsCoreSharedMixin,),
dict({"config": self.CONFIG}, **attrs))()
def test_it_reads_the_configured_offset(self):
assert self._host()._get_layout_offset("score", "y_offset") == -3
def test_it_resolves_through_an_alias(self):
"""What a plugin gains by deleting its own copy: the style block
says score_text where the layout block says score."""
assert self._host()._get_layout_offset("score_text", "y_offset") == -3
def test_skin_mode_selects_the_per_mode_offset(self):
assert self._host(SKIN_MODE="recent")._get_layout_offset(
"score", "y_offset") == 9
def test_a_plugins_own_copy_still_wins(self):
"""Until a plugin deletes its copy, this changes nothing for it."""
host = self._host(
_get_layout_offset=lambda self, element, axis, default=0: 99)
assert host._get_layout_offset("score", "y_offset") == 99
def test_an_unset_offset_is_the_default(self):
assert self._host()._get_layout_offset("nothing", "y_offset", 5) == 5