Files
LEDMatrix/test/test_sports_timezone.py
ChuckandClaude Opus 5.5 fe5bed2886 feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2) (#665)
* feat(common): favorite_team_check and sports_timezone, promoted from the scoreboards (sports consolidation stage 2)

Two new hardware-free modules, taken from files the scoreboard plugins carry
as copies:

- src/common/favorite_team_check.py: FavoriteTeamCheck(logger, leagues), the
  seven byte-identical <sport>_favorite_check.py copies. Same code; the only
  additions are two type annotations (for the mypy ratchet).
- src/common/sports_timezone.py: resolve_timezone_name(), resolve_timezone(),
  system_timezone_name(), from the ten <sport>_timezone.py copies. They
  differed only in the plugin label named in the nothing-resolved warning and
  the write-back-bug values, which become keyword-only arguments
  (plugin_label, writeback_fixed_in). Same resolution order and log text.

Tests are ported from the plugins' own (test_favorite_check.py,
test_schedule_note_uses_game_dates.py, test_timezone_resolution.py; the
timezone ones run once per plugin's values and pin the exact warning text).
Both modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's
Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses
them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(common): bdf_font and json_body shipped in 3.5.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(common): annotate the favourite check's deliberate except/pass for Bandit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 09:05:10 -04:00

314 lines
11 KiB
Python

"""src.common.sports_timezone: which zone a scoreboard renders start times in.
Ported from the scoreboard plugins' test_timezone_resolution.py, which test
their bundled ``<sport>_timezone.py`` copies. The copies differ only in the
two values this module takes as keyword arguments, so every test runs once per
plugin with that plugin's values (``PLUGINS``).
Regression the resolution order guards: a plugin used to read the global
timezone only from ``cache_manager.config_manager``. On cores that hang
``config_manager`` off the plugin manager instead, that lookup found nothing
and every start time was drawn in UTC.
"""
import logging
from datetime import datetime
import pytest
import pytz
from src.common import sports_timezone
from src.common.sports_timezone import resolve_timezone, resolve_timezone_name
#: (plugin_label, writeback_fixed_in) as the plugins' copies carried them.
#: Only baseball and football ever wrote "UTC" back into the saved config.
PLUGINS = [
("AFL scoreboard", None),
("baseball scoreboard", "1.20.0"),
("basketball scoreboard", None),
("F1 scoreboard", None),
("football scoreboard", "2.9.0"),
("hockey scoreboard", None),
("lacrosse scoreboard", None),
("NRL scoreboard", None),
("soccer scoreboard", None),
("UFC scoreboard", None),
]
@pytest.fixture(params=PLUGINS, ids=[label for label, _ in PLUGINS])
def plugin(request):
label, fixed_in = request.param
return {"plugin_label": label, "writeback_fixed_in": fixed_in}
@pytest.fixture
def system_zone(monkeypatch):
"""Stub system-zone detection so results don't depend on this machine."""
def stub(value):
monkeypatch.setattr(sports_timezone, "system_timezone_name", lambda: value)
stub(None)
return stub
class _ConfigManager:
"""Core ConfigManager stand-in exposing get_timezone()."""
def __init__(self, timezone=None):
self._timezone = timezone
def get_timezone(self):
return self._timezone
class _LegacyConfigManager:
"""Older core: no get_timezone(), only load_config()."""
def __init__(self, timezone=None):
self._timezone = timezone
def load_config(self):
return {"timezone": self._timezone}
class _CountingConfigManager(_ConfigManager):
"""Records how many times the core was asked for the timezone."""
calls = 0
def get_timezone(self):
self.calls += 1
return self._timezone
class _BrokenConfigManager:
"""Core whose get_timezone() blows up -- must not take the plugin down."""
def get_timezone(self):
raise RuntimeError("config not loaded")
class _RealCoreConfigManager:
"""Faithful stand-in for the shipping core ConfigManager.
The real get_timezone() is ``self.config.get('timezone', 'UTC')`` -- it
substitutes its own "UTC" when the global config has no timezone key.
"""
def __init__(self, config):
self._config = config
def get_config(self):
return self._config
def load_config(self):
return self._config
def get_timezone(self):
return self._config.get("timezone", "UTC")
class _Holder:
"""Stands in for a plugin_manager / cache_manager."""
def __init__(self, config_manager=None):
if config_manager is not None:
self.config_manager = config_manager
def test_plugin_config_override_wins(plugin):
assert resolve_timezone_name(
config={"timezone": "America/Denver"},
plugin_manager=_Holder(_ConfigManager("America/New_York")),
cache_manager=_Holder(_ConfigManager("Europe/London")),
**plugin,
) == "America/Denver"
def test_lower_priority_sources_are_not_evaluated(plugin, monkeypatch):
"""SportsCore._get_timezone() runs per game; once a candidate resolves, the
remaining sources must not be touched."""
plugin_cm = _CountingConfigManager("America/New_York")
cache_cm = _CountingConfigManager("Europe/London")
system_calls = []
monkeypatch.setattr(sports_timezone, "system_timezone_name",
lambda: system_calls.append(1) or None)
name = resolve_timezone_name(
config={"timezone": "America/Chicago"},
plugin_manager=_Holder(plugin_cm),
cache_manager=_Holder(cache_cm),
**plugin,
)
assert name == "America/Chicago"
assert (plugin_cm.calls, cache_cm.calls, system_calls) == (0, 0, [])
def test_plugin_manager_config_manager_is_consulted(plugin, system_zone):
"""The regression: cache_manager has no config_manager at all."""
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(_ConfigManager("America/Chicago")),
cache_manager=_Holder(),
**plugin,
) == "America/Chicago"
def test_cache_manager_config_manager_fallback(plugin, system_zone):
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(),
cache_manager=_Holder(_ConfigManager("America/Chicago")),
**plugin,
) == "America/Chicago"
def test_legacy_load_config_fallback(plugin, system_zone):
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(_LegacyConfigManager("America/Chicago")),
cache_manager=_Holder(),
**plugin,
) == "America/Chicago"
def test_raising_config_manager_falls_through(plugin, system_zone):
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(_BrokenConfigManager()),
cache_manager=_Holder(_ConfigManager("America/Chicago")),
**plugin,
) == "America/Chicago"
def test_blank_and_invalid_values_are_skipped(plugin, system_zone, caplog):
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
name = resolve_timezone_name(
config={"timezone": " "},
plugin_manager=_Holder(_ConfigManager("Not/AZone")),
cache_manager=_Holder(_ConfigManager("America/Chicago")),
**plugin,
)
assert name == "America/Chicago"
assert caplog.messages == [
"Ignoring invalid timezone 'Not/AZone' from plugin_manager.config_manager"]
def test_system_timezone_backstop(plugin, system_zone):
system_zone("America/Chicago")
assert resolve_timezone_name(
config={}, plugin_manager=_Holder(), cache_manager=_Holder(), **plugin,
) == "America/Chicago"
def test_utc_last_resort_names_the_plugin(plugin, system_zone, caplog):
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
name = resolve_timezone_name(config={}, **plugin)
assert name == "UTC"
# Word for word what the plugins' copies logged, with their own name in it.
assert caplog.messages == [
"Could not determine a timezone from the plugin config, the LEDMatrix "
"config or the system; game times will be shown in UTC. Set a timezone "
f"in the {plugin['plugin_label']}'s Advanced Settings to override."]
def test_log_defaults_to_this_modules_logger_and_honours_a_given_one(plugin, system_zone, caplog):
own = logging.getLogger("test.sports_timezone.own")
with caplog.at_level(logging.WARNING):
resolve_timezone_name(config={}, **plugin)
resolve_timezone_name(config={}, log=own, **plugin)
assert [r.name for r in caplog.records] == [sports_timezone.__name__, own.name]
def test_resolve_timezone_returns_tzinfo_and_converts(plugin):
tz = resolve_timezone(config={"timezone": "America/Chicago"}, **plugin)
# 2026-07-28 23:45Z is a 6:45pm CDT first pitch -- the exact symptom that
# started this: a Chicago game rendering as 11:45PM.
local = datetime(2026, 7, 28, 23, 45, tzinfo=pytz.UTC).astimezone(tz)
assert local.strftime("%I:%M%p").lstrip("0") == "6:45PM"
def test_plugin_level_utc_when_the_global_config_disagrees(plugin, system_zone, caplog):
"""With the write-back bug, a bare "UTC" is its artifact and is ignored;
without it, "UTC" can only be the user's own choice and is honored."""
with caplog.at_level(logging.WARNING, logger=sports_timezone.__name__):
name = resolve_timezone_name(
config={"timezone": "UTC"},
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
cache_manager=_Holder(),
**plugin,
)
fixed_in = plugin["writeback_fixed_in"]
if fixed_in is None:
assert name == "UTC"
assert caplog.messages == []
else:
assert name == "America/Chicago"
assert caplog.messages == [
"Ignoring the plugin-level timezone 'UTC': it is almost certainly "
f"left over from the write-back bug fixed in {fixed_in}, and "
"plugin_manager.config_manager says America/Chicago. Using "
"America/Chicago. If you really do want UTC here, set this plugin's "
"timezone to 'Etc/UTC' instead."]
def test_plugin_level_utc_against_the_system_zone(plugin, system_zone):
system_zone("America/Chicago")
name = resolve_timezone_name(
config={"timezone": "UTC"}, plugin_manager=_Holder(), cache_manager=_Holder(),
**plugin,
)
assert name == ("UTC" if plugin["writeback_fixed_in"] is None else "America/Chicago")
def test_utc_is_kept_when_nothing_disagrees(plugin, system_zone):
"""A genuinely-UTC device must not be dragged off UTC."""
system_zone("UTC")
assert resolve_timezone_name(
config={"timezone": "UTC"},
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "UTC"})),
cache_manager=_Holder(),
**plugin,
) == "UTC"
def test_etc_utc_is_always_honored(plugin):
"""The unambiguous opt-in the write-back bug could never have produced."""
assert resolve_timezone_name(
config={"timezone": "Etc/UTC"},
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
cache_manager=_Holder(),
**plugin,
) == "Etc/UTC"
def test_absent_global_key_falls_through_to_system_zone(plugin, system_zone):
"""The core's get_timezone() returns its own 'UTC' default for a config
with no timezone key; that must not mask the system zone."""
system_zone("America/Chicago")
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(_RealCoreConfigManager({"display": {}})),
cache_manager=_Holder(),
**plugin,
) == "America/Chicago"
def test_present_global_key_still_wins_over_system_zone(plugin, system_zone):
system_zone("America/Denver")
assert resolve_timezone_name(
config={},
plugin_manager=_Holder(_RealCoreConfigManager({"timezone": "America/Chicago"})),
cache_manager=_Holder(),
**plugin,
) == "America/Chicago"
def test_plugin_label_is_required():
with pytest.raises(TypeError):
resolve_timezone_name(config={}) # type: ignore[call-arg]
def test_system_timezone_name_is_a_string_or_none():
value = sports_timezone.system_timezone_name()
assert value is None or isinstance(value, str)