"""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 ``_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) class ScheduleNoteListCalendarTests(unittest.TestCase): """A round still to start in a "list" calendar is not a finished season. Shapes captured from ESPN on 2026-09-29, with dates kept relative to that day. The Europa League scoreboard still showed the 17 September matchday and its calendar is a ``"list"`` of rounds, not match days, so the check said the season had finished -- with the knockout rounds, and the next league-phase matchday, still to come. PLL, the World Cup and AFL really had finished and must still say so, although each has a season or round ``endDate`` in the future. """ note = ScheduleNoteTests.note @staticmethod def iso(days): return (datetime.now(timezone.utc) + timedelta(days=days)).strftime( "%Y-%m-%dT%H:%MZ") @classmethod def list_league(cls, event_days, rounds, league_type=14540, event_type=14540, phase_label="UEFA Europa League", extra_phases=()): """``rounds`` is ``[(label, start_day, end_day), ...]`` for one phase.""" return { "events": [{"date": cls.iso(d), "season": {"type": event_type}} for d in event_days], "leagues": [{ "season": {"type": {"type": league_type}}, "calendarType": "list", "calendarIsWhitelist": True, "calendar": [{ "label": phase_label, "startDate": cls.iso(-90), "endDate": cls.iso(275), "entries": [{"label": label, "startDate": cls.iso(start), "endDate": cls.iso(end)} for label, start, end in rounds], }] + list(extra_phases), }], } def test_europa_between_matchdays_is_not_finished(self): note = self.note(self.list_league([-12], [ ("League Phase", -31, 123), ("Knockout Round Playoffs", 123, 151), ("Rd of 16", 151, 172), ("Quarterfinals", 172, 200), ("Semifinals", 200, 221), ("Final", 222, 275), ])) self.assertIsNone(note) def test_world_cup_after_the_final_is_still_finished(self): # The competition runs to 31 December, and the last round ended 12 # days after the final; no round is still to start. note = self.note(self.list_league([-72], [ ("Group", -110, -93), ("Semifinals", -77, -72), ("Final", -72, -59), ], league_type=13803, event_type=13803, phase_label="FIFA World Cup")) self.assertIn("season has finished", note) def test_afl_after_the_grand_final_is_still_finished(self): # The Grand Final round had started but had not ended yet. note = self.note(self.list_league([-3], [ ("Preliminary Finals", -13, -6), ("Grand Final", -6, 1), ], league_type=3, event_type=3, phase_label="Postseason")) self.assertIn("season has finished", note) def test_an_offseason_round_does_not_count(self): # College football's "Off Season" phase holds the All-Star week. offseason = {"label": "Off Season", "startDate": self.iso(2), "endDate": self.iso(6), "entries": [{"label": "All-Star", "startDate": self.iso(2), "endDate": self.iso(6)}]} note = self.note(self.list_league( [-3], [("CFP", -40, 1)], league_type=3, event_type=3, phase_label="Postseason", extra_phases=[offseason])) self.assertIn("season has finished", note) def test_pll_with_a_season_end_date_in_the_future_is_still_finished(self): # A "day" whitelist whose last match day is past; the season's own # endDate (1 January) is ignored. note = self.note({ "events": [{"date": self.iso(-9), "season": {"type": 2}}], "leagues": [{ "season": {"type": {"type": 2}, "startDate": self.iso(-271), "endDate": self.iso(94)}, "calendarType": "day", "calendarIsWhitelist": True, "calendarEndDate": self.iso(94), "calendar": [self.iso(-30), self.iso(-22), self.iso(-9)], }], }) self.assertIn("season has finished", note)