Files
LEDMatrix/test/test_favorite_team_check.py
T
ChuckandClaude Opus 5.5 fe5bed2886 feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2) (#665)
* feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2)

Two new hardware-free modules, taken from files the scoreboard plugins carry
as copies:

- src/common/favorite_team_check.py: FavoriteTeamCheck(logger, leagues), the
  seven byte-identical <sport>_favorite_check.py copies. Same code; the only
  additions are two type annotations (for the mypy ratchet).
- src/common/sports_timezone.py: resolve_timezone_name(), resolve_timezone(),
  system_timezone_name(), from the ten <sport>_timezone.py copies. They
  differed only in the plugin label named in the nothing-resolved warning and
  the write-back-bug values, which become keyword-only arguments
  (plugin_label, writeback_fixed_in). Same resolution order and log text.

Tests are ported from the plugins' own (test_favorite_check.py,
test_schedule_note_uses_game_dates.py, test_timezone_resolution.py; the
timezone ones run once per plugin's values and pin the exact warning text).
Both modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's
Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses
them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(common): bdf_font and json_body shipped in 3.5.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(common): annotate the favourite check's deliberate except/pass for Bandit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:05:10 -04:00

356 lines
14 KiB
Python

"""src.common.favorite_team_check: the messages a user sees for a bad team code.
Ported from the scoreboard plugins' own tests (hockey-scoreboard's
test_favorite_check.py and football-scoreboard's
test_schedule_note_uses_game_dates.py), which test their bundled
``<sport>_favorite_check.py`` copies. Core now owns a copy that can drift on
its own, and a plugin that deletes its copy loses those tests with it.
Everything here is offline: ESPN is replaced with a fixed roster, so the tests
pin the *messages* the user actually sees. They are the point of the feature --
the whole thing exists to turn a blank screen into a sentence that says why.
"""
import logging
import sys
import unittest
from datetime import datetime, timedelta, timezone
from src.common.favorite_team_check import FavoriteTeamCheck
NHL = {
"BOS": "Boston Bruins",
"TB": "Tampa Bay Lightning",
"UTAH": "Utah Mammoth",
"SEA": "Seattle Kraken",
"VGK": "Vegas Golden Knights",
}
NCAA = {
"ALA": "Alabama Crimson Tide",
"UNA": "North Alabama Lions",
"CONN": "UConn Huskies",
"SC": "South Carolina Gamecocks",
"RUTG": "Rutgers Scarlet Knights",
"GS": "Golden State Warriors",
}
class RecordingLogger(logging.Logger):
"""Captures formatted records so tests can assert on the message text."""
def __init__(self):
super().__init__("test")
self.records = []
def handle(self, record):
self.records.append((record.levelname, record.getMessage()))
def messages(self, level=None):
return [m for lvl, m in self.records if level is None or lvl == level]
class SuggestionTests(unittest.TestCase):
"""The suggestion has to name the right team, and name it first."""
def assert_first_suggestion(self, code, teams, expected):
message = FavoriteTeamCheck._suggest(code, teams)
self.assertIn(expected, message,
"{!r} did not suggest {!r}: {}".format(code, expected, message))
# Where several candidates are listed, the right one must lead, since
# users act on the first thing they read.
head = message.split("(")[0]
self.assertIn(expected, head,
"{!r} buried {!r} behind another suggestion: {}".format(
code, expected, message))
def test_retired_code_points_at_the_current_one(self):
self.assert_first_suggestion("UTA", NHL, "UTAH")
def test_common_wrong_guesses(self):
self.assert_first_suggestion("GSW", NCAA, "GS")
self.assert_first_suggestion("UCONN", NCAA, "CONN")
def test_fragment_of_a_word_is_matched(self):
# 'BAMA' abbreviates nothing in 'Alabama Crimson Tide' -- it is a chunk
# out of the middle of a word -- so the initials rule alone misses it.
self.assert_first_suggestion("BAMA", NCAA, "ALA")
def test_first_word_beats_a_mid_name_match(self):
# 'SCAR' fits Rutgers *Scar*let too, but South Carolina starts at the
# first word, which is how people actually shorten a name.
self.assert_first_suggestion("SCAR", NCAA, "SC")
def test_wrong_case_says_so_rather_than_guessing(self):
message = FavoriteTeamCheck._suggest("bos", NHL)
self.assertIn("case-sensitive", message)
self.assertIn("'BOS'", message)
def test_valid_code_draws_no_suggestion(self):
self.assertEqual(FavoriteTeamCheck._suggest("BOS", NHL), "")
def test_unmatchable_code_is_not_forced_into_a_suggestion(self):
# Nothing sensible to offer is better than something wrong.
self.assertEqual(FavoriteTeamCheck._suggest("ZZZZZZ", NHL), "")
def test_abbreviates_separates_codes_string_distance_ties(self):
# The case that motivated this: 'MUN' is equidistant from 'MAN' and
# 'SUN' by string similarity, so similarity cannot choose.
self.assertTrue(FavoriteTeamCheck._abbreviates("MUN", "Manchester United"))
self.assertFalse(FavoriteTeamCheck._abbreviates("MUN", "Sunderland"))
class CheckTests(unittest.TestCase):
"""The log output for each way a league can end up empty."""
def setUp(self):
self.logger = RecordingLogger()
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
self.checker._fetch_teams = staticmethod(lambda path: dict(NHL))
self.checker._schedule_note = staticmethod(lambda path: None)
def test_bad_code_is_reported_with_a_suggestion(self):
self.checker._check("nhl", ["UTA", "BOS"])
warnings = self.logger.messages("WARNING")
self.assertEqual(len(warnings), 1)
self.assertIn("'UTA' is not a NHL team code", warnings[0])
self.assertIn("UTAH", warnings[0])
def test_all_codes_bad_says_nothing_will_show(self):
self.checker._check("nhl", ["UTA", "NOPE"])
joined = " ".join(self.logger.messages("WARNING"))
self.assertIn("no recognised favorite teams", joined)
self.assertIn("nothing will be shown", joined)
def test_good_codes_out_of_season_explain_the_empty_screen(self):
self.checker._schedule_note = staticmethod(
lambda path: "the league has nothing on until 07 October 2026")
self.checker._check("nhl", ["BOS"])
info = " ".join(self.logger.messages("INFO"))
self.assertIn("look correct", info)
self.assertIn("07 October 2026", info)
self.assertIn("not a configuration problem", info)
self.assertEqual(self.logger.messages("WARNING"), [])
def test_good_codes_in_season_stay_quiet(self):
self.checker._check("nhl", ["BOS", "TB"])
self.assertEqual(self.logger.messages("WARNING"), [])
self.assertIn("recognised: BOS, TB", " ".join(self.logger.messages("INFO")))
def test_dynamic_groups_are_not_treated_as_team_codes(self):
self.checker._check("nhl", ["AP_TOP_25", "NCAA_MENS_TOP_10"])
self.assertEqual(self.logger.messages("WARNING"), [])
def test_empty_roster_draws_no_conclusion(self):
# ESPN's college lacrosse endpoints return zero teams; a valid code
# must not be called wrong just because the roster is unavailable.
self.checker._fetch_teams = staticmethod(lambda path: {})
self.checker._check("nhl", ["ANYTHING"])
self.assertEqual(self.logger.records, [])
def test_fetch_failure_is_swallowed(self):
def boom(path):
raise RuntimeError("network down")
self.checker._fetch_teams = staticmethod(boom)
self.checker._check("nhl", ["BOS"]) # must not raise
self.assertEqual(self.logger.messages("WARNING"), [])
class ScheduleNoteTests(unittest.TestCase):
"""
Reading ESPN's scoreboard for "is there anything on?".
Both traps here are real API behaviour, confirmed against live endpoints:
an out-of-season league rolls forward to its next fixtures rather than
returning nothing, and a finished season returns its *last* game instead.
"""
def note(self, payload):
"""Run _schedule_note against a fixed payload, with no network access.
The method imports ``requests`` in its own body, so the fake has to go
into ``sys.modules`` — patching the attribute on the module has
no effect on a function-local import.
"""
import types
class Response:
@staticmethod
def json():
return payload
fake = types.ModuleType("requests")
fake.get = lambda url, timeout=None: Response()
real = sys.modules.get("requests")
sys.modules["requests"] = fake
try:
return FavoriteTeamCheck._schedule_note("hockey/nhl")
finally:
if real is None:
sys.modules.pop("requests", None)
else:
sys.modules["requests"] = real
@staticmethod
def iso(days):
from datetime import datetime, timedelta, timezone
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
def test_games_today_says_nothing(self):
self.assertIsNone(self.note({"events": [{"date": self.iso(0)}]}))
def test_a_game_already_under_way_counts_as_something_on(self):
# Games that started earlier today are in the past by the clock. Reading
# them as "not upcoming" made a live slate report the season as over.
self.assertIsNone(self.note({"events": [{"date": self.iso(-0.3)}]}))
def test_an_off_day_or_two_is_not_worth_mentioning(self):
self.assertIsNone(self.note({"events": [{"date": self.iso(1)}]}))
def test_out_of_season_reports_the_next_fixture(self):
# ESPN rolls forward, so events exist but are months away.
note = self.note({"events": [{"date": self.iso(52)}, {"date": self.iso(53)}]})
self.assertIsNotNone(note)
self.assertIn("nothing on until", note)
def test_finished_season_is_reported_as_finished(self):
# A completed season returns its last game, in the past.
note = self.note({"events": [{"date": self.iso(-120)}],
"leagues": [{"calendar": [self.iso(-300)]}]})
self.assertIsNotNone(note)
self.assertIn("season has finished", note)
def test_past_dates_never_read_as_imminent(self):
# The bug this guards: taking the soonest of *all* dates makes a game
# from last March look like one happening right now, so a finished
# season silently reports itself as in progress.
note = self.note({"events": [{"date": self.iso(-120)},
{"date": self.iso(40)}]})
self.assertIn("nothing on until", note)
def test_calendar_is_used_when_there_are_no_events(self):
note = self.note({"events": [],
"leagues": [{"calendar": [{"startDate": self.iso(30)}]}]})
self.assertIn("nothing on until", note)
def test_nothing_published_draws_no_conclusion(self):
self.assertIsNone(self.note({"events": [], "leagues": [{"calendar": []}]}))
def test_unparseable_dates_are_ignored_rather_than_fatal(self):
self.assertIsNone(self.note(
{"events": [{"date": "not a date"}, {"date": None}, {}]}))
class SchedulingTests(unittest.TestCase):
"""The check must run once, off the render path, and never raise."""
def setUp(self):
self.logger = RecordingLogger()
self.checker = FavoriteTeamCheck(self.logger, {"nhl": ("NHL", "hockey/nhl")})
self.calls = []
self.checker._check = lambda key, favs: self.calls.append((key, list(favs)))
def drain(self):
import threading
for thread in threading.enumerate():
if thread.name == "favorite-team-check":
thread.join(timeout=5)
def test_runs_once_per_league(self):
for _ in range(5):
self.checker.schedule("nhl", ["BOS"])
self.drain()
self.assertEqual(len(self.calls), 1)
def test_reset_allows_a_recheck_after_a_config_edit(self):
self.checker.schedule("nhl", ["BOS"])
self.drain()
self.checker.reset()
self.checker.schedule("nhl", ["TB"])
self.drain()
self.assertEqual([favs for _, favs in self.calls], [["BOS"], ["TB"]])
def test_no_favorites_configured_does_nothing(self):
self.checker.schedule("nhl", [])
self.checker.schedule("nhl", None)
self.checker.schedule("nhl", ["", " "])
self.drain()
self.assertEqual(self.calls, [])
def test_unknown_league_key_is_ignored(self):
self.checker.schedule("not-a-league", ["BOS"])
self.drain()
self.assertEqual(self.calls, [])
def test_thread_is_a_daemon_so_it_cannot_hold_up_shutdown(self):
import threading
started = threading.Event()
seen = {}
def record(key, favs):
seen["daemon"] = threading.current_thread().daemon
started.set()
self.checker._check = record
self.checker.schedule("nhl", ["BOS"])
self.assertTrue(started.wait(timeout=5))
self.assertTrue(seen["daemon"])
class ScheduleNoteUsesGameDatesTests(unittest.TestCase):
"""The "nothing on until" note reports a game date, not a calendar boundary.
``_schedule_note`` used to pool ESPN's rolled-forward event dates with the
league calendar's week/phase startDates and take the earliest. Calendar
weeks routinely open days before their first game, so the note reported
"nothing on until 06 September" for a league whose first snap was the
10th. Events now win; the calendar only speaks when the scoreboard has no
events at all.
"""
note = ScheduleNoteTests.note
@staticmethod
def iso(base, days_out):
# Every date in a case derives from one captured *base*, so a UTC
# midnight crossing mid-test cannot make the payload and the expected
# strftime disagree about the day.
return (base + timedelta(days=days_out)).strftime("%Y-%m-%dT%H:%MZ")
def test_the_first_game_wins_over_an_earlier_calendar_boundary(self):
base = datetime.now(timezone.utc)
note = self.note({
"events": [{"date": self.iso(base, 10)}, {"date": self.iso(base, 14)}],
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
})
self.assertIn((base + timedelta(days=10)).strftime("%d %B %Y"), note)
self.assertNotIn((base + timedelta(days=6)).strftime("%d %B %Y"), note)
def test_with_no_events_the_calendar_still_gets_a_say(self):
base = datetime.now(timezone.utc)
note = self.note({"events": [], "leagues": [{"calendar": [self.iso(base, 20)]}]})
self.assertIn((base + timedelta(days=20)).strftime("%d %B %Y"), note)
def test_only_past_dates_reads_as_a_finished_season(self):
base = datetime.now(timezone.utc)
note = self.note({"events": [{"date": self.iso(base, -40)}],
"leagues": [{"calendar": []}]})
self.assertIn("finished", note)
def test_a_finished_season_is_not_dressed_up_by_an_offseason_calendar(self):
# Past events mean the season is over; a future calendar boundary
# (the draft, next season's week 1 shell) is not the next game.
base = datetime.now(timezone.utc)
note = self.note({"events": [{"date": self.iso(base, -40)}],
"leagues": [{"calendar": [{"startDate": self.iso(base, 45)}]}]})
self.assertIn("finished", note)
def test_an_imminent_slate_is_not_worth_a_note(self):
base = datetime.now(timezone.utc)
self.assertIsNone(self.note({
"events": [{"date": self.iso(base, 1)}],
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
}))