mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
* fix(fonts): resolve asset paths against the install root, not the cwd
FontManager built its catalog from cwd-relative paths ('assets/fonts'),
so any process started outside the install root — the plugin safety
harness on CI being the recurring case — found no fonts and silently
degraded every plugin to PIL's default face. Several plugins grew
per-plugin workarounds for exactly this (countdown, text-display,
tide-display in the plugins monorepo).
Catalog population now falls back to the install root derived from this
module's location when the cwd-relative path is missing; behavior when
running from the install root is unchanged. Verified: resolve_font
returns the real FreeType face from a foreign cwd, and the full unit
suites (266 tests) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: seed CHANGELOG.md with the module-availability release discipline
The plugins monorepo's sunset rule ('delete a bundled fallback copy only
when the manifest floors on the first core release shipping the module')
needs core module additions recorded against version numbers. Seeds the
changelog at 3.1.0 and documents the discipline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: enroll the core unit suites in a dedicated job
The existing workflow ran only the three plugin-harness suites; the
skin-system, font-manager, data-source, extractor, scroll-helper,
adaptive-layout, and loader-compat suites (266 tests) existed but never
ran in CI, so a refactor of src/base_classes or src/common could regress
them silently. Also enrolls the new sports characterization and
element-style suites landing in this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat: ship src/element_style — the per-element style resolver plugins already expect
Three plugins (of-the-day, ledmatrix-music, football-scoreboard) import
src.element_style behind guarded try/except with classic fallbacks, but
the module never existed in core, so the richer per-element styling UI
those code paths implement has been dormant. This lands it:
- ElementStyleResolver.style() resolves per-element font/size/color with
the key semantic the consumers encode: a config value counts as
user-forced only when it differs from the schema default (the web UI
bakes defaults into config.json on save), and untouched configs
resolve to exactly the caller's classic values — byte-identical
rendering, proven by of-the-day's committed goldens passing unchanged.
- defaults_from_schema_file parses both declaration forms (the compact
x-style-elements map and hand-written customization blocks).
- expand_style_elements() expands x-style-elements into full config
blocks; schema_manager.load_schema() applies it (guarded, no-op for
schemas without the declaration) so the config form and defaults
merging see the expanded UI.
- Fonts resolve cwd-independently with (path, size) caching; .bdf loads
via freetype like FontManager; nothing in the module raises out of
style().
Verified: 31 new unit tests; of-the-day's previously-skipped 9-test
spec suite now runs and passes; football's resolver tests pass (27);
music's 38 plugin tests pass; schema-manager suites pass (43).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* test: characterization suite for src/base_classes/sports.py ahead of unification
Pins current behavior before the planned merge of the nine drifted
plugin copies back into this ancestor: the _extract_game_details_common
key contract per sport (reusing GUARANTEED_KEYS from the skin tests),
update() flows for upcoming/recent/live against cache-seeded fixtures
under frozen time, rendering smoke per mode class, and guard rails on
the skin-system seam.
Five surprising behaviors are pinned AS-IS and flagged in comments so
the merge changes them knowingly or not at all: is_upcoming also
matching status.type.name; hockey dropping events whose competitors
lack 'statistics'; baseball reading the event-level status for innings;
no past-date filter in upcoming; and favorites-only mode with an empty
favorites list showing nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* ci: restrict the test workflow's GITHUB_TOKEN to contents:read
CodeQL flagged the new unit-tests job for running with the default
unrestricted token; the pre-existing job had the same exposure. Both
jobs only check out the repo and run pytest, so a workflow-level
contents:read is sufficient.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* refactor(sports): convert sports.py into a package (pure move)
Phase B1a of docs/SPORTS_UNIFICATION.md. src/base_classes/sports.py
becomes a package so the upcoming capability modules have a home and
diffs show their blast radius:
sports/__init__.py re-exports the public API
sports/core.py SportsCore
sports/modes.py SportsUpcoming / SportsRecent / SportsLive
No logic change: the 1515 class-body lines are byte-identical to the
original (verified by concatenating the two modules and diffing against
HEAD). Only module docstrings and the redistributed import blocks are
new. MRO and __abstractmethods__ are unchanged, and every existing
import site — including 'from src.base_classes.sports import SportsCore'
in the sport subclasses, the skin tests, and the characterization
suite — resolves through the package __init__.
One test edit was required: the characterization suite monkeypatched
'src.base_classes.sports.get_background_service', which is no longer a
module attribute on a package. Retargeted to
'src.base_classes.sports.core.get_background_service' — the module whose
globals SportsCore.__init__ actually resolves, so the patch is effective
exactly as before. No test logic or assertion changed.
Also adds docs/SPORTS_UNIFICATION.md: the architecture for the whole
B1-B5 sequence — how upgradability (guarded imports, capability probing,
frozen view-model keys, the sunset rule), reusability (promote only what
all nine copies share), and modularity (capabilities as opt-in mixins
rather than config branches, variants as named strategies, sport-unique
code as declared override points) are kept as three separate mechanisms.
Verified: characterization + skin 94 passed; the 10-file unit suite 338
passed; test/plugins 60 passed — all identical to pre-change counts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): promote the nine universal methods into the base classes
Phase B1b of docs/SPORTS_UNIFICATION.md. Every method here is present in
all nine bundled plugin sports.py copies and absent from core, so this is
reuse of code the fleet already agreed on — not new behavior. The
promotions are inert until B5: the plugins' own overrides still run.
SportsCore: cleanup, _get_layout_offset, _load_custom_font_from_element_config
SportsUpcoming: _select_games_for_display
SportsRecent: _get_zero_clock_duration, _clear_zero_clock_tracking,
_select_recent_games_for_display
SportsLive: _is_game_really_over, _detect_stale_games
Where the copies disagreed, the canonical form was chosen on evidence and
the genuine per-sport differences became seams rather than branches:
- _favorite_key(game, side) -- NRL matches favorites on team id because its
abbreviations are ambiguous (NEW is both Newcastle Knights and New
Zealand Warriors). Default is the abbreviation; NRL overrides. Core never
learns the string nrl.
- FINAL_PERIOD / CLOCK_COUNTS_DOWN -- hockey ends in P3, and soccer/afl/nrl
clocks count UP, so 0:00 means kickoff, not expiry.
- _config_schema_path() / _font_root() -- plugin-supplied locations, never
derived from this module's __file__.
BEHAVIOR CHANGE (baseball, ufc): the rejected variant coerced a missing or
non-str clock to the literal 0:00 and then declared the game over at
period >= 4. MLB has no game clock and period is the inning, so live games
were being evicted from the 5th inning onward; UFC likewise. The promoted
variant skips the clock check when the clock is unusable -- it fails safe
(keeps showing the game) instead of failing destructive.
Also fixes a regression from the package move in e591cec: the bodies were
byte-identical but __file__ gained a directory, so _resolve_project_path's
parents[2] silently began resolving to <root>/src instead of the repo root.
Both it and _font_root now derive from a single _INSTALL_ROOT constant, so
a future move needs one line changed rather than two hand-counted depths.
Tests assert the resolved values, not the index.
The font loader takes baseball's body (BDF memo cache + native-strike
retry) under hockey's Optional signature -- the older lineage is the
correct one here, and basketball's positional str default breaks on an
explicit None. It resolves through _font_root rather than the cwd, so it
does not reintroduce the bug just fixed for FontManager, and delegates to
FontManager for the alias table and BDF header parse instead of shipping
second copies. cleanup gained the two new font caches and still leaves
background_service alone -- it is a process-wide singleton.
Verified: 111 new tests (48 core + 59 modes + 4 install-root regression);
characterization + skin suites still exactly 94, unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): stop dropping hockey and baseball events on optional feed keys
Both bugs were pinned AS-IS by the B0 characterization suite so this
phase could change them knowingly. Both fixes are adoptions of code the
corresponding plugins already ship, not new inventions.
Hockey: the extractor read competitor["statistics"] unguarded, so a
competitor arriving without that array raised KeyError inside the
generator and the WHOLE event was discarded -- valid scores and status
included. Shot/save counts now default to 0, which is already what the
suite expects for an empty statistics array.
Baseball: for live games the extractor read game_event["status"], the
event TOP-LEVEL status, to get the inning. Real ESPN events duplicate
status there, but MiLB events (synthesized from the MLB Stats API into
an ESPN-like shape) populate only the competition-level one, so the
lookup raised a bare KeyError and dropped the event. It now reads the
competition-level status that _extract_game_details_common has already
validated, so it cannot be missing at that point.
The two characterization tests that pinned the old behaviour are
rewritten to assert the fix rather than deleted, so the suite still
documents the edge case -- and still totals 94.
CHANGELOG records these plus the live-clock change from aaabc61 under
Changed/Fixed, since all three are user-visible. The two new promotion
suites join the CI unit job (449 tests).
Verified: unit job 449 passed, plugin-safety job 60 passed, and the
hockey (16) and baseball (24) plugin harnesses render clean at every
panel size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the live game-over check and font/log init
Follow-up review findings on the promoted base-class methods.
_is_game_really_over:
- `period` present-but-None raised TypeError on `None >= FINAL_PERIOD`,
taking down the whole live-update pass (_detect_stale_games has no
try/except). Same failure shape as the null `period_text` already fixed.
- An expired clock spelled "00:00" normalizes to "0000", which matched
none of the hand-listed literals, so a finished game with a two-digit
minute clock stayed on the scoreboard forever. Compare numerically.
SportsCore:
- _load_fonts kept the cwd-relative "assets/fonts/..." literals the
_font_root() seam exists to remove, so every scoreboard font degraded
to PIL's default face outside the install root.
- _should_log read self._last_warning_time unguarded while only an
unrelated method initialized it lazily; the first warning of a run
raised AttributeError. Initialize it in __init__.
Also documents that game_update_timestamps is written by subclasses, not
by the base class, so the staleness branch is inert until B5 adoption.
14 new tests. Gates: 463 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(sports): opt-in celebration and rotation capabilities
Phase B2 of the sports unification. Both features exist in only some of
the nine scoreboards, so they ship as capabilities the plugin composes,
never as `if self.<feature>_enabled` branches inside the base classes: a
sport that does not opt in has none of this code in its MRO.
CelebrationMixin (afl, nrl, soccer, football)
The two lineages spelled this differently -- _check_for_goal /
celebrate_opponent_goals vs _check_for_score / celebrate_opponent_scores
-- but the bodies were identical apart from three things, each now a
seam rather than a branch:
- wording -> score_phrase() / win_phrase() hooks
- follow-up suppression -> COALESCE_SCORING_SEQUENCE, on for football
where a touchdown lands as +6 then +1, off where two increments are
two real goals
- team identity -> _favorite_key, so nrl matches on team id without
core learning why its abbreviations are ambiguous
Both config spellings are read, so a plugin adopting the mixin keeps
working with the keys already in its published schema.
Rotation strategies
The three "dialects" turned out to be one algorithm (SWRR) in two
shapes: an incremental picker holding state across calls, and a
precomputed per-cycle list. They agree within a cycle and differ only at
the boundary, so core ships both behind a name registry rather than
declaring a winner. weight_for is supplied by the host, so rotation.py
never learns what a favorite is; an unknown name degrades to "simple"
because it arrives from user config.
Each strategy is checked against a verbatim transcription of the plugin
code it replaces, over every live-game shape up to four games -- the
differential B5 will delete the bundled copies on the strength of.
185 new tests. Gates: 648 core unit, 60 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* feat(scroll): upstream the scroll orchestration layer; release 3.2.0
Phases B3 and B4.
B3 -- src/common/sports_scroll.py is deliberately NOT a superset of the
ten plugin scroll_display.py copies. A method-level comparison of the
eight that share a shape (f1 and ufc are genuine forks) found a sharp
split, and the module is drawn along it:
promoted orchestration -- get_all_vegas_content_items is identical
in all eight; clear_all, get_scroll_info,
get_dynamic_duration, is_complete and display_frame are
96-100% similar
promoted settings -- one algorithm; the copies differ only in which
league keys they walk, so the ladder is data
(SCROLL_LEAGUE_KEYS) rather than a body per sport
NOT content -- prepare_scroll_content has 8 distinct bodies
across 8 plugins (145 lines, 53% similar at worst) and
_load_separator_icons 7 (6% at worst)
Same name, different job: prepare_scroll_content draws *this sport's*
game card. Merging those eight bodies would be exactly the mistake the
promotion rule exists to prevent, so the base raises NotImplementedError
rather than rendering something plausible -- a base that rendered
something would let a plugin ship a silently blank scroll.
The one behavior added over the plugin copies is native
global_config['target_fps'] support. The bundled copies hardcode ~100
FPS via scroll_delay and never consult the global target; Part A
threaded it through each copy by hand, and this makes that threading
legacy compatibility rather than the mechanism.
66 tests, including three against the real ScrollHelper rather than a
double -- a suite built entirely on MagicMock would sail straight past a
rename in the helper.
B4 -- bump src/__init__.py to 3.2.0 and close the CHANGELOG's Unreleased
section against it. This is the number the sunset rule keys on: the
first core release shipping the unified sports library, and therefore
the floor a plugin sets ledmatrix_min_version to before deleting its
bundled copies. The version bump and the changelog release heading move
together on purpose -- separating them would leave a commit whose
changelog announces 3.2.0 while the code still reports 3.1.0.
Nothing here changes what an existing plugin loads; adoption is B5.
Gates: 714 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): per-type warning cooldowns and font-load logging
Follow-ups from the second review pass, both on already-fixed findings:
_should_log accepted a warning_type and ignored it, sharing one
timestamp across every kind of warning -- so an API-error warning
silenced an unrelated cache warning for the next minute, and whichever
fired first won. Cooldowns are now keyed by type. Nothing in core calls
this method, so no behavior regressed; _last_warning_time is kept in
step for subclasses that read it directly.
_load_fonts logged through the module-level logger, dropping the manager
context, and had no return type hint. It now uses self.logger (set well
before _load_fonts runs) and names the directory it searched -- the bare
"Fonts not found" sent people hunting for a font-format problem when the
actual cause is an install missing assets/fonts.
Gates: 717 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* docs: record the validated hockey scroll-display pilot for B5
B5 cannot ship until this PR merges and 3.2.0 exists -- a plugin cannot
floor ledmatrix_min_version at a release that does not exist, and an
unguarded src.common.sports_scroll import would break every user on
3.1.0.
The pilot has been validated ahead of that gate: hockey's
scroll_display.py adopted against a core carrying 3.2.0 goes from 691 to
289 lines with all 16 harness renders byte-for-byte identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(sports): harden the B2/B3 capabilities against bad config and subclasses
Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.
celebrations:
- celebration_duration is coerced and floored at init. It is compared
numerically in display() *outside* any try block, so a string from a
hand-edited config propagated a TypeError straight out; zero or
negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
retried the same broken render on every frame for the rest of the
window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
entries, so a game that left the live list any other way leaked its
baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
window comparison, and log lines carry a [Celebrations] prefix.
rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
every game, so an unbounded weight from a misread config spins the
display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
next_game is previewed with its own ordering -- which is what the
method promises.
sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
when the key is absent; present-but-null reached the multiplication
inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
They ran outside it, so a raise there reached the plugin's frame loop
despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
manager said None while the display said "".
Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.
Gates: 747 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
* fix(baseball): don't drop favourite MiLB games on the diagnostic path
The competition-level status fallback fixed the inning lookup, but the
favourite-team debug block a few lines above still read the event top-level
game_event["status"]. MiLB events (synthesized from the MLB Stats API into an
ESPN-like shape) populate only the competition-level status, so the identical
event that extracted fine for a non-favourite raised KeyError and returned
None once the team was a favourite.
Worst possible shape for the bug: it only hit the games the user cared most
about, and only on the path meant to help diagnose them. The existing
regression test missed it because it never passes favourites, so
is_favorite_game was False and the block never ran.
Uses the validated competition-level `status`, which
_extract_game_details_common guarantees is present by that point. Adds a
favourites-passing companion test; confirmed it reproduces the KeyError
without the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* docs(sports): type-hint game_update_timestamps to match its sibling
Addresses the last remaining sub-point on the modes.py review thread. The
design finding itself is already handled: the base class documents that it
only reads game_update_timestamps and that a subclass's update() owns writing
"last_seen" (and afl/etc. do, so stale-game eviction works in practice). The
one concrete gap was the missing annotation -- _zero_clock_timestamps is typed
Dict[str, float] while this nested map had none. Now Dict[str, Dict[str, float]].
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* Address CodeRabbit review: font-name traversal + offline test guard
Two Minor findings from CodeRabbit's first review of this PR.
- resolve_font_path: reject relative font names carrying path components.
font_name comes from plugin config, which the web UI writes; a value like
"../../config/config.json" escaped assets/fonts/ after os.path.join and let
a config probe arbitrary paths for existence (disclosure unlikely, since
Pillow/freetype reject non-font files, but the probe is real). Relative
names must now be bare filenames (os.path.basename(name) == name); absolute
paths keep their existing isfile() gate. Test confirms the traversal
resolved the real config.json before the guard.
- build_manager fixture: patch requests.Session.get BEFORE constructing the
manager. Construction creates both SportsCore.session and the
ESPNDataSource.session; the old code only replaced manager.session after
the fact, leaving data_source.session real and able to reach the network on
an accidental fetch. Patching the class makes every session built in the
fixture offline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* test(celebrations): make the expiry tests actually test expiry
CodeRabbit (Major) on the merge re-review: celebration_duration is clamped to
a 1.0s floor, so the two expiry tests that configured 0 and expected instant
expiration never actually hit the expiry branch. They passed only because
_draw_celebration_layout raises in the harness (no real fonts) and its
exception branch clears the celebration the same way -- so they were really
re-testing the render-failure path, not expiry.
Now use a valid 1s duration, backdate started_at past the window, and mock
_draw_celebration_layout with assert_not_called() so an expired celebration
provably does NOT render. Verified discriminating: both fail if
has_active_celebration is forced to never expire.
Production code unchanged -- the expiry logic was already correct; only the
tests were mismodelling it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
614 lines
26 KiB
Python
614 lines
26 KiB
Python
"""Tests for the methods promoted onto SportsCore from the nine bundled
|
|
plugin copies of ``sports.py`` (phase B1 of docs/SPORTS_UNIFICATION.md).
|
|
|
|
Three methods and their seams land here:
|
|
|
|
- ``cleanup()`` — byte-identical in all nine copies. The tests pin the
|
|
ordering (session close, then caches, then the completion log) and the
|
|
deliberate *omission*: the process-wide background service must never be
|
|
shut down by one unloading plugin.
|
|
- ``_get_layout_offset()`` — football's resolver-backed variant, with the
|
|
classic inline config read as the fallback used by every plugin that
|
|
doesn't hand core a ``_config_schema_path()``.
|
|
- ``_load_custom_font_from_element_config()`` — baseball's body (the only
|
|
copy that handles BDF strikes correctly) under hockey's wider signature,
|
|
resolving font files through the ``_font_root()`` seam instead of the
|
|
process cwd.
|
|
"""
|
|
|
|
import ast
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from PIL import Image, ImageFont
|
|
|
|
# src.base_classes.sports transitively imports the hardware matrix driver;
|
|
# stub it so these tests can import the sports base classes off-device.
|
|
sys.modules.setdefault("rgbmatrix", MagicMock())
|
|
|
|
from src.base_classes.sports import SportsCore
|
|
|
|
LOGGER = logging.getLogger("test_sports_core_promotions")
|
|
|
|
CORE_ROOT = Path(__file__).resolve().parents[1]
|
|
FONTS_DIR = CORE_ROOT / "assets" / "fonts"
|
|
TTF_NAME = "PressStart2P-Regular.ttf"
|
|
BDF_NAME = "5x7.bdf" # a BDF whose only valid strike is 7px
|
|
BDF_NATIVE_SIZE = 7
|
|
|
|
|
|
class _StubSports(SportsCore):
|
|
"""Minimal concrete SportsCore — the abstract methods are never called
|
|
by anything under test here."""
|
|
|
|
def _fetch_data(self):
|
|
return None
|
|
|
|
def _extract_game_details(self, game_event):
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def build(monkeypatch, tmp_path):
|
|
"""Factory for real SportsCore instances: logo dir redirected to tmp and
|
|
the process-wide background service replaced with a MagicMock so the
|
|
tests can assert nothing ever calls it."""
|
|
monkeypatch.setattr(
|
|
SportsCore, "_initialize_logo_dir", lambda self, configured: tmp_path)
|
|
monkeypatch.setattr(
|
|
"src.base_classes.sports.core.get_background_service",
|
|
lambda *args, **kwargs: MagicMock())
|
|
|
|
def _build(config=None, cls=_StubSports):
|
|
display_manager = MagicMock()
|
|
display_manager.matrix.width = 128
|
|
display_manager.matrix.height = 32
|
|
display_manager.width = 128
|
|
display_manager.height = 32
|
|
display_manager.image = Image.new("RGB", (128, 32))
|
|
cache_manager = MagicMock()
|
|
cache_manager.cache_dir = str(tmp_path)
|
|
return cls(config if config is not None else {"timezone": "UTC"},
|
|
display_manager, cache_manager, LOGGER, "nhl")
|
|
|
|
return _build
|
|
|
|
|
|
def probe(config=None):
|
|
"""Unbound-call stand-in for hosts we don't need a full instance for
|
|
(same pattern as make_probe in test_sports_base_characterization)."""
|
|
host = MagicMock()
|
|
host.logger = LOGGER
|
|
host.config = config if config is not None else {}
|
|
host._font_cache = {}
|
|
host._bdf_native_size_cache = {}
|
|
host._config_schema_path.return_value = None
|
|
host._font_root.side_effect = lambda: SportsCore._font_root(host)
|
|
host._resolve_font_path.side_effect = (
|
|
lambda name: SportsCore._resolve_font_path(host, name))
|
|
return host
|
|
|
|
|
|
def offset(host, element, axis, default=0):
|
|
return SportsCore._get_layout_offset(host, element, axis, default)
|
|
|
|
|
|
def load_font(host, *args, **kwargs):
|
|
return SportsCore._load_custom_font_from_element_config(host, *args, **kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. cleanup()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCleanup:
|
|
def test_closes_session_and_clears_all_caches(self, build):
|
|
manager = build()
|
|
session = MagicMock()
|
|
manager.session = session
|
|
manager._logo_cache["TB"] = object()
|
|
manager._font_cache[("PressStart2P-Regular.ttf", 8)] = object()
|
|
manager._bdf_native_size_cache["assets/fonts/5x7.bdf"] = 7
|
|
|
|
manager.cleanup()
|
|
|
|
session.close.assert_called_once_with()
|
|
assert manager._logo_cache == {}
|
|
# Promoted alongside the font loader: these hold PIL faces and are
|
|
# an unbounded leak across enable/disable cycles if never released.
|
|
assert manager._font_cache == {}
|
|
assert manager._bdf_native_size_cache == {}
|
|
|
|
def test_second_cleanup_is_a_noop(self, build):
|
|
manager = build()
|
|
manager.session = MagicMock()
|
|
manager._logo_cache["TB"] = object()
|
|
manager._font_cache[("x", 8)] = object()
|
|
|
|
manager.cleanup()
|
|
manager.cleanup() # must not raise on already-released state
|
|
|
|
assert manager._logo_cache == {}
|
|
assert manager._font_cache == {}
|
|
assert manager._bdf_native_size_cache == {}
|
|
assert manager.session.close.call_count == 2
|
|
|
|
def test_does_not_shut_down_the_shared_background_service(self, build):
|
|
# get_background_service() hands out a PROCESS-WIDE singleton shared
|
|
# by every scoreboard. One plugin unloading must not stop background
|
|
# fetching for the other eight — cleanup() touches it not at all.
|
|
manager = build()
|
|
service = manager.background_service
|
|
manager.session = MagicMock()
|
|
|
|
manager.cleanup()
|
|
|
|
assert service.shutdown.called is False
|
|
assert service.stop.called is False
|
|
assert service.method_calls == [], (
|
|
"cleanup() called into the shared background service: "
|
|
f"{service.method_calls}")
|
|
|
|
def test_completion_is_logged_even_when_session_close_raises(self, build, caplog):
|
|
manager = build()
|
|
manager.session = MagicMock()
|
|
manager.session.close.side_effect = RuntimeError("socket already gone")
|
|
manager._logo_cache["TB"] = object()
|
|
|
|
with caplog.at_level(logging.DEBUG, logger=LOGGER.name):
|
|
manager.cleanup()
|
|
|
|
messages = [r.message for r in caplog.records]
|
|
assert any("Error closing session" in m for m in messages)
|
|
# Ordering is load-bearing: the caches still get cleared and the
|
|
# completion log still fires after a failed close.
|
|
assert manager._logo_cache == {}
|
|
assert any("cleanup completed" in m for m in messages)
|
|
|
|
def test_tolerates_missing_attributes(self):
|
|
# The hasattr guards exist so a partially constructed instance (an
|
|
# __init__ that raised) can still be cleaned up.
|
|
host = MagicMock(spec=["logger"])
|
|
host.logger = LOGGER
|
|
SportsCore.cleanup(host)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. _get_layout_offset() + the _config_schema_path() seam
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def layout_config(element, axis, value):
|
|
return {"customization": {"layout": {element: {axis: value}}}}
|
|
|
|
|
|
class TestLayoutOffsetClassicPath:
|
|
"""The default path: _config_schema_path() returns None, so offsets come
|
|
from the inline customization.layout read every plugin ships today."""
|
|
|
|
def test_config_schema_path_defaults_to_none(self, build):
|
|
manager = build()
|
|
assert manager._config_schema_path() is None
|
|
|
|
def test_reads_configured_int(self):
|
|
host = probe(layout_config("home_logo", "x_offset", 5))
|
|
assert offset(host, "home_logo", "x_offset") == 5
|
|
|
|
def test_float_is_truncated_to_int(self):
|
|
host = probe(layout_config("score", "y_offset", 2.9))
|
|
result = offset(host, "score", "y_offset")
|
|
assert result == 2 and isinstance(result, int)
|
|
|
|
def test_numeric_string_is_coerced(self):
|
|
host = probe(layout_config("score", "x_offset", "-3.5"))
|
|
assert offset(host, "score", "x_offset") == -3
|
|
|
|
def test_unconfigured_element_and_axis_use_default(self):
|
|
host = probe(layout_config("score", "x_offset", 5))
|
|
assert offset(host, "status_text", "x_offset", 7) == 7
|
|
assert offset(host, "score", "y_offset", -1) == -1
|
|
assert offset(probe(), "score", "x_offset", 4) == 4
|
|
|
|
def test_non_numeric_string_degrades_to_default(self):
|
|
host = probe(layout_config("score", "x_offset", "left"))
|
|
assert offset(host, "score", "x_offset", 3) == 3
|
|
|
|
def test_unsupported_type_degrades_to_default(self):
|
|
host = probe(layout_config("score", "x_offset", {"nested": 1}))
|
|
assert offset(host, "score", "x_offset", 2) == 2
|
|
host = probe(layout_config("score", "x_offset", None))
|
|
assert offset(host, "score", "x_offset", 2) == 2
|
|
|
|
def test_broken_config_object_degrades_to_default(self):
|
|
host = probe()
|
|
host.config = "not a dict"
|
|
assert offset(host, "score", "x_offset", 6) == 6
|
|
|
|
def test_boolean_counts_as_one(self):
|
|
# PINNED AS-IS: the classic read predates the resolver and treats a
|
|
# bool as its int value (True -> 1). See the resolver test below for
|
|
# the stricter, more correct handling.
|
|
host = probe(layout_config("score", "x_offset", True))
|
|
assert offset(host, "score", "x_offset", 4) == 1
|
|
|
|
|
|
class TestLayoutOffsetResolverPath:
|
|
"""When a plugin supplies its config_schema.json, offsets resolve through
|
|
src.element_style instead."""
|
|
|
|
@pytest.fixture
|
|
def schema_path(self, tmp_path):
|
|
path = tmp_path / "config_schema.json"
|
|
path.write_text(json.dumps({
|
|
"type": "object",
|
|
"properties": {
|
|
"customization": {
|
|
"type": "object",
|
|
"properties": {
|
|
"layout": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
return str(path)
|
|
|
|
def host(self, schema_path, config):
|
|
host = probe(config)
|
|
host._config_schema_path.return_value = schema_path
|
|
del host._style_resolver_cached # MagicMock auto-attrs otherwise
|
|
host._style_resolver_cached = None
|
|
return host
|
|
|
|
def test_reads_configured_offsets(self, schema_path):
|
|
host = self.host(schema_path, layout_config("home_logo", "x_offset", 5))
|
|
assert offset(host, "home_logo", "x_offset") == 5
|
|
|
|
def test_numeric_string_is_coerced(self, schema_path):
|
|
host = self.host(schema_path, layout_config("score", "x_offset", "-3.5"))
|
|
assert offset(host, "score", "x_offset") == -3
|
|
|
|
def test_missing_value_uses_default(self, schema_path):
|
|
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
|
assert offset(host, "score", "y_offset", 9) == 9
|
|
|
|
def test_bad_input_degrades_to_default(self, schema_path):
|
|
host = self.host(schema_path, layout_config("score", "x_offset", "left"))
|
|
assert offset(host, "score", "x_offset", 3) == 3
|
|
host = self.host(schema_path, {"customization": {"layout": "nope"}})
|
|
assert offset(host, "score", "x_offset", 3) == 3
|
|
|
|
def test_boolean_is_rejected_unlike_the_classic_path(self, schema_path):
|
|
# The intended behavior difference: a bool is not a pixel offset, so
|
|
# the resolver returns the default where the classic read returns 1.
|
|
host = self.host(schema_path, layout_config("score", "x_offset", True))
|
|
assert offset(host, "score", "x_offset", 4) == 4
|
|
|
|
def test_resolver_is_cached_and_rebuilt_when_config_is_swapped(self, schema_path):
|
|
host = self.host(schema_path, layout_config("score", "x_offset", 5))
|
|
assert offset(host, "score", "x_offset") == 5
|
|
first = host._style_resolver_cached
|
|
assert offset(host, "score", "x_offset") == 5
|
|
assert host._style_resolver_cached is first
|
|
|
|
# on_config_change swaps the dict object; the resolver must follow.
|
|
host.config = layout_config("score", "x_offset", 11)
|
|
assert offset(host, "score", "x_offset") == 11
|
|
assert host._style_resolver_cached is not first
|
|
|
|
def test_missing_schema_file_still_resolves_offsets(self, tmp_path):
|
|
# Offsets don't depend on schema defaults, so an unreadable schema
|
|
# must not cost the plugin its layout customization.
|
|
host = self.host(str(tmp_path / "absent.json"),
|
|
layout_config("score", "x_offset", 5))
|
|
assert offset(host, "score", "x_offset") == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. _load_custom_font_from_element_config() + the _font_root() seam
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFontRootSeam:
|
|
def test_default_font_root_is_the_core_install_root(self, build):
|
|
manager = build()
|
|
assert Path(manager._font_root()) == CORE_ROOT
|
|
assert (Path(manager._font_root()) / "assets" / "fonts").is_dir()
|
|
|
|
def test_resolve_font_path_honors_an_overridden_root(self, tmp_path):
|
|
fonts = tmp_path / "assets" / "fonts"
|
|
fonts.mkdir(parents=True)
|
|
(fonts / "Bundled.ttf").write_bytes(b"not really a font")
|
|
host = probe()
|
|
host._font_root.side_effect = lambda: str(tmp_path)
|
|
assert SportsCore._resolve_font_path(host, "Bundled.ttf") == str(
|
|
fonts / "Bundled.ttf")
|
|
|
|
def test_unknown_font_returns_the_familiar_relative_path(self):
|
|
host = probe()
|
|
assert SportsCore._resolve_font_path(host, "Nope.ttf") == os.path.join(
|
|
"assets", "fonts", "Nope.ttf")
|
|
|
|
|
|
class TestFontLoaderSignature:
|
|
"""Hockey's signature is the only safe superset: basketball's positional
|
|
``default_font: str`` blows up on an explicit None."""
|
|
|
|
def test_two_arg_call(self):
|
|
font = load_font(probe(), {"font": TTF_NAME, "font_size": 10})
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert font.size == 10
|
|
|
|
def test_default_size_is_used_when_config_omits_it(self):
|
|
assert load_font(probe(), {}, 12).size == 12
|
|
|
|
def test_three_positional_args(self):
|
|
font = load_font(probe(), {}, 6, "4x6-font.ttf")
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert font.size == 6
|
|
assert font.path.endswith("4x6-font.ttf")
|
|
|
|
def test_explicit_default_font_none(self):
|
|
# The regression this signature guards: os.path.join(..., None).
|
|
font = load_font(probe(), {"font_size": 9}, default_font=None)
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert font.path.endswith(TTF_NAME)
|
|
|
|
def test_config_font_wins_over_default_font(self):
|
|
font = load_font(probe(), {"font": TTF_NAME}, 8, "4x6-font.ttf")
|
|
assert font.path.endswith(TTF_NAME)
|
|
|
|
def test_string_font_size_is_coerced(self):
|
|
assert load_font(probe(), {"font": TTF_NAME, "font_size": "11"}).size == 11
|
|
|
|
|
|
class TestFontLoaderBehavior:
|
|
def test_family_alias_resolves_through_the_font_manager_catalog(self):
|
|
# "press_start" is a FontManager catalog family, not a filename; the
|
|
# promoted loader must not carry its own duplicate alias table.
|
|
host = probe()
|
|
font = load_font(host, {"font": "press_start", "font_size": 8})
|
|
assert font.path.endswith(TTF_NAME)
|
|
assert ("PressStart2P-Regular.ttf", 8) in host._font_cache
|
|
|
|
def test_memo_cache_returns_the_same_face(self):
|
|
host = probe()
|
|
first = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
|
second = load_font(host, {"font": TTF_NAME, "font_size": 8})
|
|
assert first is second
|
|
assert len(host._font_cache) == 1
|
|
# A different size is a different face.
|
|
assert load_font(host, {"font": TTF_NAME, "font_size": 9}) is not first
|
|
assert len(host._font_cache) == 2
|
|
|
|
def test_bdf_loads_at_its_native_strike_when_the_request_misses(self):
|
|
# BDF is a fixed-size bitmap format: FreeType raises "invalid pixel
|
|
# size" for anything but the file's own strike. Baseball's retry is
|
|
# the only copy that gets this right.
|
|
host = probe()
|
|
font = load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert font.size == BDF_NATIVE_SIZE
|
|
assert font.path.endswith(BDF_NAME)
|
|
assert set(host._bdf_native_size_cache.values()) == {BDF_NATIVE_SIZE}
|
|
# The retried face is memoized under the REQUESTED size.
|
|
assert host._font_cache[(BDF_NAME, 8)] is font
|
|
|
|
def test_bdf_at_its_native_size_needs_no_retry(self):
|
|
host = probe()
|
|
font = load_font(host, {"font": BDF_NAME, "font_size": BDF_NATIVE_SIZE})
|
|
assert font.size == BDF_NATIVE_SIZE
|
|
assert host._bdf_native_size_cache == {}
|
|
|
|
def test_bdf_strike_lookup_is_memoized(self, monkeypatch):
|
|
calls = []
|
|
real = SportsCore.__module__
|
|
|
|
def counting(path):
|
|
calls.append(path)
|
|
from src.font_manager import FontManager
|
|
return FontManager._read_bdf_native_size(path)
|
|
|
|
monkeypatch.setattr(f"{real}._read_bdf_native_size", counting)
|
|
host = probe()
|
|
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
host._font_cache.clear() # force the load path again
|
|
load_font(host, {"font": BDF_NAME, "font_size": 8})
|
|
assert len(calls) == 1
|
|
|
|
def test_missing_font_falls_back_and_caches_the_fallback(self, caplog):
|
|
host = probe()
|
|
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
|
|
font = load_font(host, {"font": "DoesNotExist.ttf", "font_size": 8})
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert font.path.endswith(TTF_NAME)
|
|
assert any("Font file not found" in r.message for r in caplog.records)
|
|
# Cached under the requested name so a misconfiguration costs one
|
|
# disk probe, not one per frame.
|
|
assert host._font_cache[("DoesNotExist.ttf", 8)] is font
|
|
|
|
def test_unknown_extension_falls_back(self):
|
|
host = probe()
|
|
font = load_font(host, {"font": "AUTHORS", "font_size": 8})
|
|
assert font.path.endswith(TTF_NAME)
|
|
|
|
def test_fallback_honors_the_supplied_default_font(self):
|
|
host = probe()
|
|
font = load_font(host, {"font": "DoesNotExist.ttf"}, 6, "4x6-font.ttf")
|
|
assert font.path.endswith("4x6-font.ttf")
|
|
|
|
|
|
class TestFontLoaderCwdIndependence:
|
|
"""The bug the _font_root() seam exists to prevent: every plugin copy
|
|
joins 'assets/fonts' onto the process cwd, so a process started anywhere
|
|
else silently degrades to PIL's default bitmap face (the same defect
|
|
already fixed in FontManager — see CHANGELOG Unreleased/Fixed)."""
|
|
|
|
@pytest.mark.parametrize("font_name,expected_size",
|
|
[(TTF_NAME, 8), (BDF_NAME, BDF_NATIVE_SIZE)])
|
|
def test_fonts_load_from_an_unrelated_cwd(self, monkeypatch, font_name,
|
|
expected_size):
|
|
monkeypatch.chdir("/")
|
|
host = probe()
|
|
font = load_font(host, {"font": font_name, "font_size": 8})
|
|
assert isinstance(font, ImageFont.FreeTypeFont), (
|
|
f"{font_name} degraded to PIL's default face when the process "
|
|
"runs outside the install root")
|
|
assert font.size == expected_size
|
|
assert Path(font.path) == FONTS_DIR / font_name
|
|
|
|
def test_fallback_font_also_survives_an_unrelated_cwd(self, monkeypatch):
|
|
monkeypatch.chdir("/")
|
|
font = load_font(probe(), {"font": "DoesNotExist.ttf", "font_size": 8})
|
|
assert isinstance(font, ImageFont.FreeTypeFont)
|
|
assert Path(font.path) == FONTS_DIR / TTF_NAME
|
|
|
|
@pytest.mark.parametrize("key", ["score", "time", "team", "status",
|
|
"detail", "rank"])
|
|
def test_load_fonts_survives_an_unrelated_cwd(self, monkeypatch, key):
|
|
"""`_load_fonts` had the same cwd-relative literals the seam exists to
|
|
remove; every scoreboard font silently became PIL's default bitmap face
|
|
when the process started outside the install root."""
|
|
monkeypatch.chdir("/")
|
|
fonts = SportsCore._load_fonts(probe())
|
|
assert isinstance(fonts[key], ImageFont.FreeTypeFont), (
|
|
f"fonts['{key}'] degraded to PIL's default face outside the "
|
|
"install root")
|
|
|
|
|
|
class TestShouldLogCooldown:
|
|
"""`_should_log` reads `self._last_warning_time` unguarded, so it must be
|
|
initialized in __init__ — otherwise the first warning of a run raises
|
|
AttributeError instead of logging."""
|
|
|
|
def test_cooldown_clock_is_initialized(self, build):
|
|
assert build()._last_warning_time == 0
|
|
|
|
def test_first_call_logs_then_cools_down(self, build):
|
|
manager = build()
|
|
assert manager._should_log("api", cooldown=60) is True
|
|
assert manager._should_log("api", cooldown=60) is False
|
|
|
|
def test_cooldown_expires(self, build):
|
|
manager = build()
|
|
assert manager._should_log("api", cooldown=60) is True
|
|
manager._warning_cooldowns["api"] -= 61
|
|
assert manager._should_log("api", cooldown=60) is True
|
|
|
|
def test_cooldowns_are_tracked_per_warning_type(self, build):
|
|
"""The parameter was accepted and ignored: one shared timestamp meant an
|
|
API warning silenced an unrelated cache warning for the next minute."""
|
|
manager = build()
|
|
assert manager._should_log("api", cooldown=60) is True
|
|
assert manager._should_log("cache", cooldown=60) is True
|
|
assert manager._should_log("api", cooldown=60) is False
|
|
assert manager._should_log("cache", cooldown=60) is False
|
|
|
|
def test_one_type_expiring_does_not_free_another(self, build):
|
|
manager = build()
|
|
manager._should_log("api")
|
|
manager._should_log("cache")
|
|
manager._warning_cooldowns["api"] -= 61
|
|
assert manager._should_log("api") is True
|
|
assert manager._should_log("cache") is False
|
|
|
|
def test_legacy_single_clock_field_is_kept_in_step(self, build):
|
|
"""Subclasses in the plugin copies read _last_warning_time directly."""
|
|
manager = build()
|
|
manager._should_log("api")
|
|
assert manager._last_warning_time == manager._warning_cooldowns["api"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Seam guard rails
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPromotedSeamsExist:
|
|
@pytest.mark.parametrize("name", [
|
|
"cleanup", "_get_layout_offset", "_load_custom_font_from_element_config",
|
|
"_config_schema_path", "_font_root", "_resolve_font_path",
|
|
])
|
|
def test_method_is_callable_on_the_base_class(self, name):
|
|
assert callable(getattr(SportsCore, name, None)), (
|
|
f"SportsCore.{name} is part of the promoted plugin-facing seam "
|
|
"(docs/SPORTS_UNIFICATION.md) — plugins probe for it with "
|
|
"hasattr before delegating.")
|
|
|
|
def test_no_sport_names_leaked_into_core(self):
|
|
"""core.py must never branch on which sport it is (prose and skin-id
|
|
examples in docstrings are fine — executable code is not)."""
|
|
tree = ast.parse((CORE_ROOT / "src" / "base_classes" / "sports"
|
|
/ "core.py").read_text())
|
|
docstrings = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
|
|
ast.AsyncFunctionDef)):
|
|
first = node.body[0] if node.body else None
|
|
if (isinstance(first, ast.Expr)
|
|
and isinstance(first.value, ast.Constant)
|
|
and isinstance(first.value.value, str)):
|
|
docstrings.add(id(first.value))
|
|
|
|
tokens = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
if id(node) not in docstrings:
|
|
tokens.append(node.value)
|
|
elif isinstance(node, ast.Name):
|
|
tokens.append(node.id)
|
|
elif isinstance(node, ast.Attribute):
|
|
tokens.append(node.attr)
|
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
ast.ClassDef)):
|
|
tokens.append(node.name)
|
|
|
|
haystack = " ".join(tokens).lower()
|
|
for sport in ("afl", "nrl", "hockey", "baseball", "basketball",
|
|
"football", "lacrosse", "soccer", "ufc"):
|
|
assert sport not in haystack, (
|
|
f"core.py code mentions '{sport}' — core must never learn "
|
|
"sport names; add an override point instead.")
|
|
|
|
|
|
class TestInstallRootResolution:
|
|
"""Guards the depth bug the sports.py -> package move introduced.
|
|
|
|
The move was byte-identical in every class body, but `__file__` gained a
|
|
directory, so `Path(__file__).resolve().parents[2]` silently changed from
|
|
the repo root to `<root>/src`. Textual identity is not semantic identity
|
|
when code measures its own location: these tests assert the resolved
|
|
values, not the index.
|
|
"""
|
|
|
|
def test_install_root_is_the_repo_root(self):
|
|
from src.base_classes.sports.core import _INSTALL_ROOT
|
|
|
|
# The repo root is the directory that actually holds src/ and assets/.
|
|
assert (_INSTALL_ROOT / "src").is_dir()
|
|
assert (_INSTALL_ROOT / "src" / "base_classes" / "sports").is_dir()
|
|
assert _INSTALL_ROOT.name != "src", (
|
|
"_INSTALL_ROOT resolved to src/ — the parents[] depth is off by "
|
|
"one, which is exactly the regression the package move caused.")
|
|
|
|
def test_resolve_project_path_roots_at_repo_not_src(self):
|
|
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
|
|
|
resolved = SportsCore._resolve_project_path(None, Path("assets/fonts"))
|
|
assert resolved == _INSTALL_ROOT / "assets" / "fonts"
|
|
assert "src" not in resolved.relative_to(_INSTALL_ROOT).parts
|
|
|
|
def test_absolute_paths_pass_through_unchanged(self):
|
|
from src.base_classes.sports.core import SportsCore
|
|
|
|
absolute = Path("/tmp/some/logo/dir")
|
|
assert SportsCore._resolve_project_path(None, absolute) == absolute
|
|
|
|
def test_font_root_and_project_path_share_one_anchor(self):
|
|
"""Both consumers must derive from the same constant, so a future
|
|
move needs exactly one line changed rather than two."""
|
|
from src.base_classes.sports.core import SportsCore, _INSTALL_ROOT
|
|
|
|
assert SportsCore._font_root(None) == str(_INSTALL_ROOT)
|