diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f3dd34..c9440de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,24 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +New modules a plugin may import via `src.*` (floor on the release that ships +them). Both are promoted from files the scoreboard plugins carry as copies; +the plugins keep their copies as a fallback until they floor on that release. + +- `src/common/favorite_team_check.py` — `FavoriteTeamCheck(logger, leagues)`: + checks configured favourite team codes against ESPN once per league, on a + daemon thread, and logs why a league shows nothing (a wrong code, with the + nearest real one, or a season that has not started). The seven copies + (`_favorite_check.py`) were byte-identical; this is the same code, + with type annotations added. +- `src/common/sports_timezone.py` — `resolve_timezone_name()` / + `resolve_timezone()` (plus `system_timezone_name()`): the timezone a + scoreboard draws start times in. The ten copies (`_timezone.py`) + differed only in two values, which are keyword-only arguments here: + `plugin_label` (named in the warning logged when nothing resolves) and + `writeback_fixed_in` (for a plugin that once wrote `"UTC"` back into the + saved config; `None` otherwise). Same resolution order and log messages. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index b03e5767..efb25d25 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -81,6 +81,8 @@ more. Shared sports code lives in `src/common`: | `sports_shared.py` | 3.3.0 | `SportsCoreSharedMixin`, `SportsLiveSharedMixin`, `SportsRecentSharedMixin` — the sport-independent `sports.py` methods | | `sports_helpers.py` | 3.5.0 | clamp/logo/rotation free functions and `SportsHelpersMixin`, plus the `_favorite_key` seam | | `espn_dates.py` | 3.5.0 | ESPN date-range and `limit` workarounds | +| `favorite_team_check.py` | Unreleased | `FavoriteTeamCheck` — logs why a favourite team code shows nothing | +| `sports_timezone.py` | Unreleased | Which timezone start times are drawn in (`resolve_timezone_name`) | Each is described in [src/common/README.md](../src/common/README.md). diff --git a/mypy-clean.txt b/mypy-clean.txt index a78faf48..b3706103 100644 --- a/mypy-clean.txt +++ b/mypy-clean.txt @@ -21,6 +21,7 @@ src/common/__init__.py src/common/api_helper.py src/common/bdf_font.py src/common/espn_dates.py +src/common/favorite_team_check.py src/common/font_layout.py src/common/frame_timing.py src/common/json_body.py @@ -32,6 +33,7 @@ src/common/scroll_config.py src/common/snapshot_policy.py src/common/sports_card.py src/common/sports_scroll.py +src/common/sports_timezone.py src/config_service.py src/core_config_keys.py src/deprecation.py diff --git a/src/common/README.md b/src/common/README.md index 0179b604..0a52c0a0 100644 --- a/src/common/README.md +++ b/src/common/README.md @@ -24,11 +24,12 @@ Rules for the package: | Module | For | Plugins import it? | Since | |---|---|---|---| | [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — | -| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | Unreleased | +| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 | | [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 | +| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | Unreleased | | [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 | | [`frame_timing`](#frame_timing) | Timing of every presented frame, stall watchdog | No, core-internal | n/a | -| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | Unreleased | +| [`json_body`](#json_body) | Parse a response body as JSON, with orjson if installed | Optional (large payloads) | 3.5.0 | | [`logo_helper`](#logo_helper) | Load, resize and cache team logos | Yes | — | | [`path_safety`](#path_safety) | Turn request-supplied names into safe paths | No, core-internal | n/a | | [`permission_utils`](#permission_utils) | File modes and shared-group ownership | Rarely | — | @@ -41,6 +42,7 @@ Rules for the package: | [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 | | [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 | | [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 | +| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | Unreleased | | [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a | | [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — | @@ -100,6 +102,17 @@ results; `espn_date_chunks()`, `fetch_espn_date_chunks()`, `clamp_espn_limit()` and `merge_scoreboard_payloads()` are the pieces. Scoreboard plugins also bundle a copy for older cores. +### favorite_team_check + +[`favorite_team_check.py`](favorite_team_check.py). +`FavoriteTeamCheck(logger, leagues)`, where `leagues` maps a league key to +`(display name, ESPN sport/league path)`. `schedule(league_key, favorites)` +checks the configured favourite team codes against ESPN's team list once per +league, on a daemon thread, and logs a bad code with the nearest real one, or +says the league has nothing on yet; `reset()` re-arms it after a config edit. +Diagnostics only: every failure is swallowed. Scoreboard plugins also bundle +a copy for older cores. + ### font_layout [`font_layout.py`](font_layout.py). `load_truetype(path, size)` is @@ -237,6 +250,18 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists the attributes the host class must have and the three methods deliberately left out. +### sports_timezone + +[`sports_timezone.py`](sports_timezone.py). +`resolve_timezone_name(config, plugin_manager, cache_manager, log, *, +plugin_label, writeback_fixed_in=None)` and `resolve_timezone(...)` (the same +as a pytz zone): the plugin's own `timezone`, then the global one via either +manager's `config_manager`, then the host's zone (`system_timezone_name()`), +then UTC. `plugin_label` names the plugin in the warning logged when nothing +resolves; `writeback_fixed_in` is for a plugin that once wrote `"UTC"` into +the saved config (a bare plugin-level `"UTC"` is then ignored when another +source disagrees). Scoreboard plugins also bundle a copy for older cores. + ### sync_manager [`sync_manager.py`](sync_manager.py). Core-internal. `DisplaySyncManager` diff --git a/src/common/favorite_team_check.py b/src/common/favorite_team_check.py new file mode 100644 index 00000000..bcb30481 --- /dev/null +++ b/src/common/favorite_team_check.py @@ -0,0 +1,325 @@ +""" +Explain an empty screen: a wrong team code, or a season that has not started. + +Favourite teams are matched by exact ESPN abbreviation, so a plausible-looking +code silently matches nothing and the plugin shows an empty screen with no hint +that the code is at fault. The codes are not always guessable — ESPN calls +Alabama ``ALA`` rather than ``BAMA``, and Golden State ``GS`` rather than +``GSW``. Between seasons a perfectly correct code produces the same empty +screen for a completely different reason, and the two were indistinguishable +from the logs. + +This module is diagnostics only. It runs on a daemon thread, once per league per +process, and every failure is swallowed: it must never delay a frame or change +what is displayed. +""" + +import difflib +import logging +import re +import threading +from datetime import datetime, timezone +from typing import Dict, Iterable, List, Optional, Set, Tuple + +TEAMS_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/teams?limit=1000" +SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/{path}/scoreboard" +REQUEST_TIMEOUT = 15 + + +class FavoriteTeamCheck: + """ + Validates configured favourite team codes against ESPN, and says so in the log. + + ``leagues`` maps the plugin's own league key to a + ``(human readable name, ESPN sport/league path)`` pair, e.g. + ``{'nhl': ('NHL', 'hockey/nhl')}``. + """ + + # How far out the next fixture has to be before it is worth mentioning. + # An off day or two is normal mid-season and saying so would just be noise. + GAP_DAYS = 3 + + def __init__(self, logger: Optional[logging.Logger], + leagues: Dict[str, Tuple[str, str]]) -> None: + self.logger = logger or logging.getLogger(__name__) + self.leagues = leagues + self._checked: Set[str] = set() + self._lock = threading.Lock() + + def reset(self) -> None: + """Re-check on the next call, e.g. after the user edits the config.""" + with self._lock: + self._checked.clear() + + def schedule(self, league_key: str, favorites: Iterable[str]) -> None: + """Check one league in the background, at most once per process.""" + try: + favorites = [str(f) for f in (favorites or []) if str(f).strip()] + if not favorites or league_key not in self.leagues: + return + with self._lock: + if league_key in self._checked: + return + self._checked.add(league_key) + threading.Thread( + target=self._run, args=(league_key, favorites), + name="favorite-team-check", daemon=True, + ).start() + except Exception: + pass # nosec B110 - a diagnostic must never be the reason an update fails # nosemgrep + + def _run(self, league_key: str, favorites) -> None: + try: + self._check(league_key, favorites) + except Exception as exc: + self.logger.debug("Favorite team check failed for %s: %s", + league_key, exc) + + def _check(self, league_key: str, favorites) -> None: + name, path = self.leagues[league_key] + + try: + teams = self._fetch_teams(path) + except Exception as exc: + self.logger.debug("Could not verify %s favorite teams: %s", name, exc) + return + if not teams: + # Some ESPN endpoints (college lacrosse) return no teams at all. + # Nothing can be concluded, so say nothing. + return + + # Dynamic groups like AP_TOP_25 are expanded elsewhere; they are not + # team codes and must not be reported as bad ones. + codes = [f for f in favorites if not self._is_dynamic(f)] + recognised = [f for f in codes if f in teams] + unknown = [f for f in codes if f not in teams] + + for code in unknown: + self.logger.warning( + "%s favorite team %r is not a %s team code.%s " + "Every code this league accepts is listed at %s.", + name, code, name, self._suggest(code, teams), + TEAMS_URL.format(path=path), + ) + + if codes and not recognised: + self.logger.warning( + "%s has no recognised favorite teams, so nothing will be shown " + "for it. Codes must be ESPN abbreviations, e.g. %s.", + name, ", ".join("{} ({})".format(a, n) + for a, n in list(sorted(teams.items()))[:3]), + ) + return + + if not recognised: + return + + # Codes are fine, so check the other cause of an empty screen. + try: + note = self._schedule_note(path) + except Exception as exc: + self.logger.debug("Could not check the %s schedule: %s", name, exc) + return + + if note: + self.logger.info( + "%s favorite teams %s look correct, but %s. An empty display " + "until then is expected, not a configuration problem.", + name, ", ".join(recognised), note, + ) + else: + self.logger.info("%s favorite teams recognised: %s", + name, ", ".join(recognised)) + + @staticmethod + def _is_dynamic(code: str) -> bool: + upper = (code or "").strip().upper() + return upper.startswith("AP_") or upper.startswith("TOP_") or "TOP_" in upper + + @staticmethod + def _fetch_teams(path: str) -> Dict[str, str]: + """ESPN's {abbreviation: display name} for a league. + + ``limit=1000`` is required: the default page size truncates the NCAA + responses to roughly half their teams, which makes valid codes look wrong. + """ + import requests + + payload = requests.get(TEAMS_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + entries = payload['sports'][0]['leagues'][0]['teams'] + return { + t['team']['abbreviation']: t['team']['displayName'] + for t in entries if t.get('team', {}).get('abbreviation') + } + + @classmethod + def _schedule_note(cls, path: str) -> Optional[str]: + """ + Why the league has nothing to show, as a clause, or ``None`` if it does. + + Two things make this harder than reading ``events``: + + * An out-of-season league does not come back empty. ESPN rolls the + scoreboard forward to the next day that has fixtures, so in July the + NHL endpoint returns seven September games. Emptiness cannot be the + signal; the date of those games is, and it is more useful anyway. + * A *finished* season rolls nowhere and returns its last game instead, + months in the past — so dates have to be filtered to the future + before the soonest one means anything. + """ + import requests + + payload = requests.get(SCOREBOARD_URL.format(path=path), + timeout=REQUEST_TIMEOUT).json() + + event_dates = [cls._parse_date(e.get('date')) + for e in payload.get('events') or []] + calendar_dates = [] + for entry in (payload.get('leagues') or [{}])[0].get('calendar') or []: + calendar_dates.append(cls._parse_date( + entry if isinstance(entry, str) else entry.get('startDate'))) + + # Count the last day as current, rather than filtering on "later than + # right now": a game that began a few hours ago still means the league + # has something on, and dropping it would report a live slate as a + # finished season. A day's grace also keeps this correct whatever the + # user's timezone, since these timestamps are UTC. + now = datetime.now(timezone.utc) + + def future(candidates): + return sorted(d for d in candidates if d and (now - d).days < 1) + + # Events are fixtures; the calendar is week and phase boundaries, + # which routinely open days before their first game (an NFL week 1 + # calendar entry starts the weekend before the Thursday opener). + # Reading the two together reported the earliest boundary as a game + # date -- "nothing on until 06 September" for a league whose first + # snap is the 10th. The calendar only gets a say when the scoreboard + # 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 + # phase must not be dressed up as its next game. + if any(event_dates): + upcoming = future(event_dates) + else: + upcoming = future(calendar_dates) + if not upcoming: + if not any(event_dates) and not any(calendar_dates): + return None # Nothing published either way; draw no conclusion. + return ("the season has finished and the next one's fixtures are " + "not published yet") + + # A day or two out is just an off day, and saying so would be noise. + if (upcoming[0] - now).days < cls.GAP_DAYS: + return None + return "the league has nothing on until {}".format( + upcoming[0].strftime('%d %B %Y')) + + @staticmethod + def _parse_date(raw) -> Optional[datetime]: + if not raw or not isinstance(raw, str): + return None + try: + return datetime.fromisoformat(raw.replace('Z', '+00:00')) + except ValueError: + return None + + @classmethod + def _suggest(cls, code: str, teams: Dict[str, str]) -> str: + """Nearest matching code for a typo, as a ready-to-log clause.""" + upper = (code or "").strip().upper() + if not upper or code in teams: + return "" + + # Right code, wrong case — matching is case-sensitive. Guard on the case + # actually differing, so a valid code never draws this message. + for abbr in teams: + if abbr.upper() == upper: + return " Codes are case-sensitive; use {!r} ({}).".format( + abbr, teams[abbr]) + + ranked = cls._rank(upper, (a for a, n in teams.items() + if cls._abbreviates(upper, n)), teams) + if not ranked: + # Nicknames are often a fragment of a word rather than its initials: + # 'BAMA' sits inside 'Alabama' but abbreviates nothing in it. Require + # three characters, since shorter fragments match far too much. + if len(upper) >= 3: + ranked = cls._rank( + upper, + (a for a, n in teams.items() + if any(upper in w for w in cls._words(n))), + teams) + + if len(ranked) == 1: + return " Closest match is {!r} ({}).".format( + ranked[0], teams[ranked[0]]) + if ranked: + return " Did you mean {}?".format(", ".join( + "{!r} ({})".format(a, teams[a]) for a in ranked[:3])) + + # Otherwise fall back to similarity, against names before codes: a name + # gives more characters to compare and so produces fewer ties. + names = {n.upper(): a for a, n in teams.items()} + hits = difflib.get_close_matches(upper, list(names), n=1, cutoff=0.6) + if hits: + abbr = names[hits[0]] + return " Closest match is {!r} ({}).".format(abbr, teams[abbr]) + + code_hits = difflib.get_close_matches(upper, list(teams), n=1, cutoff=0.6) + if code_hits: + return " Closest match is {!r} ({}).".format( + code_hits[0], teams[code_hits[0]]) + return "" + + @staticmethod + def _words(name: str): + return [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + @classmethod + def _rank(cls, code: str, candidates, teams: Dict[str, str]): + """ + Order candidate codes best-first. + + A code that picks up the *first* word of the name wins, because that is + how people shorten team names: 'SCAR' for South Carolina starts at + 'South', whereas for Rutgers Scarlet Knights it starts mid-name. Without + this the tie is broken alphabetically and the obvious answer can land + third in the list. + """ + def key(abbr): + words = cls._words(teams.get(abbr, '')) + first_word_hit = bool(words) and words[0].startswith(code[:1]) + return (not first_word_hit, len(abbr), abbr) + + return sorted(set(candidates), key=key) + + @staticmethod + def _abbreviates(code: str, name: str) -> bool: + """ + Whether ``code`` reads as an abbreviation of ``name``. + + Each part of the code must be a prefix of one of the name's words, taken + in order — which is how people actually shorten team names. Plain string + similarity is no use for three-letter codes: 'MUN' scores identically + against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the + suggestion is a coin flip. This rule separates them, because 'MUN' + splits as M-anchester UN-ited while Sunderland has no word starting M. + """ + words = [w for w in re.split(r'[^A-Za-z0-9]+', (name or '').upper()) if w] + + def consume(rest: str, remaining: List[str]) -> bool: + if not rest: + return True + if not remaining: + return False + head, tail = remaining[0], remaining[1:] + # Skip this word entirely, as in "Manchester United" -> "UTD". + if consume(rest, tail): + return True + for size in range(1, min(len(rest), len(head)) + 1): + if head.startswith(rest[:size]) and consume(rest[size:], tail): + return True + return False + + return consume((code or "").strip().upper(), words) diff --git a/src/common/sports_timezone.py b/src/common/sports_timezone.py new file mode 100644 index 00000000..aa2882de --- /dev/null +++ b/src/common/sports_timezone.py @@ -0,0 +1,262 @@ +"""Timezone resolution for the scoreboard plugins. + +Game start times arrive from ESPN in UTC and have to be converted to the +user's local zone before they are drawn. This module owns the "which zone?" +decision so every part of a scoreboard (its scorebug, its scroll-mode game +card and its plugin manager) agrees. + +The scoreboards each carried a copy of this module as ``_timezone.py``. +The copies were identical apart from two per-plugin values, which are +keyword-only arguments here: ``plugin_label``, the name the final warning +tells the user to open, and ``writeback_fixed_in`` (see below). + +Resolution order, first valid wins: + +1. ``timezone`` in the plugin's own config (explicit per-plugin override) +2. The LEDMatrix global timezone via ``plugin_manager.config_manager`` +3. The LEDMatrix global timezone via ``cache_manager.config_manager`` +4. The host system's zone (``TZ``, ``/etc/timezone``, ``/etc/localtime``) +5. UTC + +Steps 2 and 3 matter because the core does not consistently hang +``config_manager`` off both objects -- reading only one of them is what made +a scoreboard fall through to UTC while the clock plugin (which checks +``plugin_manager`` first) showed the right time on the same device. Step 4 is +the backstop for cores that expose no ``config_manager`` at all: a Pi with its +system clock set correctly should never end up rendering UTC. + +Two traps this module exists to avoid, both of which render every start time +in UTC on a correctly-configured device: + +* **The stale ``"UTC"`` artifact.** Some plugins once wrote + ``"timezone": "UTC"`` into the *saved* config whenever resolution failed, and + that write-back stuck -- thereafter shadowing the real global timezone. Such + a plugin passes ``writeback_fixed_in`` (the release that fixed it), and step 1 + then treats a bare ``"UTC"`` as suspect: it is honored only when nothing + downstream disagrees. ``Etc/UTC`` is the unambiguous spelling for "I really + do want UTC"; the bug never produced it, so it is always honored. A plugin + that never had the bug leaves ``writeback_fixed_in`` as ``None``, and its + plugin-level ``"UTC"`` is honored verbatim. +* **``get_timezone()``'s own default.** The core's + ``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')``, so + it hands back ``"UTC"`` for a config that simply has no ``timezone`` key. + Steps 2 and 3 read the raw config dict instead, so an absent key falls + through to the system zone rather than latching onto that default. +""" + +import logging +import os +from typing import Any, Dict, Iterable, Optional, Tuple + +import pytz + +logger = logging.getLogger(__name__) + + +def _from_config_manager(config_manager: Any, log: logging.Logger) -> Optional[str]: + """Pull the global timezone out of a core ConfigManager, if it has one. + + Reads the raw config dict in preference to ``get_timezone()``. The core's + ``ConfigManager.get_timezone()`` is ``self.config.get('timezone', 'UTC')`` -- + it substitutes its own ``"UTC"`` when the key is absent, which is + indistinguishable from the user deliberately choosing UTC. Taking that at + face value would mask a missing global setting and stop resolution ever + reaching the host system zone. So: if the raw config is readable and has no + ``timezone`` key, report "nothing here" and let the caller fall through. + ``get_timezone()`` is only consulted for cores that expose no raw config. + """ + if config_manager is None: + return None + + raw_readable = False + for loader_name in ("get_config", "load_config"): + loader = getattr(config_manager, loader_name, None) + if not callable(loader): + continue + try: + main_config = loader() + except Exception: + log.debug("config_manager.%s() failed", loader_name, exc_info=True) + continue + if isinstance(main_config, dict): + raw_readable = True + name: Optional[str] = main_config.get("timezone") + if name: + return name + + if raw_readable: + return None + + getter = getattr(config_manager, "get_timezone", None) + if callable(getter): + try: + name = getter() + if name: + return name + except Exception: + log.debug("config_manager.get_timezone() failed", exc_info=True) + + return None + + +def system_timezone_name() -> Optional[str]: + """Best-effort IANA name for the host's configured timezone.""" + name = os.environ.get("TZ") + if name: + return name + + # Debian / Raspberry Pi OS record the zone name here. + try: + with open("/etc/timezone", "r", encoding="utf-8") as handle: + name = handle.read().strip() + if name: + return name + except OSError: + pass + + # Otherwise /etc/localtime is a symlink into the zoneinfo tree. + try: + path = os.path.realpath("/etc/localtime") + marker = "zoneinfo" + os.sep + if marker in path: + return path.split(marker, 1)[1] + except OSError: + pass + + return None + + +def _validated(name: object, source: str, log: logging.Logger) -> Optional[str]: + """Return a usable IANA name, or None if blank/absent/not a real zone.""" + if not isinstance(name, str): + return None + name = name.strip() + if not name: + return None + try: + pytz.timezone(name) + except pytz.UnknownTimeZoneError: + log.warning("Ignoring invalid timezone %r from %s", name, source) + return None + except Exception: + # Not an unknown-zone error, so something else went wrong inside pytz. + # Log it loudly rather than silently reclassifying it as "invalid" -- + # but still don't propagate: this runs in the render path, and a + # mislabelled zone beats taking the whole display down. + log.warning( + "Unexpected error validating timezone %r from %s; ignoring it", + name, source, exc_info=True, + ) + return None + return name + + +def resolve_timezone_name( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, + *, + plugin_label: str, + writeback_fixed_in: Optional[str] = None, +) -> str: + """Return the IANA timezone name to render game times in. + + Never raises and never returns an empty string; falls back to ``"UTC"`` + only when every source is missing or invalid. + + ``plugin_label`` names the plugin in the warning logged when nothing + resolves (e.g. ``"hockey scoreboard"``). ``writeback_fixed_in`` is the + plugin release that stopped writing ``"UTC"`` back into the saved config, + for a plugin that ever did; ``None`` (the default) otherwise. + """ + log = log or logger + + def downstream(): + """Yield (source, name) for everything except the plugin's own config. + + Lazy: ``SportsCore._get_timezone()`` runs this once per game and the + answer is almost always already in the plugin config, so evaluating on + demand keeps the common case from calling into both config managers and + stat-ing the host timezone files every time. + """ + yield ( + "plugin_manager.config_manager", + _from_config_manager(getattr(plugin_manager, "config_manager", None), log), + ) + yield ( + "cache_manager.config_manager", + _from_config_manager(getattr(cache_manager, "config_manager", None), log), + ) + yield "system timezone", system_timezone_name() + + def first_valid( + sources: Iterable[Tuple[str, Any]], + ) -> Tuple[Optional[str], Optional[str]]: + for source, name in sources: + name = _validated(name, source, log) + if name: + return source, name + return None, None + + plugin_value = _validated((config or {}).get("timezone"), "plugin config", log) + + if writeback_fixed_in is not None and plugin_value and plugin_value.lower() == "utc": + # Before writeback_fixed_in this plugin wrote "timezone": "UTC" into + # the saved config whenever it failed to resolve a global timezone, and + # that write-back persisted. A bare "UTC" is therefore far more likely + # to be that artifact than a deliberate choice -- it only ever appeared + # on failure. Honor it only when nothing downstream disagrees; a user + # who genuinely wants UTC writes the unambiguous "Etc/UTC", which the + # bug never produced and which falls through to the normal path below. + source, downstream_name = first_valid(downstream()) + if downstream_name and downstream_name.lower() not in ("utc", "etc/utc"): + log.warning( + "Ignoring the plugin-level timezone 'UTC': it is almost " + "certainly left over from the write-back bug fixed in %s, and " + "%s says %s. Using %s. If you really do want UTC here, set " + "this plugin's timezone to 'Etc/UTC' instead.", + writeback_fixed_in, source, downstream_name, downstream_name, + ) + return downstream_name + log.debug("Plugin-level timezone 'UTC' agrees with %s; using UTC", source or "no other source") + return "UTC" + + if plugin_value: + log.debug("Resolved timezone %s from plugin config", plugin_value) + return plugin_value + + source, name = first_valid(downstream()) + if name: + log.debug("Resolved timezone %s from %s", name, source) + return name + + log.warning( + "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 " + "in the %s's Advanced Settings to override.", + plugin_label, + ) + return "UTC" + + +def resolve_timezone( + config: Optional[Dict[str, Any]] = None, + plugin_manager: Any = None, + cache_manager: Any = None, + log: Optional[logging.Logger] = None, + *, + plugin_label: str, + writeback_fixed_in: Optional[str] = None, +): + """``resolve_timezone_name`` as a ready-to-use tzinfo object.""" + return pytz.timezone( + resolve_timezone_name( + config=config, + plugin_manager=plugin_manager, + cache_manager=cache_manager, + log=log, + plugin_label=plugin_label, + writeback_fixed_in=writeback_fixed_in, + ) + ) diff --git a/test/test_favorite_team_check.py b/test/test_favorite_team_check.py new file mode 100644 index 00000000..2b878012 --- /dev/null +++ b/test/test_favorite_team_check.py @@ -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 +``_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)}]}], + })) diff --git a/test/test_sports_timezone.py b/test/test_sports_timezone.py new file mode 100644 index 00000000..6923dd76 --- /dev/null +++ b/test/test_sports_timezone.py @@ -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 ``_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)