mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
feat(common): sports_live_fetch -- the reconciled live fetch and odds (sports family 9)
New hardware-free module src/common/sports_live_fetch.py, copied from
ledmatrix-plugins claude/family9-reconcile once the nine scoreboards made
_fetch_todays_games (seven bodies when measured) and _fetch_odds (three) one
body each. Both live on the plugins' SportsCore, so one mixin,
SportsLiveFetchMixin, carries exactly those two plus the odds worker
_drain_odds_queue; no manager gains a method it lacks today.
Owner decisions (2026-10-12):
- Live scores are always fresh: every poll asks ESPN through
get_espn_scoreboard and leaves the answer under the shared
espn_scoreboard_cache_key for its dates (max_age=1: only a copy another
reader fetched within the last second is reused). The plugins' 30 s
<sport_key>_scoreboard_current cache is gone.
- College basketball is asked by date like every other league (ESPN
answered single dates for both college leagues on 2026-10-12).
- Odds never block update(): one daemon worker per manager answers a queue
and attaches each line in place under _games_lock; one batch in flight per
manager, games queued meanwhile join it, every dict queued for an id gets
the one answer. show_odds, the two odds intervals and a missing odds
manager (debug, not ERROR) are respected.
- An answer whose events is not a list is no games.
Fix: get_espn_scoreboard() no longer caches an answer whose events is not a
list ("events": null), which every reader of the shared key inherited; it is
still returned to the caller.
- test/test_sports_live_fetch.py: the plugins' pinned cases (requests,
dates and keys at 10:00 and 02:00 Eastern, freshness, malformed and failed
answers, odds that never block and land later, the intervals, one batch per
manager, a worker that cannot start, update and display on two threads),
plus the host contract. Mutation-checked (11 mutants, all caught).
- test/test_sports_live_fetch_parity.py: with LEDMATRIX_PLUGINS, compares
each body with every plugin copy. Passes against claude/family9-reconcile;
fails against the pre-reconcile tree.
- test/test_espn_scoreboard_cache.py: the null-events regression.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased: New modules,
Fixes), docs/SPORTS_UNIFICATION.md family 9 status and decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user