mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(common): favourite check no longer calls a started postseason a finished season The day after a regular season ends, ESPN's default scoreboard still returns that last regular-season day, while leagues[0].season has moved to Postseason. All events were in the past, so the check logged "the season has finished" for MLB on 2026-09-29 while the upcoming manager in the same process was showing TB's wild-card games. When every event is past and the league is in a later in-season phase (regular season or postseason) than all of the returned events, draw no conclusion. The offseason is excluded, so a genuinely finished season is still reported as finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(common): favourite check reports the next matchday between soccer rounds Between matchdays ESPN's soccer scoreboard keeps showing the last one, so every event is in the past and in the league's current phase, which the postseason rule does not cover; the check said the Premier League season had finished on 2026-09-29 (last games 20 September, next 10 October). When the league calendar is a "day" whitelist, its entries are days with games, so a future one is used as the next fixture. MLB's day calendar is a blacklist and is not read that way; PLL's whitelist has no future days and is still reported as finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
442 lines
18 KiB
Python
442 lines
18 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)}]}],
|
|
}))
|
|
|
|
|
|
class ScheduleNotePostseasonTests(unittest.TestCase):
|
|
"""Past regular-season events are not a finished season once the postseason starts.
|
|
|
|
Captured from ESPN's MLB scoreboard on 2026-09-29, two days after the
|
|
regular season ended: the default scoreboard still returned the last
|
|
regular-season day (event ``season.type`` 2), while ``leagues[0].season``
|
|
had already moved to Postseason (type 3). The check told a Rays fan the
|
|
season was over while their upcoming manager listed TB's wild-card games.
|
|
"""
|
|
|
|
note = ScheduleNoteTests.note
|
|
|
|
@staticmethod
|
|
def payload(league_type, event_type, days=-2):
|
|
date = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
|
return {
|
|
"events": [{"date": date, "season": {"year": 2026, "type": event_type}}],
|
|
"leagues": [{"season": {"year": 2026, "type": {"type": league_type}},
|
|
"calendar": [date]}],
|
|
}
|
|
|
|
def test_postseason_after_regular_season_events_is_not_finished(self):
|
|
self.assertIsNone(self.note(self.payload(league_type=3, event_type=2)))
|
|
|
|
def test_a_finished_postseason_is_still_reported_as_finished(self):
|
|
# AFL the week after its grand final: league and events both postseason.
|
|
note = self.note(self.payload(league_type=3, event_type=3, days=-4))
|
|
self.assertIn("season has finished", note)
|
|
|
|
def test_offseason_after_the_postseason_is_still_finished(self):
|
|
note = self.note(self.payload(league_type=4, event_type=3, days=-40))
|
|
self.assertIn("season has finished", note)
|
|
|
|
|
|
class ScheduleNoteMatchdayTests(unittest.TestCase):
|
|
"""Between soccer matchdays the next fixture is only in the calendar.
|
|
|
|
Captured from ESPN's Premier League scoreboard on 2026-09-29: the
|
|
scoreboard still showed the 20 September matchday, the league was in the
|
|
same season phase as those events, and the next games (10 October) were
|
|
only in ``leagues[0].calendar`` -- a ``"day"`` calendar with
|
|
``calendarIsWhitelist`` true, i.e. the days that have games. The check
|
|
said the season had finished.
|
|
"""
|
|
|
|
note = ScheduleNoteTests.note
|
|
|
|
@staticmethod
|
|
def payload(event_days, calendar_days, whitelist=True):
|
|
from datetime import datetime, timedelta, timezone
|
|
now = datetime.now(timezone.utc)
|
|
|
|
def iso(days):
|
|
return (now + timedelta(days=days)).strftime("%Y-%m-%dT%H:%MZ")
|
|
|
|
return {
|
|
"events": [{"date": iso(d), "season": {"type": 14308}}
|
|
for d in event_days],
|
|
"leagues": [{"season": {"type": {"type": 14308}},
|
|
"calendarType": "day",
|
|
"calendarIsWhitelist": whitelist,
|
|
"calendar": [iso(d) for d in calendar_days]}],
|
|
}
|
|
|
|
def test_gap_between_matchdays_reports_the_next_matchday(self):
|
|
from datetime import datetime, timedelta, timezone
|
|
note = self.note(self.payload([-9], [-9, 11, 12]))
|
|
self.assertIn("nothing on until", note)
|
|
self.assertIn((datetime.now(timezone.utc) + timedelta(days=11))
|
|
.strftime("%d %B %Y"), note)
|
|
|
|
def test_next_matchday_within_a_couple_of_days_says_nothing(self):
|
|
self.assertIsNone(self.note(self.payload([-2], [-2, 1])))
|
|
|
|
def test_no_matchdays_left_is_still_finished(self):
|
|
# PLL on the same day: a match-day calendar whose last day is past.
|
|
note = self.note(self.payload([-9], [-30, -9]))
|
|
self.assertIn("season has finished", note)
|
|
|
|
def test_a_blacklist_day_calendar_is_not_read_as_fixtures(self):
|
|
# MLB's day calendar has calendarIsWhitelist false: it lists days
|
|
# without games, so a future entry there is not a next fixture.
|
|
note = self.note(self.payload([-9], [11], whitelist=False))
|
|
self.assertIn("season has finished", note)
|