mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
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
591 lines
25 KiB
Python
591 lines
25 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._last_warning_time -= 61
|
|
assert manager._should_log("api", cooldown=60) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|