mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
feat(common): sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, promoted from the scoreboards (sports consolidation stage 4)
The identical sweep: method families every scoreboard carrying them has as an identical copy (executable AST, docstrings stripped, decorators compared), re-measured at ledmatrix-plugins 56c4f15. Four new hardware-free modules; the bodies are the plugins', with type annotations and type comments for the mypy ratchet. - src/common/sports_plugin_host.py: SportsPluginHostMixin, ten helpers of the scoreboard plugin class (manager.py) identical in all nine: _dispatch_switch_refresh (with _SWITCH_REFRESH_MIN_GAP_SECONDS), get_vegas_priority_weight, _favorite_team_is_live, _favorite_scan_targets, _favorite_scan_games, _game_involves, get_vegas_content_type, _dynamic_feature_enabled, _get_total_games_for_manager, _build_manager_key. Listed before BasePlugin, whose defaults two of them override. - src/common/sports_live_scroll.py: SportsLiveScrollMixin, the eight manager.py methods that rebuild a live scroll strip mid-cycle (all eight scoreboards with a strip; ufc has none), with the two rebuild-rate constants. LIVE_VOLATILE_FIELDS differs (afl/nrl/soccer add period_text) and stays in each plugin as host contract. - src/common/sports_display_rules.py: SportsCardOptionsMixin (_card_option, _recent_date_text; the eight team scoreboards; must precede SportsCoreSharedMixin, whose _card_option it wraps) and SportsGameRulesMixin (_filtered_or_all, all but football; _effective_live_duration, all but ufc). Split by carrier so ufc does not gain a _card_option override it never had. - src/common/sports_font_path.py: resolve_font_path, what the 17 _resolve_font_path copies (nine sports.py, eight game_renderer.py) return on a core that ships it: the cwd first, then resolve_asset_path. Not resolve_asset_path alone, which skips the cwd. Left in the plugins: _get_timezone, _extract_game_details and _fetch_data (per-plugin import, abstract contract), _schema_font_size and _resolve_font_size (each plugin's _SCHEMA_PATH), and the families carried by seven plugins or fewer. Tests: behaviour ported from the plugins' own tests against stub hosts with exactly the documented contract, a host-contract test per mixin, and test_sports_stage4_parity.py, which with LEDMATRIX_PLUGINS set compares every promoted method and constant with every plugin copy using scripts/sports_drift_report.py's normalisation plus decorators and values (27 pass against 56c4f15; mutation-checked: a changed body, a swapped decorator, a changed constant, a dropped @contextmanager and an unlisted new method each fail it). test_sports_font_path.py runs both on the same paths from a temporary cwd. All four modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's Unreleased section (module entries for the sunset floor; the version is not bumped) and SPORTS_UNIFICATION. Nothing in core uses them yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""src.common.sports_font_path: the plugins' ``_resolve_font_path``, path for path.
|
||||
|
||||
The plugins' copy probes the core for ``FontManager._resolve_asset_path``
|
||||
and falls back to its own install-root join; ``resolve_font_path`` is what
|
||||
that comes to on a core that ships it. The bodies differ, so instead of an
|
||||
AST comparison this runs both on the same paths -- found in the cwd only,
|
||||
under the install root only, in both, absolute, and nowhere -- from a
|
||||
temporary cwd, and requires the same string back. The plugin copies are read
|
||||
from LEDMATRIX_PLUGINS (every sports.py and game_renderer.py that still has
|
||||
one); without it, the comparison is against the copy transcribed below.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common.font_layout import resolve_asset_path
|
||||
from src.common.sports_font_path import resolve_font_path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
BUNDLED = "assets/fonts/PressStart2P-Regular.ttf"
|
||||
|
||||
#: ledmatrix-plugins 56c4f15, plugins/*-scoreboard/sports.py (docstring and
|
||||
#: comments dropped). The same body is in every sports.py and game_renderer.py.
|
||||
TRANSCRIBED = '''
|
||||
def _resolve_font_path(path: str) -> str:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
try:
|
||||
import src.font_manager as _core_fonts
|
||||
manager = getattr(_core_fonts, "FontManager", None)
|
||||
resolver = getattr(manager, "_resolve_asset_path", None)
|
||||
if resolver is not None:
|
||||
resolved = resolver(path)
|
||||
if resolved and os.path.exists(resolved):
|
||||
return resolved
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(_core_fonts.__file__)))
|
||||
candidate = os.path.join(root, path)
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
except (ImportError, AttributeError, OSError):
|
||||
return path
|
||||
return path
|
||||
'''
|
||||
|
||||
|
||||
def _compile(source: str):
|
||||
namespace = {"os": os}
|
||||
exec(compile(source, "<plugin copy>", "exec"), namespace) # nosec B102 - test-only, source is a plugin file # nosemgrep
|
||||
return namespace["_resolve_font_path"]
|
||||
|
||||
|
||||
def _plugin_copies():
|
||||
"""(label, function) for every plugin copy, or the transcription."""
|
||||
raw = os.environ.get("LEDMATRIX_PLUGINS")
|
||||
root = Path(raw) if raw else None
|
||||
if root is not None and (root / "plugins").is_dir():
|
||||
root = root / "plugins"
|
||||
copies = []
|
||||
if root is not None and root.is_dir():
|
||||
for path in sorted(root.glob("*-scoreboard/*.py")):
|
||||
if path.name not in ("sports.py", "game_renderer.py"):
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_resolve_font_path":
|
||||
copies.append((f"{path.parent.name}/{path.name}",
|
||||
_compile(ast.unparse(node))))
|
||||
if not copies:
|
||||
copies.append(("transcribed", _compile(TRANSCRIBED)))
|
||||
return copies
|
||||
|
||||
|
||||
COPIES = _plugin_copies()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def elsewhere(tmp_path, monkeypatch):
|
||||
"""A cwd that is not the install root, holding one font of its own and a
|
||||
shadow of a bundled one."""
|
||||
(tmp_path / "cwd_only.ttf").write_bytes(b"x")
|
||||
shadow = tmp_path / BUNDLED
|
||||
shadow.parent.mkdir(parents=True)
|
||||
shadow.write_bytes(b"x")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _cases(cwd: Path):
|
||||
return [
|
||||
"cwd_only.ttf", # in the cwd only
|
||||
BUNDLED, # in both: the cwd wins
|
||||
"assets/fonts/4x6-font.ttf", # under the install root only
|
||||
str(REPO / BUNDLED), # absolute, exists
|
||||
str(cwd / "missing.ttf"), # absolute, missing
|
||||
"assets/fonts/no-such-font.ttf", # relative, nowhere
|
||||
"", # empty
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,copy", COPIES, ids=[c[0] for c in COPIES])
|
||||
def test_same_answer_as_the_plugin_copy(label, copy, elsewhere):
|
||||
for path in _cases(elsewhere):
|
||||
assert resolve_font_path(path) == copy(path), (label, path)
|
||||
|
||||
|
||||
def test_the_cwd_comes_first(elsewhere):
|
||||
assert resolve_font_path(BUNDLED) == BUNDLED
|
||||
assert resolve_asset_path(BUNDLED) != BUNDLED # what dropping it would change
|
||||
|
||||
|
||||
def test_the_install_root_after_it(elsewhere):
|
||||
found = resolve_font_path("assets/fonts/4x6-font.ttf")
|
||||
assert Path(found).is_absolute() and Path(found).is_file()
|
||||
|
||||
|
||||
def test_nowhere_comes_back_unchanged(elsewhere):
|
||||
assert resolve_font_path("assets/fonts/no-such-font.ttf") == "assets/fonts/no-such-font.ttf"
|
||||
Reference in New Issue
Block a user