mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""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
|
||||
``<sport>_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)}]}],
|
||||
}))
|
||||
@@ -0,0 +1,313 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user