mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f96075076 | ||
|
|
439013b18c | ||
|
|
e5bbfa2ae3 | ||
|
|
db49275075 |
@@ -19,6 +19,38 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 3.6.2
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#670).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer says the Europa League season has
|
||||||
|
finished between matchdays. Its scoreboard keeps showing the last matchday,
|
||||||
|
and its calendar is a "list" of rounds rather than match days, so neither
|
||||||
|
3.6.1 rule applied. When every event is past, a round in a list calendar
|
||||||
|
that has not started yet (outside an offseason phase) now draws no
|
||||||
|
conclusion. PLL, the World Cup and AFL, whose seasons are over, are still
|
||||||
|
reported as finished: no round of theirs is still to start. (#670)
|
||||||
|
|
||||||
|
## 3.6.1
|
||||||
|
|
||||||
|
A fix to `src.common.favorite_team_check` (#667). Plugins that drop their
|
||||||
|
bundled copy of it should floor on 3.6.1, not 3.6.0.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- The favourite-team check no longer logs "the season has finished" for a
|
||||||
|
league that is still playing. ESPN's default scoreboard keeps showing the
|
||||||
|
last slate after it: MLB's regular-season games two days into the
|
||||||
|
postseason, a soccer league's previous matchday between rounds. When every
|
||||||
|
event is in the past, the check now looks first at the league's phase (a
|
||||||
|
regular season or postseason that has moved past the events shown draws no
|
||||||
|
conclusion) and at a match-day calendar (`calendarType` "day" with
|
||||||
|
`calendarIsWhitelist`, as soccer, the NHL and the NBA use), whose next date
|
||||||
|
becomes "nothing on until <date>". An offseason, or a payload without these
|
||||||
|
fields, is reported as before.
|
||||||
|
|
||||||
## 3.6.0
|
## 3.6.0
|
||||||
|
|
||||||
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
|
New modules a plugin may import via `src.*` (floor on 3.6.0). Both are
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.6.0"
|
__version__ = "3.6.2"
|
||||||
|
|
||||||
|
|||||||
@@ -199,13 +199,27 @@ class FavoriteTeamCheck:
|
|||||||
# has no events at all to roll forward to: events that exist but are
|
# 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
|
# all in the past mean the season is over, and an offseason calendar
|
||||||
# phase must not be dressed up as its next game.
|
# 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):
|
if any(event_dates):
|
||||||
upcoming = future(event_dates)
|
upcoming = future(event_dates)
|
||||||
|
if not upcoming and cls._calendar_is_match_days(payload):
|
||||||
|
upcoming = future(calendar_dates)
|
||||||
else:
|
else:
|
||||||
upcoming = future(calendar_dates)
|
upcoming = future(calendar_dates)
|
||||||
if not upcoming:
|
if not upcoming:
|
||||||
if not any(event_dates) and not any(calendar_dates):
|
if not any(event_dates) and not any(calendar_dates):
|
||||||
return None # Nothing published either way; draw no conclusion.
|
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.
|
||||||
|
if cls._later_round_scheduled(payload, now):
|
||||||
|
return None # e.g. Europa League between matchdays.
|
||||||
return ("the season has finished and the next one's fixtures are "
|
return ("the season has finished and the next one's fixtures are "
|
||||||
"not published yet")
|
"not published yet")
|
||||||
|
|
||||||
@@ -215,6 +229,76 @@ class FavoriteTeamCheck:
|
|||||||
return "the league has nothing on until {}".format(
|
return "the league has nothing on until {}".format(
|
||||||
upcoming[0].strftime('%d %B %Y'))
|
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)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _later_round_scheduled(cls, payload, now: datetime) -> bool:
|
||||||
|
"""
|
||||||
|
Whether a "list" calendar has a round that has not started yet.
|
||||||
|
|
||||||
|
Competitions with a list calendar (the UEFA club competitions, the
|
||||||
|
World Cup, AFL, NFL) give each phase its rounds as ``entries`` with
|
||||||
|
start and end dates. Between matchdays the Europa League scoreboard
|
||||||
|
keeps showing the last one: on 2026-09-29 every event was from 17
|
||||||
|
September, the next matchday was only days away, and the rounds from
|
||||||
|
the knockout play-offs to the final were all still to come. A round
|
||||||
|
that starts later means the season is not over, even though the
|
||||||
|
date of the next fixture is not known.
|
||||||
|
|
||||||
|
Only a round's *start* counts. End dates are padded well past the
|
||||||
|
last game -- the World Cup's final round ran to 1 August for a 19 July
|
||||||
|
final -- so a future end date is also true of a finished season.
|
||||||
|
Rounds in an offseason phase (the college football All-Star week)
|
||||||
|
are not games for the favourites and do not count either.
|
||||||
|
"""
|
||||||
|
league = (payload.get('leagues') or [{}])[0] or {}
|
||||||
|
for phase in league.get('calendar') or []:
|
||||||
|
if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')):
|
||||||
|
continue
|
||||||
|
for entry in phase.get('entries') or []:
|
||||||
|
if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')):
|
||||||
|
continue
|
||||||
|
start = cls._parse_date(entry.get('startDate'))
|
||||||
|
if start and start > now:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_offseason(label) -> bool:
|
||||||
|
"""'Off Season', 'Offseason', 'Off-season' ..."""
|
||||||
|
return isinstance(label, str) and 'offseason' in re.sub(
|
||||||
|
r'[^a-z]', '', label.lower())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_date(raw) -> Optional[datetime]:
|
def _parse_date(raw) -> Optional[datetime]:
|
||||||
if not raw or not isinstance(raw, str):
|
if not raw or not isinstance(raw, str):
|
||||||
|
|||||||
@@ -353,3 +353,187 @@ class ScheduleNoteUsesGameDatesTests(unittest.TestCase):
|
|||||||
"events": [{"date": self.iso(base, 1)}],
|
"events": [{"date": self.iso(base, 1)}],
|
||||||
"leagues": [{"calendar": [{"startDate": self.iso(base, 6)}]}],
|
"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)
|
||||||
|
|||||||
Reference in New Issue
Block a user