mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(sports): scroll and Vegas cards name the printed date's own weekday (#747)
With scroll_card.date_format "weekday", a Friday 8 PM ET game read "Sat Oct 2" on the scroll and Vegas cards. Cause: the extractor prints the "M/D" in the plugin's resolved zone (its own setting, then the global one, then the system zone), but the card is handed only the plugin's config. Its timezone ships as "", so card_tzinfo fell back to UTC and the weekday belonged to the UTC date: the next day for evening games in the Americas, the previous day for morning games east of UTC (Auckland, Kiritimati). Fix: every zone is within a day of UTC, so the printed date is the start's UTC date or a neighbour of it. _format_date_as now takes the game and names the weekday of whichever of those days has the printed month and day, falling back to the zone-based weekday only when the start cannot place the date (no offset, unparseable, or more than a day away). The switch-mode scorebug shares the formatter and passes the game too, so the twins stay identical; it already used the resolved zone and draws what it drew before. Public signatures are unchanged. Tests cover US DST end, New Year's Eve, both sides of the date line, NZ DST start and UTC+14. The twins test's weekday pin is updated: the drawn date now agrees, and only the bare weekday helpers still differ. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -699,6 +699,17 @@ policies are unchanged.
|
||||
heartbeat when the service stops), is `stale` at once instead of `live`
|
||||
for up to 180 s. No new files or writes: both checks are on the reading
|
||||
side.
|
||||
- A scoreboard's scroll and Vegas cards with `scroll_card.date_format:
|
||||
"weekday"` now show the printed date's own weekday. A Friday 8 PM ET game
|
||||
read "Sat Oct 2". The card took the weekday in the plugin's own
|
||||
`timezone` setting, which ships blank, so it fell back to UTC, while the
|
||||
"Oct 2" beside it came from the zone the plugin actually resolves (its
|
||||
setting, then the global one, then the system zone). Every zone is within a
|
||||
day of UTC, so the card now finds which day near the start's UTC date has
|
||||
the printed month and day and names that one. Games east of UTC (Auckland,
|
||||
Kiritimati) were off by a day the other way and are fixed the same way.
|
||||
The switch-mode scorebug, which already used the plugin's resolved zone,
|
||||
shares the same formatter and draws what it drew before.
|
||||
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
||||
schedule-off blank, or an on-demand session starting or ending at once,
|
||||
even when the mode name stays the same. The display republished its
|
||||
|
||||
@@ -18,7 +18,7 @@ the extra guard only stops a None size raising TypeError.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
if not raw:
|
||||
return ""
|
||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
|
||||
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
|
||||
game=game)
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
|
||||
"""The weekday of the date a card prints as month/day, or '' if unknown.
|
||||
|
||||
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
|
||||
else the global one, else the system zone). The card cannot see that zone:
|
||||
it is handed the plugin's config, whose ``timezone`` ships as "", so
|
||||
card_tzinfo answers UTC and an evening kickoff in the Americas got the
|
||||
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
|
||||
a day of UTC, so the printed date is the start's UTC date or a neighbour
|
||||
of it; the one with that month and day is the date on the card.
|
||||
"""
|
||||
if not isinstance(game, dict):
|
||||
return ""
|
||||
raw = game.get("start_time_utc") or game.get("start_time")
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||
str(raw).replace("Z", "+00:00"))
|
||||
if start.utcoffset() is None:
|
||||
return "" # naive: no instant to place the date against
|
||||
utc_day = start.astimezone(timezone.utc).date()
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
return ""
|
||||
for offset in (0, -1, 1):
|
||||
try:
|
||||
candidate = utc_day + timedelta(days=offset)
|
||||
except OverflowError:
|
||||
continue
|
||||
if (candidate.month, candidate.day) == (month, day):
|
||||
return WEEKDAY_ABBR[candidate.weekday()]
|
||||
return ""
|
||||
|
||||
|
||||
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
|
||||
game: Optional[Dict] = None) -> str:
|
||||
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
|
||||
|
||||
The body both date formatters share. They differ in which setting names the
|
||||
@@ -349,6 +385,9 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
|
||||
*weekday* is a zero-argument callable, only called for the "weekday" style.
|
||||
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
|
||||
With *game*, the "weekday" style names the printed date's own weekday
|
||||
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
|
||||
date its start time cannot place.
|
||||
"""
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
|
||||
if fmt == "day_first":
|
||||
return f"{day} {name}"
|
||||
if fmt == "weekday":
|
||||
day_name = weekday()
|
||||
day_name = _printed_weekday(game, month, day) or weekday()
|
||||
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
|
||||
|
||||
@@ -360,14 +360,16 @@ class SportsCoreSharedMixin:
|
||||
The formatting is sports_card's. What differs from the card's
|
||||
``format_game_date`` is passed in: the setting (``switch_date_format``,
|
||||
see :meth:`_switch_date_format`) and the weekday, which comes from
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone.
|
||||
:meth:`_weekday_for` and so from this plugin's resolved timezone
|
||||
when the game's start cannot place the printed date. The game goes
|
||||
in too, so both formatters name the printed date's own weekday.
|
||||
"""
|
||||
raw = str(date_text or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
return _card._format_date_as(self._switch_date_format(), raw,
|
||||
lambda: self._weekday_for(game),
|
||||
self._MONTH_ABBR)
|
||||
self._MONTH_ABBR, game=game)
|
||||
|
||||
def _weekday_for(self, game: Optional[Dict]) -> str:
|
||||
"""Weekday abbreviation from the game's start time, or ''."""
|
||||
|
||||
@@ -13,6 +13,8 @@ body. That is what let all eight adopt this with byte-identical renders.
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -169,6 +171,95 @@ class TestDateAndTime:
|
||||
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||
|
||||
|
||||
class TestWeekdayMatchesThePrintedDate:
|
||||
"""The weekday is the printed date's, whichever zone printed it.
|
||||
|
||||
The extractor prints "M/D" in the plugin's resolved zone (its own
|
||||
setting, else the global one, else the system zone). The card is handed
|
||||
only the plugin's config, whose ``timezone`` ships as "" -- so a weekday
|
||||
taken in card_tzinfo's zone was UTC's, and an evening kickoff in the
|
||||
Americas read "Sat Oct 2" for a Friday game.
|
||||
"""
|
||||
|
||||
WEEKDAY = {"timezone": "", "scroll_card": {"date_format": "weekday"}}
|
||||
|
||||
@staticmethod
|
||||
def _as_printed(start_utc, zone):
|
||||
"""The game dict and the date text, as the extractor builds them."""
|
||||
local = datetime.fromisoformat(start_utc).astimezone(ZoneInfo(zone))
|
||||
game = {"start_time_utc": datetime.fromisoformat(start_utc),
|
||||
"game_date": f"{local.month}/{local.day}"}
|
||||
want = f"{C.WEEKDAY_ABBR[local.weekday()]} {C.MONTH_ABBR[local.month - 1]} {local.day}"
|
||||
return game, want
|
||||
|
||||
@pytest.mark.parametrize("start_utc, zone, want", [
|
||||
# Friday 8 PM EDT is Saturday in UTC.
|
||||
("2026-10-03T00:00:00+00:00", "America/New_York", "Fri Oct 2"),
|
||||
# The night US clocks go back: 8:30 PM EDT Saturday, then 11 PM EST
|
||||
# Sunday, each the next day in UTC.
|
||||
("2026-11-01T00:30:00+00:00", "America/New_York", "Sat Oct 31"),
|
||||
("2026-11-02T04:00:00+00:00", "America/New_York", "Sun Nov 1"),
|
||||
# New Year's Eve on the west coast is New Year's Day in UTC.
|
||||
("2027-01-01T04:00:00+00:00", "America/Los_Angeles", "Thu Dec 31"),
|
||||
# Just east of the date line: Pago Pago's Friday evening.
|
||||
("2026-10-03T05:00:00+00:00", "Pacific/Pago_Pago", "Fri Oct 2"),
|
||||
# Just west of it, the other way: Saturday morning in Auckland is
|
||||
# Friday in UTC -- and the 10 AM game on the day NZ clocks go forward.
|
||||
("2026-10-02T20:00:00+00:00", "Pacific/Auckland", "Sat Oct 3"),
|
||||
("2026-09-26T21:00:00+00:00", "Pacific/Auckland", "Sun Sep 27"),
|
||||
# UTC+14, the furthest any zone sits from UTC.
|
||||
("2026-10-02T11:00:00+00:00", "Pacific/Kiritimati", "Sat Oct 3"),
|
||||
# A zone on UTC's own date needs nothing.
|
||||
("2026-10-02T19:00:00+00:00", "Europe/London", "Fri Oct 2"),
|
||||
])
|
||||
def test_the_shipped_blank_timezone(self, log, start_utc, zone, want):
|
||||
game, printed = self._as_printed(start_utc, zone)
|
||||
assert printed == want # the case says what the extractor prints
|
||||
assert C.format_game_date(self.WEEKDAY, log, game["game_date"], game) == want
|
||||
|
||||
def test_an_iso_string_start_reads_the_same(self, log):
|
||||
game = {"start_time_utc": "2026-10-03T00:00:00Z"}
|
||||
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Fri Oct 2"
|
||||
assert C.format_game_date(self.WEEKDAY, log, "10/02", game) == "Fri Oct 2"
|
||||
|
||||
def test_a_plugin_level_zone_still_agrees(self, log):
|
||||
game, want = self._as_printed("2026-10-03T00:00:00+00:00", "America/Chicago")
|
||||
cfg = dict(self.WEEKDAY, timezone="America/Chicago")
|
||||
assert C.format_game_date(cfg, log, game["game_date"], game) == want == "Fri Oct 2"
|
||||
|
||||
def test_a_date_no_zone_could_print_keeps_the_zone_weekday(self, log):
|
||||
"""More than a day from the start: nothing to anchor to, so the
|
||||
weekday is card_tzinfo's, as it always was."""
|
||||
game = {"start_time_utc": datetime(2026, 10, 3, 0, 0, tzinfo=timezone.utc)}
|
||||
assert C.format_game_date(self.WEEKDAY, log, "10/9", game) == "Sat Oct 9"
|
||||
|
||||
def test_a_start_without_an_offset_keeps_the_zone_weekday(self, log):
|
||||
"""A naive time names no instant, so it cannot place the date."""
|
||||
game = {"start_time_utc": datetime(2026, 10, 2, 20, 0)}
|
||||
assert C.format_game_date(self.WEEKDAY, log, "10/3", game) == \
|
||||
f"{C.weekday_for(self.WEEKDAY, log, game)} Oct 3"
|
||||
|
||||
@pytest.mark.parametrize("game", [None, {}, {"start_time_utc": "garbage"}])
|
||||
def test_no_usable_start_draws_no_weekday(self, log, game):
|
||||
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Oct 2"
|
||||
|
||||
def test_the_scorebug_twin_formats_the_same(self, log):
|
||||
"""Switch mode (SportsCoreSharedMixin) shares the formatter body."""
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
|
||||
class Host(SportsCoreSharedMixin):
|
||||
config = {"scroll_card": {"date_format": "weekday",
|
||||
"switch_date_format": "inherit"}}
|
||||
logger = log
|
||||
|
||||
def _get_timezone(self):
|
||||
return ZoneInfo("America/New_York")
|
||||
|
||||
game, want = self._as_printed("2026-11-01T00:30:00+00:00", "America/New_York")
|
||||
assert Host()._format_game_date(game["game_date"], game) == want
|
||||
assert C.format_game_date(Host.config, log, game["game_date"], game) == want
|
||||
|
||||
|
||||
class TestFontSizing:
|
||||
def test_snaps_to_the_faces_pixel_grid(self):
|
||||
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||
|
||||
@@ -561,21 +561,24 @@ class TestPinnedDivergence:
|
||||
assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9)
|
||||
|
||||
def test_weekday_zone_source(self):
|
||||
# DIVERGENCE, user-visible: the scorebug asks the plugin's
|
||||
# DIVERGENCE, not drawn: the scorebug asks the plugin's
|
||||
# _get_timezone() (plugin setting -> global setting -> system zone);
|
||||
# the card reads only config["timezone"] and falls back to UTC. The
|
||||
# scoreboards' schemas default that key to "", and the scroll display
|
||||
# hands the renderer the plugin config, so a board that sets only the
|
||||
# global zone gets UTC weekdays in scroll mode: an evening kickoff in
|
||||
# New York is labelled with the next day.
|
||||
# hands the renderer the plugin config, so the bare weekday helpers
|
||||
# still disagree for an evening kickoff in New York.
|
||||
game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT
|
||||
host = _Host({}, tz=ZoneInfo("America/New_York"))
|
||||
assert host._weekday_for(game) == "Sat"
|
||||
assert C.weekday_for({}, LOG, game) == "Sun"
|
||||
# DECIDED: what a card draws is the printed date's own weekday, so
|
||||
# the scroll card no longer labels that kickoff with the next day
|
||||
# ("Sun Sep 19" before). Both formatters place the extractor's "M/D"
|
||||
# against the start time instead of re-deriving the day in a zone.
|
||||
cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}}
|
||||
host = _Host(cfg, tz=ZoneInfo("America/New_York"))
|
||||
assert host._format_game_date("9/19", game) == "Sat Sep 19"
|
||||
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19"
|
||||
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sat Sep 19"
|
||||
|
||||
def test_weekday_out_of_range_start(self):
|
||||
# DIVERGENCE: the mixin catches OverflowError from astimezone() and
|
||||
|
||||
Reference in New Issue
Block a user