diff --git a/src/common/favorite_team_check.py b/src/common/favorite_team_check.py index bcb30481..7695c28d 100644 --- a/src/common/favorite_team_check.py +++ b/src/common/favorite_team_check.py @@ -199,13 +199,25 @@ class FavoriteTeamCheck: # has no events at all to roll forward to: events that exist but are # all in the past mean the season is over, and an offseason calendar # phase must not be dressed up as its next game. + # + # The exception is a calendar of match days. With calendarType "day" + # and calendarIsWhitelist true, every entry is a day that has games, + # so a future entry is a real next fixture. Soccer needs it: between + # matchdays the scoreboard keeps showing the last one, so on + # 2026-09-29 every Premier League event was from 20 September and the + # next games (10 October) were only in the calendar. A day calendar + # that is not a whitelist (MLB's) lists days *without* games. if any(event_dates): upcoming = future(event_dates) + if not upcoming and cls._calendar_is_match_days(payload): + upcoming = future(calendar_dates) else: upcoming = future(calendar_dates) if not upcoming: if not any(event_dates) and not any(calendar_dates): return None # Nothing published either way; draw no conclusion. + if cls._moved_to_later_phase(payload): + return None # e.g. postseason under way; see the method. return ("the season has finished and the next one's fixtures are " "not published yet") @@ -215,6 +227,38 @@ class FavoriteTeamCheck: return "the league has nothing on until {}".format( upcoming[0].strftime('%d %B %Y')) + @staticmethod + def _calendar_is_match_days(payload) -> bool: + """Whether the league calendar lists the days that have games.""" + league = (payload.get('leagues') or [{}])[0] or {} + return (league.get('calendarType') == 'day' + and league.get('calendarIsWhitelist') is True) + + @staticmethod + def _moved_to_later_phase(payload) -> bool: + """ + Whether the league is in a later in-season phase than its events. + + ESPN does not roll the scoreboard forward into a postseason. The day + after MLB's regular season ended, the default scoreboard still returned + that last regular-season day, while ``leagues[0].season`` already said + Postseason and the wild-card games were two days out. Past events alone + then read as a finished season while the same process's upcoming + manager was showing the favourite's playoff games. + + Only regular season (2) and postseason (3) count as "later". The + offseason (4) follows the postseason too, and there past events really + do mean the season is over. + """ + season = ((payload.get('leagues') or [{}])[0] or {}).get('season') or {} + league_type = (season.get('type') or {}).get('type') + if league_type not in (2, 3): + return False + event_types = [(e.get('season') or {}).get('type') + for e in payload.get('events') or []] + known = [t for t in event_types if isinstance(t, int)] + return bool(known) and all(t < league_type for t in known) + @staticmethod def _parse_date(raw) -> Optional[datetime]: if not raw or not isinstance(raw, str): diff --git a/test/test_favorite_team_check.py b/test/test_favorite_team_check.py index 2b878012..35ef30ad 100644 --- a/test/test_favorite_team_check.py +++ b/test/test_favorite_team_check.py @@ -353,3 +353,89 @@ class ScheduleNoteUsesGameDatesTests(unittest.TestCase): "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)