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:
Chuck
2026-09-29 09:05:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b30052b59
commit fe5bed2886
8 changed files with 1304 additions and 2 deletions
+355
View File
@@ -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)}]}],
}))
+313
View File
@@ -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)