Merge origin/main into chore/remove-ipc-mailboxes

Conflicts were additive only: CHANGELOG.md (this PR's Unreleased section
now sits above main's 3.8.x release notes) and test/js/run_all.js (both
new unit suites kept). display.py and api_v3/__init__.py merged cleanly
with the stack-trace-exposure fix (#778).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-15 10:57:51 -04:00
co-authored by Claude Sonnet 5.5
21 changed files with 2109 additions and 37 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_overview_reconciliation_poll.js',
'unit/test_display_partial_ids.js',
'unit/test_general_web_login_token.js',
'unit/test_on_demand_starting.js'];
'unit/test_on_demand_starting.js',
'unit/test_general_display_autostart.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
@@ -0,0 +1,77 @@
// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in
// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart
// to /api/v3/system/action and puts the box back when that is refused --
// including the route's HTTP-200 {status: "error"} answer for a failed
// systemctl (e.g. the sudo rule is missing).
//
// Fake fetch, no jsdom and no server needed.
const path = require('path');
const { pathToFileURL } = require('url');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
async function run(checked, answer) {
const { createApi } = await load('core/api.js');
const general = await load('pages/general.js');
const sent = [];
const notes = [];
const fetch = (url, init) => {
sent.push([url, JSON.parse(init.body)]);
if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch'));
return Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 500,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(answer.body)),
});
};
const ctx = { root: {}, state: {}, signal: { aborted: false },
api: createApi({ fetch }), notify: (m, t) => notes.push([m, t]) };
const box = { checked, disabled: false };
await general.setDisplayAutostart(ctx, box);
return { box, sent, notes };
}
(async () => {
console.log('\n── General tab: Start Display on Boot ──');
{
const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } });
ok('unchecking posts disable_autostart',
t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent);
ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false);
ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes);
}
{
const t = await run(true, { ok: true, body: { status: 'success' } });
ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent);
}
{
const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } });
ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled);
ok('...and shows the route\'s message',
t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes);
}
{
const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } });
ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled);
}
{
const t = await run(true, 'network');
ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled);
ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
+9
View File
@@ -392,6 +392,15 @@ class TestGetEspnScoreboard:
"20261004", cache_manager=cache)
assert cache.records == {}
@pytest.mark.parametrize("payload", [{"events": None}, {}, {"events": "x"}, []])
def test_an_answer_without_an_events_list_is_returned_not_cached(self, service, payload):
espn = Espn()
espn.get = lambda url, **kwargs: _response(payload, url, None)
cache = RecordCache()
assert get_espn_scoreboard(espn, "football", "nfl", "20261004",
cache_manager=cache) == payload
assert cache.records == {}
def test_no_cache_manager_just_fetches(self, service):
espn = Espn()
get_espn_scoreboard(espn, "football", "nfl", "20261004")
+60
View File
@@ -0,0 +1,60 @@
"""The General tab's "Start Display on Boot" toggle reads systemd's state.
ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no
systemctl, unit not installed) -> no toggle. The toggle itself posts the
existing enable_autostart / disable_autostart system actions, whose sudo
grants test_systemctl_sudoers_alignment.py already checks.
"""
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from test.test_web_settings_ui import client # noqa: F401,E402
from web_interface.blueprints import pages_v3 # noqa: E402
REAL_RUN = subprocess.run
UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service']
def _fake_systemctl(monkeypatch, answer):
"""Answer the unit query with `answer` (stdout, or an exception to
raise); every other command (the update channel's git) runs for real."""
calls = []
def run(cmd, *args, **kwargs):
if cmd != UNIT_QUERY:
return REAL_RUN(cmd, *args, **kwargs)
calls.append(cmd)
if isinstance(answer, Exception):
raise answer
return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='')
monkeypatch.setattr(pages_v3.subprocess, 'run', run)
return calls
def _toggle(body):
start = body.find('data-action="display-autostart"')
return None if start < 0 else body[start:body.find('>', start)]
@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)])
def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked):
calls = _fake_systemctl(monkeypatch, stdout)
tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True))
assert calls == [UNIT_QUERY]
assert tag is not None
assert ('checked' in tag) is checked
@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n'])
def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome):
_fake_systemctl(monkeypatch, outcome)
body = client.get('/v3/partials/general').get_data(as_text=True)
assert _toggle(body) is None
assert 'name="plugins_directory"' in body # rest of the tab still renders
+399
View File
@@ -0,0 +1,399 @@
"""src.common.sports_live_fetch: the live poll's request and the odds queue.
The cases follow ledmatrix-plugins' ``scripts/test_live_fetch_odds.py`` (the
tables the family 9 reconcile was checked against); the expected values are
its columns, which agree across all nine scoreboards. The host is the mixin
over core's ``SportsFetchMixin`` (``_needs_previous_day``), with a session
answering like ESPN did on 2026-10-12 (a single date is 200, a range 400) and
a cache that ages entries on a test clock. Nothing reaches the network. The
odds endpoint is held on an event, so "update() never waits" is shown by the
call returning while the request is still open.
"""
import ast
import json
import logging
import threading
import time
from datetime import datetime
from pathlib import Path
import pytest
import requests
from src.common import espn_dates, sports_live_fetch
from src.common import fetch_service as fs
from src.common.fetch_service import FetchService
from src.common.sports_fetch import SportsFetchMixin
from src.common.sports_live_fetch import SportsLiveFetchMixin
LOG = logging.getLogger("test_sports_live_fetch")
SITE = "https://site.api.espn.com/apis/site/v2/sports/"
def _response(url, answer):
resp = requests.Response()
resp.url = url
resp.status_code = answer if isinstance(answer, int) else 200
resp._content = answer if isinstance(answer, bytes) else json.dumps(
{} if isinstance(answer, int) else answer).encode()
return resp
class Espn(requests.Session):
"""Two games a day for a single date; a range is 400, as ESPN answers."""
def __init__(self, answer=None):
super().__init__()
self.answer, self.asked = answer, []
def get(self, url, params=None, **_kwargs):
dates = (params or {}).get("dates")
self.asked.append((url[len(SITE):], dates))
if dates and "-" in dates:
return _response(url, 400)
if isinstance(self.answer, type):
raise self.answer("refused by the test")
if self.answer is not None:
return _response(url, self.answer)
return _response(url, {"events": [{"id": dates[4:] + s} for s in "ab"]})
class Cache:
"""``get(key, max_age)`` against entries stamped on ``clock``."""
def __init__(self, clock):
self.clock, self.store, self.reads = clock, {}, []
def get(self, key, max_age=None):
self.reads.append((key, max_age))
if key not in self.store:
return None
data, stamp = self.store[key]
return data if max_age is None or self.clock() - stamp <= max_age else None
def set(self, key, data, ttl=None):
self.store[key] = (data, self.clock())
class Clock:
def __init__(self):
self.now = 100_000.0
def __call__(self):
return self.now
class Frozen(datetime):
at = datetime(2026, 10, 12, 10, 0)
@classmethod
def now(cls, tz=None):
return tz.localize(cls.at) if tz is not None else cls.at
class Host(SportsLiveFetchMixin, SportsFetchMixin):
"""A scoreboard SportsCore as far as the family reaches."""
sport, league, sport_key = "basketball", "nba", "nba"
def __init__(self, session=None, cache=None, odds=None, show_odds=True):
self.session, self.headers, self.logger = session or Espn(), {}, LOG
self.cache_manager = cache
self.odds_manager, self.show_odds = odds, show_odds
self.mode_config = {}
self._games_lock = threading.RLock()
@pytest.fixture(autouse=True)
def offline(monkeypatch):
clock = Clock()
monkeypatch.setattr(fs, "_service", FetchService({"rate_limits": {}}))
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
monkeypatch.setattr(sports_live_fetch, "datetime", Frozen)
monkeypatch.setattr(time, "time", clock)
Frozen.at = datetime(2026, 10, 12, 10, 0)
return clock
# ---------------------------------------------------------------------------
# The live poll's request
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("hour, asked, key, ids", [
(10, [("20261012",)], "espn_scoreboard_basketball_nba_20261012", ["1012a", "1012b"]),
(2, [("20261011-20261012",), ("20261011",), ("20261012",)],
"espn_scoreboard_basketball_nba_20261011-20261012",
["1011a", "1011b", "1012a", "1012b"]),
])
def test_what_the_poll_asks_and_keeps(offline, hour, asked, key, ids):
Frozen.at = datetime(2026, 10, 12, hour, 0)
espn, cache = Espn(), Cache(offline)
data = Host(espn, cache)._fetch_todays_games()
assert [(d,) for _, d in espn.asked] == asked
assert {path for path, _ in espn.asked} == {"basketball/nba/scoreboard"}
assert cache.reads == [(key, 1)]
assert list(cache.store) == [key]
assert [e["id"] for e in data["events"]] == ids
def test_college_basketball_is_asked_by_date(offline):
espn, host = Espn(), Host()
host.session, host.league = espn, "mens-college-basketball"
host._fetch_todays_games()
assert espn.asked == [("basketball/mens-college-basketball/scoreboard", "20261012")]
@pytest.mark.parametrize("seed_age, later, requests_made", [
(None, 20, [1, 1]), # every poll asks ESPN; nothing is held 30 s
(None, 0.5, [1, 0]), # a copy from this second is the same answer
(0, None, [0]), # another reader's copy from this second
(2, None, [1]), # ... and one two seconds old is asked again
])
def test_every_poll_is_fresh(offline, seed_age, later, requests_made):
espn, cache = Espn(), Cache(offline)
if seed_age is not None:
cache.store["espn_scoreboard_basketball_nba_20261012"] = (
{"events": [{"id": "seed"}]}, offline.now - seed_age)
host, made = Host(espn, cache), []
for step in ([0] if later is None else [0, later]):
offline.now += step
before = len(espn.asked)
host._fetch_todays_games()
made.append(len(espn.asked) - before)
assert made == requests_made
def test_the_old_per_plugin_key_is_not_read(offline):
cache = Cache(offline)
cache.store["nba_scoreboard_current"] = ({"events": [{"id": "old"}]}, offline.now)
data = Host(Espn(), cache)._fetch_todays_games()
assert [e["id"] for e in data["events"]] == ["1012a", "1012b"]
assert "nba_scoreboard_current" not in {k for k, _ in cache.reads}
@pytest.mark.parametrize("answer, result, cached", [
({}, {"events": []}, False),
({"events": None}, {"events": []}, False),
({"events": []}, {"events": []}, True),
(404, None, False),
(500, None, False),
(requests.ConnectionError, None, False),
(requests.Timeout, None, False),
(b"<html>", None, False),
])
def test_what_a_failed_or_malformed_answer_becomes(offline, answer, result, cached):
cache = Cache(offline)
assert Host(Espn(answer), cache)._fetch_todays_games() == result
assert bool(cache.store) is cached
# ---------------------------------------------------------------------------
# Odds
# ---------------------------------------------------------------------------
class Odds:
"""An odds endpoint held open until ``release()``, counting what is in
flight and how many odds workers are alive while it answers."""
def __init__(self, answer="odds", held=True):
self.answer, self.calls, self.gate = answer, [], threading.Event()
if not held:
self.gate.set()
self.lock, self.active, self.most_active, self.most_workers = threading.Lock(), 0, 0, 0
def get_odds(self, sport, league, event_id, update_interval_seconds=None):
workers = sum(t.name == "nba-odds" and t.is_alive() for t in threading.enumerate())
with self.lock:
self.calls.append((event_id, update_interval_seconds))
self.active += 1
self.most_active = max(self.most_active, self.active)
self.most_workers = max(self.most_workers, workers)
self.gate.wait(10)
with self.lock:
self.active -= 1
if isinstance(self.answer, type):
raise self.answer("odds endpoint failed")
return {"details": f"{event_id} -3.5"} if self.answer else None
def release(self):
self.gate.set()
def settle():
for thread in threading.enumerate():
if thread.name == "nba-odds":
thread.join(10)
def game(gid="g1", **flags):
return {"id": gid, **flags}
@pytest.mark.parametrize("flags, interval", [({"is_live": True}, 60), ({"is_upcoming": True}, 3600),
({"is_final": True}, 3600)])
def test_the_call_never_waits_and_the_line_lands_later(flags, interval):
odds = Odds()
host, g = Host(odds=odds), game(**flags)
host._fetch_odds(g) # returns while the request is held open
assert "odds" not in g
odds.release()
settle()
assert g["odds"] == {"details": "g1 -3.5"}
assert odds.calls == [("g1", interval)]
def test_the_intervals_come_from_the_mode_config():
odds = Odds(held=False)
host = Host(odds=odds)
host.mode_config = {"live_odds_update_interval": 30, "odds_update_interval": 900}
host._fetch_odds(game("l", is_live=True))
settle()
host._fetch_odds(game("u"))
settle()
assert odds.calls == [("l", 30), ("u", 900)]
@pytest.mark.parametrize("answer", [None, RuntimeError])
def test_no_line_or_a_failed_request_leaves_the_game_bare(caplog, answer):
odds = Odds(answer, held=False)
host, g = Host(odds=odds), game()
with caplog.at_level(logging.DEBUG, logger=LOG.name):
host._fetch_odds(g)
settle()
assert "odds" not in g and host._odds_worker_busy is False
assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
@pytest.mark.parametrize("host", [
Host(odds=Odds(held=False), show_odds=False),
Host(odds=None),
], ids=["show_odds off", "no odds manager"])
def test_nothing_is_asked(caplog, host):
with caplog.at_level(logging.DEBUG, logger=LOG.name):
host._fetch_odds(game())
assert not [t for t in threading.enumerate() if t.name == "nba-odds"]
assert [r.levelname for r in caplog.records] in ([], ["DEBUG"])
def test_a_game_without_an_id_is_not_asked():
odds = Odds(held=False)
Host(odds=odds)._fetch_odds({"is_live": True})
settle()
assert odds.calls == []
def test_one_batch_per_manager_and_every_dict_gets_the_answer():
odds = Odds()
host = Host(odds=odds)
first, again, other = game("g1"), game("g1"), game("g2")
with host._games_lock: # queued before the worker takes one
for g in (first, again, other): # update() rebuilds the dicts
host._fetch_odds(g)
odds.release()
settle()
assert odds.calls == [("g1", 3600), ("g2", 3600)]
assert odds.most_workers == 1
assert first["odds"] == again["odds"] == {"details": "g1 -3.5"}
def test_a_thread_that_will_not_start_is_tried_again(monkeypatch):
class NoThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
raise RuntimeError("can't start new thread")
odds = Odds(held=False)
host = Host(odds=odds)
monkeypatch.setattr(sports_live_fetch.threading, "Thread", NoThread)
host._fetch_odds(game("g1"))
assert host._odds_worker_busy is False
monkeypatch.undo()
host._fetch_odds(game("g2"))
settle()
assert [c[0] for c in odds.calls] == ["g1", "g2"]
def test_update_and_display_on_two_threads():
"""Two updaters queueing fresh dicts while a reader walks the games under
the lock, as display() does: no exception, one worker, one request at a
time, and every dict ends with its line."""
odds = Odds(held=False)
odds.get_odds = _slowed(odds.get_odds)
host, shown, raised = Host(odds=odds), [], []
def updater():
try:
for n in range(30):
g = game(f"g{n % 4}")
with host._games_lock:
shown.append(g)
host._fetch_odds(g)
except Exception as exc: # noqa: BLE001
raised.append(exc)
def display():
try:
for _ in range(300):
with host._games_lock:
[g.get("odds") for g in shown]
except Exception as exc: # noqa: BLE001
raised.append(exc)
threads = [threading.Thread(target=updater) for _ in range(2)]
threads.append(threading.Thread(target=display))
for t in threads:
t.start()
for t in threads:
t.join(30)
settle()
assert raised == []
assert odds.most_active == 1 and odds.most_workers == 1
assert all(g.get("odds") for g in shown)
def _slowed(get_odds):
def slow(**kwargs):
time.sleep(0.005)
return get_odds(**kwargs)
return slow
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
MEMBERS = ["_drain_odds_queue", "_fetch_odds", "_fetch_todays_games"]
def _self_reads():
tree = ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8"))
reads = {node.attr for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
gets = {node.args[1].value for node in ast.walk(tree)
if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "getattr"
and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)}
return reads | gets
class TestHostContract:
def test_the_mixin_carries_only_the_family(self):
assert sorted(n for n in vars(SportsLiveFetchMixin) if not n.startswith("__")) == MEMBERS
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads() - set(MEMBERS)
if f"``{n}``" not in sports_live_fetch.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_no_other_shared_mixin_defines_these(self):
from src.common import (sports_display_rules, sports_favorites, sports_helpers,
sports_rankings, sports_rotation, sports_shared)
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
sports_shared.SportsLiveSharedMixin, sports_helpers.SportsHelpersMixin,
sports_favorites.SportsFavoritesMixin, sports_rotation.SportsRotationMixin,
sports_rankings.SportsRankingsMixin, SportsFetchMixin,
sports_display_rules.SportsGameRulesMixin]
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
+104
View File
@@ -0,0 +1,104 @@
"""sports_live_fetch still matches every plugin copy.
``src.common.sports_live_fetch`` was copied from the scoreboards once family 9
had made each method one body in all nine ``SportsCore`` classes:
``_fetch_todays_games``, ``_fetch_odds`` and ``_drain_odds_queue``. The plugins delete their copies once they floor on the release that
ships this module. 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 each method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's ``sports.py`` names the module.
Without the variable this skips: core CI has no plugins checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_live_fetch
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsLiveFetchMixin"
METHODS = ("_fetch_todays_games", "_fetch_odds", "_drain_odds_queue")
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 this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls, name):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == name), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
OURS = _class(ast.parse(Path(sports_live_fetch.__file__).read_text(encoding="utf-8")), MIXIN)
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
def test_every_remaining_plugin_copy_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _method(_class(tree, CARRIER), name)
if copy is None:
assert sports_live_fetch.__name__ in source, (
f"{sport}: no {name} on {CARRIER} and no {sports_live_fetch.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
f"{CARRIER}.{name} in {sport} differs from sports_live_fetch. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", SPORTS)
def test_no_other_plugin_class_carries_a_copy(sport):
"""A copy on another class would shadow the shared one for that class."""
_, tree = _plugin_tree(_plugins_root(), sport)
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
assert strays == []
def test_the_drift_report_calls_them_identical():
families = DRIFT.build(_plugins_root(), ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
for name in METHODS:
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name
+395
View File
@@ -0,0 +1,395 @@
"""src.common.sports_rankings: the poll, the tables, the season, the filter.
The cases follow ledmatrix-plugins' ``scripts/test_rankings.py`` (the tables
the family 8 reconcile was checked against); the expected values are its
columns, which agree across all nine scoreboards for everything this module
does. The host is the mixin over core's ``SportsCoreSharedMixin``
(``_league_has_rankings``, ``_game_divisions``) and ``SportsRotationMixin``
(``_rankings_loaded``), with a data source and an HTTP session that answer
from fixtures shaped like ESPN's payloads. Nothing reaches the network.
"""
import ast
import logging
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.common import sports_rankings
from src.common.sports_favorites import SportsFavoritesMixin
from src.common.sports_helpers import SportsHelpersMixin
from src.common.sports_rankings import SportsRankingsMixin, choose_top_division_poll
from src.common.sports_rotation import SportsRotationMixin
from src.common.sports_shared import SportsCoreSharedMixin
LOG = logging.getLogger("test_sports_rankings")
CORE_API = "https://sports.core.api.espn.com/v2/sports/"
UGA, ND, OSU, TEX, VAN, SDSU = ("61", "UGA"), ("87", "ND"), ("194", "OSU"), \
("251", "TEX"), ("238", "VAN"), ("21", "SDSU")
SDST, NDSU, NEWARK, FRST, DEN = ("2571", "SDST"), ("2449", "NDSU"), \
("9194", "OSU"), ("9001", "FRST"), ("2172", "DEN")
FBS_IDS, FCS_IDS = (UGA, ND, OSU, TEX, VAN, SDSU), (SDST, NDSU)
def poll(name, kind, *teams, **extra):
return {"name": name, "shortName": name, "type": kind, **extra, "ranks": [
{"current": n, "previous": n, "team": {"id": tid, "abbreviation": abbr}}
for n, (tid, abbr) in enumerate(teams, 1)]}
AP = poll("AP Top 25", "ap", UGA, ND, OSU, SDSU)
COACHES = poll("AFCA Coaches Poll", "usa", ND, UGA, TEX)
CFP = poll("College Football Playoff Rankings", "cfp", OSU, UGA)
FCS = poll("FCS Coaches Poll", "fcs", NDSU, SDST)
D2 = poll("AFCA Division II Coaches Poll", "afca", FRST)
SEEDS = poll("NCAA Men's Hockey Tournament Seedings", "tournament", DEN)
USCHO = poll("USCHO Men's Poll", "USCHOMENSPOLL", DEN, ND)
AP_TABLE = {"ND": 2, "OSU": 3, "SDSU": 4, "UGA": 1}
AP_IDS = {194: 3, 21: 4, 61: 1, 87: 2}
def at(text):
return datetime.fromisoformat(text).replace(tzinfo=timezone.utc).timestamp()
class Clock:
def __init__(self):
self.now = 100_000.0
def __call__(self):
return self.now
@pytest.fixture
def clock(monkeypatch):
c = Clock()
monkeypatch.setattr(sports_rankings.time, "time", c)
monkeypatch.setattr(sports_rankings.time, "monotonic", c)
return c
class Rosters:
"""The core API's division rosters, published up to ``last`` season."""
def __init__(self, last=2026):
self.last, self.urls = last, []
def get(self, url, params=None, timeout=None):
self.urls.append(url[len(CORE_API):])
season, group = url.split("/seasons/")[1].split("/")[0], url.split("/groups/")[1][:2]
ids = {"80": FBS_IDS, "81": FCS_IDS}[group] if int(season) <= self.last else ()
resp = MagicMock()
resp.json.return_value = {"items": [{"$ref": f"{CORE_API}x/teams/{t}?lang=en"}
for t, _ in ids]}
return resp
class Host(SportsRankingsMixin, SportsRotationMixin, SportsCoreSharedMixin,
SportsFavoritesMixin, SportsHelpersMixin):
"""A scoreboard SportsCore as far as the rankings reach."""
sport = "football"
def __init__(self, league="college-football", payload=None, quality="any",
divisions=(), rosters=None):
self.logger = LOG
self.league = league
self.data_source = MagicMock()
self.data_source.fetch_standings.return_value = (
{"rankings": [AP, COACHES]} if payload is None else payload)
self.session = rosters or Rosters()
self.cache_manager = MagicMock()
self.cache_manager.get.return_value = None
self._team_rankings_cache, self._ranked_team_ids = {}, {}
self._rankings_cache_timestamp, self._rankings_cache_duration = 0, 3600
self._division_team_ids, self._division_loaded_at = None, 0.0
self.other_games_min_quality = quality
self.other_games_divisions = list(divisions)
def tables(host):
return host._team_rankings_cache, host._ranked_team_ids
# ---------------------------------------------------------------------------
# Which poll
# ---------------------------------------------------------------------------
POLL_SETS = [
([AP, COACHES], "AP Top 25"),
([COACHES, AP], "AFCA Coaches Poll"),
([CFP, AP, COACHES], "College Football Playoff Rankings"),
([FCS, D2, AP, COACHES], "AP Top 25"),
([FCS, D2], None),
([poll("Coaches Poll", "fcs", NDSU), AP], "AP Top 25"), # by type alone
([SEEDS, USCHO], "USCHO Men's Poll"),
([], None),
(None, None),
]
@pytest.mark.parametrize("blocks, expected", POLL_SETS)
def test_the_poll_chosen(blocks, expected):
assert Host()._choose_poll(blocks).get("name") == expected
assert choose_top_division_poll(blocks).get("name") == expected
def test_an_untyped_unnamed_block_counts():
"""An exclude list: a block ESPN invents is a poll unless it says otherwise."""
assert choose_top_division_poll([{"ranks": []}]) == {"ranks": []}
# ---------------------------------------------------------------------------
# The tables
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("league, asked", [
("college-football", True), ("mens-college-basketball", True),
("usa.ncaa.m.1", True), ("nfl", False), ("nba", False), ("eng.1", False)])
def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
host = Host(league)
host._fetch_team_rankings()
assert host.data_source.fetch_standings.called is asked
assert tables(host) == ((AP_TABLE, AP_IDS) if asked else ({}, {}))
@pytest.mark.parametrize("payload, abbr, ids", [
({}, {}, {}),
({"rankings": []}, {}, {}),
({"rankings": None}, {}, {}),
({"rankings": [{"name": "AP Top 25", "type": "ap"}]}, {}, {}),
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
{"current": 0, "team": {"id": "61", "abbreviation": "UGA"}},
{"current": -1, "team": {"id": "87", "abbreviation": "ND"}},
{"team": {"id": "194", "abbreviation": "OSU"}},
{"current": 4, "team": {"id": "251", "abbreviation": "TEX"}}]}]},
{"TEX": 4}, {251: 4}),
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [
{"current": 1},
{"current": 2, "team": {"id": "87"}},
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
{"OSU": 3, "TEX": 4}, {87: 2}),
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry
{"current": None, "team": {"id": "61", "abbreviation": "UGA"}},
{"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}},
{"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]},
{"OSU": 3}, {194: 3}),
])
def test_payload_shapes(clock, payload, abbr, ids):
host = Host(payload=payload)
host._fetch_team_rankings()
assert tables(host) == (abbr, ids)
assert host._rankings_cache_timestamp == clock.now # an empty answer is cached too
def test_a_failed_fetch_is_not_cached(clock):
host = Host(payload={"rankings": {"name": "AP Top 25"}}) # not a list
assert host._fetch_team_rankings() == {}
assert host._rankings_cache_timestamp == 0
@pytest.mark.parametrize("payload", [{"rankings": [AP, COACHES]}, {"rankings": []}])
def test_rankings_are_fetched_hourly(clock, payload):
host = Host(payload=payload)
calls = []
for step in (0, 60, 3599, 3601):
clock.now = 100_000.0 + step
before = host.data_source.fetch_standings.call_count
host._fetch_team_rankings()
calls.append(host.data_source.fetch_standings.call_count - before)
assert calls == [1, 0, 0, 1]
# ---------------------------------------------------------------------------
# A poll from a season that has ended
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("end, now, kept", [
("2026-04-08T06:59Z", "2026-10-12T12:00", False), # college basketball in October
("2026-04-08T06:59Z", "2026-04-08T06:58", True),
("2026-04-08T06:59Z", "2026-04-08T06:59", True), # at endDate: not yet over
("2026-04-08T06:59Z", "2026-04-08T07:00", False),
("2027-01-28T07:59Z", "2027-01-10T12:00", True), # bowl week
("2027-04-08T06:59Z", "2026-11-03T12:00", True), # the new season's first poll
("2026-04-08T06:59:00Z", "2026-10-12T12:00", False),
("April", "2026-10-12T12:00", True), # unreadable: current
(None, "2026-10-12T12:00", True), # no season block
])
def test_a_finished_seasons_poll_loads_nothing(clock, end, now, kept):
block = dict(AP, season={"year": 2026, "endDate": end}) if end else AP
host = Host(payload={"rankings": [block, COACHES]})
clock.now = at(now)
host._fetch_team_rankings()
assert tables(host) == ((AP_TABLE, AP_IDS) if kept else ({}, {}))
# ---------------------------------------------------------------------------
# Division rosters
# ---------------------------------------------------------------------------
class Frozen(datetime):
at = datetime(2026, 10, 12, 12)
@classmethod
def now(cls, tz=None):
return cls.at
@pytest.mark.parametrize("today, season, sizes", [
("2026-01-15", 2025, (6, 2)), ("2026-06-30", 2025, (6, 2)),
("2026-07-01", 2026, (6, 2)), ("2026-10-12", 2026, (6, 2)),
("2026-12-31", 2026, (6, 2)), ("2027-01-15", 2026, (6, 2))])
def test_the_rosters_are_for_the_season_year(clock, monkeypatch, today, season, sizes):
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
Frozen.at = datetime.fromisoformat(today)
host = Host()
rosters = host._load_division_team_ids()
assert host.session.urls == [
f"football/leagues/college-football/seasons/{season}/types/2/groups/{g}/teams"
for g in (80, 81)]
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
def test_no_rosters_outside_college_football(clock):
host = Host("mens-college-basketball")
assert host._load_division_team_ids() == {}
assert host.session.urls == []
@pytest.mark.parametrize("last, cached, calls, sizes", [
(2026, None, [2, 0, 0, 2], (6, 2)), # a day
(0, None, [2, 0, 2, 2], (0, 0)), # empty: retried after 10 minutes
(2026, "cache", [0, 0, 0, 0], (2, 1)), # the cache manager's copy
])
def test_roster_caching(clock, last, cached, calls, sizes):
host = Host(rosters=Rosters(last))
if cached:
host.cache_manager.get.side_effect = lambda key, *a, **k: (
["61", "87"] if key.endswith("_80") else ["2571"])
made = []
for step in (0, 60, 601, 86_401):
clock.now = 100_000.0 + step
before = len(host.session.urls)
host._load_division_team_ids()
made.append(len(host.session.urls) - before)
rosters = host._division_team_ids
assert made == calls
assert (len(rosters["fbs"]), len(rosters["fcs"])) == sizes
# ---------------------------------------------------------------------------
# Ranked games and the filter
# ---------------------------------------------------------------------------
GAMES = {
"UGA v TEX": (UGA, TEX),
"TEX v VAN": (TEX, VAN),
"OSU v VAN": (OSU, VAN),
"Newark 'OSU' v VAN": (NEWARK, VAN),
"SDSU v SDST": (SDSU, SDST),
"SDST v NDSU": (SDST, NDSU),
"'OSU' v VAN, no ids": ((None, "OSU"), (None, "VAN")),
"61 v 238, no abbrs": (("61", None), ("238", None)),
}
def game(label):
g = {"id": "g"}
for side, (tid, abbr) in zip(("home", "away"), GAMES[label]):
if tid is not None:
g[f"{side}_id"] = tid
if abbr is not None:
g[f"{side}_abbr"] = abbr
return g
def loaded(**kwargs):
host = Host(**kwargs)
host._fetch_team_rankings()
return host
@pytest.mark.parametrize("label, best", [
("UGA v TEX", 1), ("TEX v VAN", 99), ("OSU v VAN", 3),
("Newark 'OSU' v VAN", 99), # same abbreviation, another school: by id
("SDSU v SDST", 4), ("SDST v NDSU", 99),
("'OSU' v VAN, no ids", 3), # no ids: by abbreviation
("61 v 238, no abbrs", 1)])
def test_ranked_and_best_rank(clock, label, best):
host = loaded()
assert host._best_rank(game(label)) == best
assert host._is_ranked_game(game(label)) is (best != 99)
@pytest.mark.parametrize("home, away, best", [
(NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it
(("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side
])
def test_each_side_is_ranked_by_its_own_id(clock, home, away, best):
"""#789: one side's missing id no longer sends both sides to abbreviations."""
g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away))
for key, value in zip(("id", "abbr"), team) if value is not None}
assert loaded()._best_rank(g) == best
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
FILTERS = {
"any": ("any", (), True, "YYYYYYYY"),
"ranked": ("ranked", (), True, "Y.Y.Y.YY"),
"ranked, no poll loaded": ("ranked", (), False, "YYYYYYYY"),
"any, fbs": ("any", ("fbs",), True, "YYYYY.YY"),
"ranked, fbs": ("ranked", ("fbs",), True, "Y.Y.Y.YY"),
"any, fcs": ("any", ("fcs",), True, "....YYY."),
}
@pytest.mark.parametrize("name", FILTERS)
def test_passes_other_filters(clock, monkeypatch, name):
quality, divisions, with_poll, kept = FILTERS[name]
monkeypatch.setattr(sports_rankings, "datetime", Frozen)
Frozen.at = datetime(2026, 10, 12, 12)
host = loaded(payload={"rankings": [AP, COACHES] if with_poll else []},
quality=quality, divisions=divisions)
assert "".join("Y" if host._passes_other_filters(game(g)) else "." for g in GAMES) == kept
# ---------------------------------------------------------------------------
# Carrier and host contract
# ---------------------------------------------------------------------------
MEMBERS = ["_DIVISION_CACHE_TTL", "_DIVISION_GROUPS_BY_LEAGUE", "_DIVISION_RETRY_SECONDS",
"_NON_TOP_POLL_NAMES", "_NON_TOP_POLL_TYPES", "_best_rank", "_choose_poll",
"_fetch_team_rankings", "_is_ranked_game", "_load_division_team_ids",
"_passes_other_filters", "_poll_season_ended", "_ranked_positions"]
def _self_reads():
tree = ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8"))
return {node.attr for node in ast.walk(tree)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_the_mixin_carries_only_the_family(self):
"""Every scoreboard's SportsCore has all of them, so adopting it adds none."""
assert sorted(n for n in vars(SportsRankingsMixin)
if not n.startswith("__")) == MEMBERS
def test_every_host_read_is_documented(self):
own = set(MEMBERS)
undocumented = sorted(n for n in _self_reads() - own
if f"``{n}``" not in sports_rankings.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_no_other_shared_mixin_defines_these(self):
from src.common import sports_display_rules, sports_shared
others = [sports_shared.SportsCoreSharedMixin, sports_shared.SportsRecentSharedMixin,
sports_shared.SportsLiveSharedMixin, SportsHelpersMixin,
SportsFavoritesMixin, SportsRotationMixin,
sports_display_rules.SportsGameRulesMixin]
assert [m for m in MEMBERS if any(m in vars(o) for o in others)] == []
+132
View File
@@ -0,0 +1,132 @@
"""sports_rankings still matches every plugin copy.
``src.common.sports_rankings`` was copied from the scoreboards once family 8
had made each method one body in all nine ``SportsCore`` classes:
``_choose_poll``, ``_fetch_team_rankings``, ``_poll_season_ended``,
``_load_division_team_ids``, ``_ranked_positions``, ``_is_ranked_game``,
``_best_rank`` and ``_passes_other_filters``, with the class constants they
read. The plugins delete their copies once they floor on the release that
ships this module. 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 each method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators, and each constant a plugin still defines by its expression. A copy that is
gone counts as adopted when the plugin's ``sports.py`` names the module.
Without the variable this skips: core CI has no plugins checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_rankings
REPO = Path(__file__).resolve().parents[1]
SPORTS = ("afl", "baseball", "basketball", "football", "hockey", "lacrosse",
"nrl", "soccer", "ufc")
CARRIER, MIXIN = "SportsCore", "SportsRankingsMixin"
METHODS = ("_choose_poll", "_fetch_team_rankings", "_poll_season_ended",
"_load_division_team_ids", "_ranked_positions", "_is_ranked_game",
"_best_rank", "_passes_other_filters")
CONSTANTS = ("_NON_TOP_POLL_TYPES", "_NON_TOP_POLL_NAMES", "_DIVISION_GROUPS_BY_LEAGUE",
"_DIVISION_CACHE_TTL", "_DIVISION_RETRY_SECONDS")
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 this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls, name):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == name), None)
def _constant(cls, name):
"""The value a class body assigns to ``name`` (plain or annotated), or None."""
for node in cls.body:
if isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == name for t in node.targets):
return node.value
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) \
and node.target.id == name:
return node.value
return None
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _plugin_tree(root, sport):
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
return source, ast.parse(source)
OURS = _class(ast.parse(Path(sports_rankings.__file__).read_text(encoding="utf-8")), MIXIN)
@pytest.mark.parametrize("sport, name", [(s, n) for s in SPORTS for n in METHODS])
def test_every_remaining_plugin_copy_matches(sport, name):
source, tree = _plugin_tree(_plugins_root(), sport)
copy = _method(_class(tree, CARRIER), name)
if copy is None:
assert sports_rankings.__name__ in source, (
f"{sport}: no {name} on {CARRIER} and no {sports_rankings.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(OURS, name)), (
f"{CARRIER}.{name} in {sport} differs from sports_rankings. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", SPORTS)
def test_the_plugin_constants_match(sport):
_, tree = _plugin_tree(_plugins_root(), sport)
core = _class(tree, CARRIER)
for name in CONSTANTS:
value = _constant(core, name)
if value is not None:
assert ast.dump(value) == ast.dump(_constant(OURS, name)), f"{sport}: {name}"
@pytest.mark.parametrize("sport", SPORTS)
def test_no_other_plugin_class_carries_a_copy(sport):
"""A copy on another class would shadow the shared one for that class."""
_, tree = _plugin_tree(_plugins_root(), sport)
strays = [f"{node.name}.{name}" for node in tree.body if isinstance(node, ast.ClassDef)
for name in METHODS if node.name != CARRIER and _method(node, name) is not None]
assert strays == []
def test_the_drift_report_calls_them_identical():
families = DRIFT.build(_plugins_root(), ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
for name in METHODS:
row = rows.get(("sports.py", name))
assert row is None or row["worst_class_variants"] == 1, name