mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-11 01:26:37 +00:00
refactor: remove the skin system
Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vendored
-9
@@ -671,15 +671,6 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/skins",
|
||||
"api_v3.list_skins",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps",
|
||||
"api_v3.get_starlark_apps",
|
||||
|
||||
@@ -209,30 +209,3 @@ class TestStandaloneBackupContract:
|
||||
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
||||
assert ".standalone-backup-" in sm_text
|
||||
|
||||
|
||||
class TestSkinTargetResolution:
|
||||
def _store(self, tmp_path):
|
||||
return PluginStoreManager(
|
||||
plugins_dir=str(tmp_path / "plugins"),
|
||||
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
||||
|
||||
def test_valid_skin_id_resolves_inside_skins_dir(self, tmp_path):
|
||||
from src.skin_system import skin_runtime
|
||||
store = self._store(tmp_path)
|
||||
target = store._resolve_skin_target("my-skin")
|
||||
assert target is not None
|
||||
assert target.parent == skin_runtime.get_skins_directory().resolve()
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
"../evil",
|
||||
"..",
|
||||
"a/../../etc",
|
||||
"/etc/passwd",
|
||||
"skin/../../outside",
|
||||
"",
|
||||
None,
|
||||
123,
|
||||
])
|
||||
def test_traversal_and_malformed_ids_rejected(self, tmp_path, bad_id):
|
||||
store = self._store(tmp_path)
|
||||
assert store._resolve_skin_target(bad_id) is None
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
"""Gap tests for src/skin_system/skin_runtime.py: the discovery cache,
|
||||
module namespacing internals, API gating edge cases, and targeting.
|
||||
|
||||
test/test_skin_system.py already covers discovery validation, load_skin
|
||||
basics, and build_context — nothing here duplicates those.
|
||||
|
||||
NOTE: every test uses a UNIQUE skin id. load_skin caches the entry
|
||||
module in sys.modules per skin id and never re-executes it, so reusing
|
||||
an id across tests would silently serve another test's module.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# skin_runtime -> skin_base can transitively reach hardware modules via
|
||||
# sports imports in sibling tests' processes; stub the matrix driver
|
||||
# before importing, matching test_skin_system.py.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION, ScoreboardSkin
|
||||
|
||||
|
||||
DEFAULT_BODY = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class {cls}(ScoreboardSkin):\n"
|
||||
" def render_live(self, ctx, game):\n"
|
||||
" return True\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_runtime_state():
|
||||
"""Clear the discovery cache and any skin modules this test creates."""
|
||||
skin_runtime._discovery_cache.clear()
|
||||
before = {k for k in sys.modules if k.startswith("_skin_")}
|
||||
yield
|
||||
skin_runtime._discovery_cache.clear()
|
||||
created = [k for k in sys.modules
|
||||
if k.startswith("_skin_") and k not in before]
|
||||
for k in created:
|
||||
sys.modules.pop(k, None)
|
||||
|
||||
|
||||
def make_skin(skins_dir: Path, skin_id: str, *,
|
||||
api_version: str = SKIN_API_VERSION,
|
||||
class_name: str = "TestSkin",
|
||||
body: str = None,
|
||||
extra_files: dict = None,
|
||||
entry_point: str = None,
|
||||
manifest_id: str = None,
|
||||
manifest_extra: dict = None,
|
||||
write_entry: bool = True) -> Path:
|
||||
"""Write a skin package directory and return its path."""
|
||||
skin_dir = skins_dir / skin_id
|
||||
skin_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"id": manifest_id or skin_id,
|
||||
"name": skin_id,
|
||||
"version": "1.0.0",
|
||||
"skin_api_version": api_version,
|
||||
"class_name": class_name,
|
||||
}
|
||||
if entry_point:
|
||||
manifest["entry_point"] = entry_point
|
||||
manifest.update(manifest_extra or {})
|
||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||
if write_entry:
|
||||
entry_name = entry_point or "skin.py"
|
||||
(skin_dir / entry_name).write_text(
|
||||
body if body is not None else DEFAULT_BODY.format(cls=class_name))
|
||||
for name, content in (extra_files or {}).items():
|
||||
(skin_dir / name).write_text(content)
|
||||
return skin_dir
|
||||
|
||||
|
||||
def counting_read_manifest(monkeypatch):
|
||||
"""Wrap skin_runtime._read_manifest with a call counter."""
|
||||
original = skin_runtime._read_manifest
|
||||
counter = {"count": 0}
|
||||
|
||||
def wrapper(skin_dir):
|
||||
counter["count"] += 1
|
||||
return original(skin_dir)
|
||||
|
||||
monkeypatch.setattr(skin_runtime, "_read_manifest", wrapper)
|
||||
return counter
|
||||
|
||||
|
||||
def bump_mtime(path: Path, offset: float = 100.0):
|
||||
"""Set a distinct, strictly later mtime so the fingerprint changes."""
|
||||
t = time.time() + offset
|
||||
os.utime(path, (t, t))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A. Discovery cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDiscoveryCache:
|
||||
def test_second_call_serves_cache(self, tmp_path, monkeypatch):
|
||||
make_skin(tmp_path, "t01-cache-hit")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
first = skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
assert count_after_first >= 1
|
||||
second = skin_runtime.discover_skins(tmp_path)
|
||||
assert counter["count"] == count_after_first # no re-read
|
||||
assert second == first
|
||||
assert "t01-cache-hit" in second
|
||||
|
||||
def test_manifest_edit_invalidates_without_force_refresh(self, tmp_path):
|
||||
skin_dir = make_skin(tmp_path, "t02-edit")
|
||||
skins = skin_runtime.discover_skins(tmp_path)
|
||||
assert skins["t02-edit"]["name"] == "t02-edit"
|
||||
|
||||
manifest_path = skin_dir / "skin.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["name"] = "renamed"
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
bump_mtime(manifest_path)
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
||||
assert skins["t02-edit"]["name"] == "renamed"
|
||||
|
||||
def test_new_skin_dir_invalidates(self, tmp_path):
|
||||
make_skin(tmp_path, "t03-first")
|
||||
assert set(skin_runtime.discover_skins(tmp_path)) == {"t03-first"}
|
||||
|
||||
new_dir = make_skin(tmp_path, "t03-second")
|
||||
bump_mtime(new_dir / "skin.json")
|
||||
bump_mtime(tmp_path)
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path) # no force_refresh
|
||||
assert set(skins) == {"t03-first", "t03-second"}
|
||||
|
||||
def test_py_file_change_does_not_invalidate(self, tmp_path, monkeypatch):
|
||||
# PIN: the fingerprint only globs */skin.json — editing a skin's
|
||||
# .py file alone does NOT invalidate the cache; the cached
|
||||
# manifests are still served (a code change needs a restart).
|
||||
skin_dir = make_skin(tmp_path, "t04-pyedit")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
|
||||
(skin_dir / "skin.py").write_text("# rewritten\n" +
|
||||
DEFAULT_BODY.format(cls="TestSkin"))
|
||||
bump_mtime(skin_dir / "skin.py")
|
||||
|
||||
skins = skin_runtime.discover_skins(tmp_path)
|
||||
assert counter["count"] == count_after_first # cache still served
|
||||
assert "t04-pyedit" in skins
|
||||
|
||||
def test_force_refresh_rereads_with_unchanged_fingerprint(self, tmp_path,
|
||||
monkeypatch):
|
||||
make_skin(tmp_path, "t05-force")
|
||||
counter = counting_read_manifest(monkeypatch)
|
||||
skin_runtime.discover_skins(tmp_path)
|
||||
count_after_first = counter["count"]
|
||||
skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert counter["count"] > count_after_first
|
||||
|
||||
def test_result_mapping_is_copy_but_manifests_shared(self, tmp_path):
|
||||
make_skin(tmp_path, "t06-copy")
|
||||
result = skin_runtime.discover_skins(tmp_path)
|
||||
|
||||
# Mutating the returned mapping does not poison the cache...
|
||||
del result["t06-copy"]
|
||||
again = skin_runtime.discover_skins(tmp_path) # cache hit
|
||||
assert "t06-copy" in again
|
||||
|
||||
# ...but the inner manifest dicts ARE shared with the cache (pin).
|
||||
again["t06-copy"]["name"] = "mutated-inner"
|
||||
third = skin_runtime.discover_skins(tmp_path) # cache hit
|
||||
assert third["t06-copy"]["name"] == "mutated-inner"
|
||||
|
||||
def test_missing_directory_returns_empty_and_caches_nothing(self, tmp_path):
|
||||
missing = tmp_path / "not-yet"
|
||||
assert skin_runtime.discover_skins(missing) == {}
|
||||
assert str(missing) not in skin_runtime._discovery_cache
|
||||
|
||||
# Creating the directory later is picked up without force_refresh.
|
||||
make_skin(missing, "t07-late")
|
||||
skins = skin_runtime.discover_skins(missing)
|
||||
assert "t07-late" in skins
|
||||
|
||||
def test_hidden_underscore_and_plain_file_entries_skipped(self, tmp_path):
|
||||
make_skin(tmp_path, ".hidden-skin")
|
||||
make_skin(tmp_path, "_private-skin")
|
||||
(tmp_path / "stray-file").write_text("not a directory")
|
||||
make_skin(tmp_path, "t08-good")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert set(skins) == {"t08-good"}
|
||||
|
||||
def test_manifest_id_mismatch_keys_by_manifest_id(self, tmp_path):
|
||||
make_skin(tmp_path, "t09-dirname", manifest_id="t09-manifest-id")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert "t09-manifest-id" in skins
|
||||
assert "t09-dirname" not in skins
|
||||
assert skins["t09-manifest-id"]["_skin_dir"].endswith("t09-dirname")
|
||||
|
||||
def test_falsy_required_field_drops_skin(self, tmp_path):
|
||||
make_skin(tmp_path, "t10-empty-class", class_name="")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert skins == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B. Module namespacing (_load_skin_module via load_skin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BODY_WITH_HELPERS = (
|
||||
"import helpers\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
)
|
||||
|
||||
|
||||
class TestModuleNamespacing:
|
||||
def test_namespaced_sys_modules_keys(self, tmp_path):
|
||||
make_skin(tmp_path, "t11-ns", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 11\n"})
|
||||
skin = skin_runtime.load_skin("t11-ns", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert "_skin_t11-ns_skin" in sys.modules
|
||||
assert "_skin_t11-ns_helpers" in sys.modules
|
||||
|
||||
def test_preseeded_bare_name_restored(self, tmp_path, monkeypatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
||||
make_skin(tmp_path, "t12a-restore", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 'a'\n"})
|
||||
skin = skin_runtime.load_skin("t12a-restore", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert sys.modules["helpers"] is sentinel
|
||||
|
||||
def test_absent_bare_name_stays_absent(self, tmp_path):
|
||||
saved = sys.modules.pop("helpers", None)
|
||||
try:
|
||||
assert "helpers" not in sys.modules
|
||||
make_skin(tmp_path, "t12b-absent", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "VALUE = 'b'\n"})
|
||||
skin = skin_runtime.load_skin("t12b-absent", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert "helpers" not in sys.modules
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules["helpers"] = saved
|
||||
|
||||
def test_stdlib_shadowing_sibling_leaves_real_module_intact(self, tmp_path):
|
||||
real_json = sys.modules["json"]
|
||||
make_skin(tmp_path, "t12c-json",
|
||||
extra_files={"json.py": "SKIN_LOCAL = True\n"})
|
||||
skin = skin_runtime.load_skin("t12c-json", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert sys.modules["json"] is real_json
|
||||
assert not hasattr(sys.modules["json"], "SKIN_LOCAL")
|
||||
assert json.loads('{"ok": 1}') == {"ok": 1} # stdlib still works
|
||||
# The skin's copy lives only under its namespaced alias.
|
||||
assert getattr(sys.modules["_skin_t12c-json_json"], "SKIN_LOCAL") is True
|
||||
|
||||
def test_entry_module_executed_once_across_loads(self, tmp_path,
|
||||
monkeypatch):
|
||||
executions = []
|
||||
monkeypatch.setattr(builtins, "_t13_skin_executions", executions,
|
||||
raising=False)
|
||||
body = (
|
||||
"import builtins\n"
|
||||
"builtins._t13_skin_executions.append(1)\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
)
|
||||
make_skin(tmp_path, "t13-cached", body=body)
|
||||
for _ in range(3):
|
||||
skin = skin_runtime.load_skin("t13-cached", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
assert len(executions) == 1 # module executed exactly once
|
||||
|
||||
def test_sibling_import_failure_returns_none_and_restores_bare(
|
||||
self, tmp_path, monkeypatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setitem(sys.modules, "helpers", sentinel)
|
||||
make_skin(tmp_path, "t14-sibfail", body=BODY_WITH_HELPERS,
|
||||
extra_files={"helpers.py": "raise RuntimeError('sibling boom')\n"})
|
||||
assert skin_runtime.load_skin("t14-sibfail", skins_dir=tmp_path) is None
|
||||
assert sys.modules["helpers"] is sentinel
|
||||
|
||||
def test_missing_entry_point_file(self, tmp_path):
|
||||
make_skin(tmp_path, "t15-noentry", write_entry=False)
|
||||
assert skin_runtime.load_skin("t15-noentry", skins_dir=tmp_path) is None
|
||||
|
||||
def test_custom_entry_point(self, tmp_path):
|
||||
make_skin(tmp_path, "t16-custom", entry_point="render.py")
|
||||
skin = skin_runtime.load_skin("t16-custom", skins_dir=tmp_path)
|
||||
assert isinstance(skin, ScoreboardSkin)
|
||||
assert "_skin_t16-custom_render" in sys.modules
|
||||
assert "_skin_t16-custom_skin" not in sys.modules
|
||||
|
||||
def test_class_name_pointing_at_unrelated_class(self, tmp_path):
|
||||
body = "class NotASkin:\n pass\n"
|
||||
make_skin(tmp_path, "t17a-wrongclass", body=body,
|
||||
class_name="NotASkin")
|
||||
assert skin_runtime.load_skin("t17a-wrongclass",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
def test_class_name_pointing_at_instance(self, tmp_path):
|
||||
body = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class MySkin(ScoreboardSkin):\n"
|
||||
" pass\n"
|
||||
"obj = MySkin({}, {})\n"
|
||||
)
|
||||
make_skin(tmp_path, "t17b-instance", body=body, class_name="obj")
|
||||
assert skin_runtime.load_skin("t17b-instance",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
def test_constructor_raising_returns_none(self, tmp_path):
|
||||
body = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" def __init__(self, manifest, options):\n"
|
||||
" raise ValueError('ctor boom')\n"
|
||||
)
|
||||
make_skin(tmp_path, "t18-ctor", body=body)
|
||||
assert skin_runtime.load_skin("t18-ctor", skins_dir=tmp_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. API gate + targeting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApiGateAndTargeting:
|
||||
def test_same_major_higher_minor_loads(self, tmp_path):
|
||||
make_skin(tmp_path, "t19-minor", api_version="1.9.0")
|
||||
skin = skin_runtime.load_skin("t19-minor", skins_dir=tmp_path)
|
||||
assert isinstance(skin, ScoreboardSkin)
|
||||
|
||||
def test_malformed_api_version_refused(self, tmp_path):
|
||||
make_skin(tmp_path, "t20-malformed", api_version="abc")
|
||||
assert skin_runtime.load_skin("t20-malformed",
|
||||
skins_dir=tmp_path) is None
|
||||
|
||||
@pytest.mark.parametrize("manifest,sport,sport_key,expected", [
|
||||
# No targets key at all -> matches everything
|
||||
({"id": "x"}, "baseball", "mlb", True),
|
||||
({"id": "x"}, None, None, True),
|
||||
# Empty targets dict -> matches everything
|
||||
({"id": "x", "targets": {}}, "hockey", None, True),
|
||||
# sports family match
|
||||
({"id": "x", "targets": {"sports": ["baseball"]}},
|
||||
"baseball", None, True),
|
||||
# sport_keys exact match
|
||||
({"id": "x", "targets": {"sport_keys": ["milb"]}},
|
||||
None, "milb", True),
|
||||
# OR semantics: sport_keys matches even though sports excludes it
|
||||
({"id": "x", "targets": {"sports": ["hockey"],
|
||||
"sport_keys": ["milb"]}},
|
||||
"baseball", "milb", True),
|
||||
# Neither matches
|
||||
({"id": "x", "targets": {"sports": ["hockey"]}},
|
||||
"baseball", None, False),
|
||||
({"id": "x", "targets": {"sports": ["hockey"],
|
||||
"sport_keys": ["nhl"]}},
|
||||
"baseball", "milb", False),
|
||||
])
|
||||
def test_skin_matches_target(self, manifest, sport, sport_key, expected):
|
||||
assert skin_runtime.skin_matches_target(
|
||||
manifest, sport, sport_key) is expected
|
||||
@@ -1,571 +0,0 @@
|
||||
"""Tests for the skin system: discovery, version gating, fallback
|
||||
semantics, module isolation, and the view-model contract."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
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 the fallback-semantics tests can import SportsCore off-device.
|
||||
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||
|
||||
# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming
|
||||
# or removing any of these is a breaking change to every published skin:
|
||||
# it requires a VIEW_MODEL_VERSION major bump and a compat shim.
|
||||
GUARANTEED_KEYS = [
|
||||
"id", "game_time", "game_date", "start_time_utc", "status_text",
|
||||
"is_live", "is_final", "is_upcoming", "is_halftime",
|
||||
"home_abbr", "home_id", "home_score", "home_logo_path", "home_record",
|
||||
"away_abbr", "away_id", "away_score", "away_logo_path", "away_record",
|
||||
]
|
||||
|
||||
|
||||
def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION,
|
||||
body: str = None, extra_files: dict = None,
|
||||
class_name: str = "TestSkin") -> Path:
|
||||
skin_dir = skins_dir / skin_id
|
||||
skin_dir.mkdir(parents=True)
|
||||
manifest = {
|
||||
"id": skin_id, "name": skin_id, "version": "1.0.0",
|
||||
"skin_api_version": api_version, "class_name": class_name,
|
||||
"targets": {"sports": ["baseball"]},
|
||||
}
|
||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||
if body is None:
|
||||
body = (
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
f"class {class_name}(ScoreboardSkin):\n"
|
||||
" def render_live(self, ctx, game):\n"
|
||||
" ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n"
|
||||
" return True\n"
|
||||
)
|
||||
(skin_dir / "skin.py").write_text(body)
|
||||
for name, content in (extra_files or {}).items():
|
||||
(skin_dir / name).write_text(content)
|
||||
return skin_dir
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
def test_discovers_valid_skin(self, tmp_path):
|
||||
write_skin(tmp_path, "my-skin")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert "my-skin" in skins
|
||||
assert skins["my-skin"]["_skin_dir"].endswith("my-skin")
|
||||
|
||||
def test_skips_manifest_missing_required_fields(self, tmp_path):
|
||||
skin_dir = tmp_path / "broken"
|
||||
skin_dir.mkdir()
|
||||
(skin_dir / "skin.json").write_text(json.dumps({"id": "broken"}))
|
||||
assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {}
|
||||
|
||||
def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path):
|
||||
(tmp_path / "not-a-skin").mkdir()
|
||||
bad = tmp_path / "bad-json"
|
||||
bad.mkdir()
|
||||
(bad / "skin.json").write_text("{nope")
|
||||
write_skin(tmp_path, "good-skin")
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert list(skins) == ["good-skin"]
|
||||
|
||||
def test_missing_directory_is_empty(self, tmp_path):
|
||||
assert skin_runtime.discover_skins(tmp_path / "nope") == {}
|
||||
|
||||
def test_example_skin_in_repo_is_discoverable(self):
|
||||
skins = skin_runtime.discover_skins(force_refresh=True)
|
||||
assert "example-classic-baseball" in skins
|
||||
|
||||
|
||||
class TestLoadSkin:
|
||||
def test_loads_and_instantiates(self, tmp_path):
|
||||
write_skin(tmp_path, "my-skin")
|
||||
skin = skin_runtime.load_skin("my-skin", sport="baseball",
|
||||
skins_dir=tmp_path)
|
||||
assert isinstance(skin, ScoreboardSkin)
|
||||
|
||||
def test_unknown_skin_returns_none(self, tmp_path):
|
||||
assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None
|
||||
|
||||
def test_api_major_mismatch_is_refused(self, tmp_path):
|
||||
write_skin(tmp_path, "old-skin", api_version="99.0.0")
|
||||
assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None
|
||||
|
||||
def test_target_mismatch_still_loads(self, tmp_path):
|
||||
write_skin(tmp_path, "my-skin") # targets baseball
|
||||
skin = skin_runtime.load_skin("my-skin", sport="hockey",
|
||||
skins_dir=tmp_path)
|
||||
assert skin is not None # soft warning, not a hard block
|
||||
|
||||
def test_import_error_returns_none(self, tmp_path):
|
||||
write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n")
|
||||
assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None
|
||||
|
||||
def test_wrong_class_returns_none(self, tmp_path):
|
||||
write_skin(tmp_path, "classless", body="x = 1\n")
|
||||
assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None
|
||||
|
||||
def test_options_are_passed_through(self, tmp_path):
|
||||
write_skin(tmp_path, "my-skin")
|
||||
skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path,
|
||||
options={"accent": [1, 2, 3]})
|
||||
assert skin.options == {"accent": [1, 2, 3]}
|
||||
|
||||
def test_sibling_modules_are_isolated_between_skins(self, tmp_path):
|
||||
helper = "VALUE = {!r}\n"
|
||||
body = (
|
||||
"import helpers\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" def render_live(self, ctx, game):\n"
|
||||
" ctx.logger.info(helpers.VALUE)\n"
|
||||
" self.helper_value = helpers.VALUE\n"
|
||||
" return False\n"
|
||||
)
|
||||
write_skin(tmp_path, "skin-a", body=body,
|
||||
extra_files={"helpers.py": helper.format("A")})
|
||||
write_skin(tmp_path, "skin-b", body=body,
|
||||
extra_files={"helpers.py": helper.format("B")})
|
||||
skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path)
|
||||
skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path)
|
||||
ctx = _make_context()
|
||||
skin_a.render_live(ctx, {})
|
||||
skin_b.render_live(ctx, {})
|
||||
assert skin_a.helper_value == "A"
|
||||
assert skin_b.helper_value == "B"
|
||||
|
||||
def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path):
|
||||
"""The live/recent/upcoming hosts each load the same skin — the
|
||||
2nd and 3rd loads must still resolve sibling modules (regression:
|
||||
cached siblings used to be skipped without rebinding)."""
|
||||
body = (
|
||||
"import reload_helpers\n"
|
||||
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||
"class TestSkin(ScoreboardSkin):\n"
|
||||
" def render_live(self, ctx, game):\n"
|
||||
" self.helper_value = reload_helpers.VALUE\n"
|
||||
" return False\n"
|
||||
)
|
||||
write_skin(tmp_path, "reload-skin", body=body,
|
||||
extra_files={"reload_helpers.py": "VALUE = 'R'\n"})
|
||||
ctx = _make_context()
|
||||
for _ in range(3):
|
||||
skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path)
|
||||
assert skin is not None
|
||||
skin.render_live(ctx, {})
|
||||
assert skin.helper_value == "R"
|
||||
|
||||
|
||||
def _make_host(fonts=None):
|
||||
host = MagicMock()
|
||||
host.sport = "baseball"
|
||||
host.sport_key = "mlb"
|
||||
host.skin_options = {"accent": True}
|
||||
host.fonts = fonts or {"time": ImageFont.load_default()}
|
||||
host.logger = logging.getLogger("test_skin_system")
|
||||
host.display_manager.width = 128
|
||||
host.display_manager.height = 32
|
||||
return host
|
||||
|
||||
|
||||
def _make_context(width=128, height=32):
|
||||
host = _make_host()
|
||||
return skin_runtime.build_context(host, {}, size=(width, height))
|
||||
|
||||
|
||||
class TestBuildContext:
|
||||
def test_context_shape(self):
|
||||
host = _make_host()
|
||||
game = {"home_abbr": "LAD", "away_abbr": "SF"}
|
||||
ctx = skin_runtime.build_context(host, game)
|
||||
assert (ctx.width, ctx.height) == (128, 32)
|
||||
assert ctx.canvas.size == (128, 32)
|
||||
assert ctx.sport == "baseball"
|
||||
assert ctx.options == {"accent": True}
|
||||
assert ctx.layout.bounds.w == 128
|
||||
|
||||
def test_explicit_size_overrides_display(self):
|
||||
ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64))
|
||||
assert ctx.canvas.size == (64, 64)
|
||||
|
||||
def test_load_logo_binds_game_and_survives_failure(self):
|
||||
host = _make_host()
|
||||
host._load_and_resize_logo.side_effect = RuntimeError("disk gone")
|
||||
ctx = skin_runtime.build_context(
|
||||
host, {"home_id": "1", "home_abbr": "LAD",
|
||||
"home_logo_path": "x.png", "home_logo_url": None})
|
||||
assert ctx.load_logo("home") is None # exception swallowed
|
||||
assert ctx.load_logo("elsewhere") is None # bad side rejected
|
||||
|
||||
def test_draw_helpers_draw_on_canvas(self):
|
||||
ctx = _make_context()
|
||||
ctx.draw_text("HI", 2, 2, font=ImageFont.load_default())
|
||||
fit = ctx.layout.fit_text("42", ctx.layout.bounds)
|
||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||
logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
|
||||
ctx.draw_image(logo, ctx.layout.bounds.left_col(20))
|
||||
ctx.draw_image(None, ctx.layout.bounds) # None must no-op
|
||||
assert ctx.canvas.convert("L").getbbox() is not None
|
||||
|
||||
|
||||
class _FallbackProbe:
|
||||
"""Bare-bones SportsCore stand-in that exercises the real _render_game."""
|
||||
|
||||
def __init__(self, skin):
|
||||
from src.base_classes.sports import SportsCore
|
||||
self._cls = SportsCore
|
||||
self.SKIN_MODE = "live"
|
||||
self.logger = logging.getLogger("test_skin_system")
|
||||
self.sport = "baseball"
|
||||
self.sport_key = "mlb"
|
||||
self.skin_options = {}
|
||||
self.fonts = {"time": ImageFont.load_default()}
|
||||
self._skin = skin
|
||||
self._skin_load_attempted = True
|
||||
self._skin_failures = 0
|
||||
self._skin_slow_renders = 0
|
||||
self._skin_config = "test-skin"
|
||||
self.display_manager = MagicMock()
|
||||
self.display_manager.width = 128
|
||||
self.display_manager.height = 32
|
||||
self.display_manager.image = Image.new("RGB", (128, 32))
|
||||
self.builtin_calls = 0
|
||||
|
||||
def _resolve_skin_id(self):
|
||||
return "test-skin"
|
||||
|
||||
def _draw_scorebug_layout(self, game, force_clear=False):
|
||||
self.builtin_calls += 1
|
||||
|
||||
def _render_game(self, game, force_clear=False):
|
||||
from src.base_classes.sports import SportsCore
|
||||
SportsCore._render_game(self, game, force_clear)
|
||||
|
||||
def _get_skin(self):
|
||||
return self._skin
|
||||
|
||||
|
||||
class TestRenderGameFallback:
|
||||
def test_skin_handles_render(self):
|
||||
class GoodSkin(ScoreboardSkin):
|
||||
def render_live(self, ctx, game):
|
||||
ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0))
|
||||
return True
|
||||
|
||||
probe = _FallbackProbe(GoodSkin({}, {}))
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
assert probe.builtin_calls == 0
|
||||
probe.display_manager.update_display.assert_called_once()
|
||||
assert probe.display_manager.image.convert("L").getbbox() is not None
|
||||
|
||||
def test_skin_declining_falls_back(self):
|
||||
probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
assert probe.builtin_calls == 1
|
||||
|
||||
def test_no_skin_falls_back(self):
|
||||
probe = _FallbackProbe(None)
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
assert probe.builtin_calls == 1
|
||||
|
||||
def test_three_strikes_disables_skin(self):
|
||||
class BrokenSkin(ScoreboardSkin):
|
||||
calls = 0
|
||||
|
||||
def render_live(self, ctx, game):
|
||||
BrokenSkin.calls += 1
|
||||
raise ValueError("kaboom")
|
||||
|
||||
probe = _FallbackProbe(BrokenSkin({}, {}))
|
||||
for i in range(5):
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
# every render fell back to the built-in layout...
|
||||
assert probe.builtin_calls == 5
|
||||
# ...and the skin stopped being called after the 3rd failure
|
||||
assert BrokenSkin.calls == 3
|
||||
assert probe._skin_failures == 3
|
||||
|
||||
def test_skin_cannot_mutate_callers_game_dict(self):
|
||||
class MutatingSkin(ScoreboardSkin):
|
||||
def render_live(self, ctx, game):
|
||||
game.clear()
|
||||
game["hacked"] = True
|
||||
return True
|
||||
|
||||
probe = _FallbackProbe(MutatingSkin({}, {}))
|
||||
game = {"status_text": "Q1", "home_score": "3"}
|
||||
probe._render_game(game)
|
||||
assert game == {"status_text": "Q1", "home_score": "3"}
|
||||
|
||||
|
||||
class TestSkinModeResolution:
|
||||
def _core(self, skin_config, mode="live"):
|
||||
from src.base_classes.sports import SportsCore
|
||||
probe = _FallbackProbe(None)
|
||||
probe.SKIN_MODE = mode
|
||||
probe._skin_config = skin_config
|
||||
return SportsCore._resolve_skin_id(probe)
|
||||
|
||||
def test_plain_id_applies_to_all_modes(self):
|
||||
assert self._core("retro", "live") == "retro"
|
||||
assert self._core("retro", "recent") == "retro"
|
||||
|
||||
def test_per_mode_mapping(self):
|
||||
cfg = {"live": "retro", "recent": "built-in"}
|
||||
assert self._core(cfg, "live") == "retro"
|
||||
assert self._core(cfg, "recent") is None
|
||||
assert self._core(cfg, "upcoming") is None
|
||||
|
||||
def test_builtin_and_empty_mean_none(self):
|
||||
assert self._core("built-in") is None
|
||||
assert self._core("") is None
|
||||
assert self._core(None) is None
|
||||
|
||||
|
||||
class TestViewModelContract:
|
||||
@pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"])
|
||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
||||
def test_fixtures_carry_all_guaranteed_keys(self, sport, mode):
|
||||
with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f:
|
||||
game = json.load(f)
|
||||
missing = [k for k in GUARANTEED_KEYS if k not in game]
|
||||
assert not missing, f"{sport}_{mode} fixture missing {missing}"
|
||||
|
||||
def test_extractor_produces_guaranteed_keys(self):
|
||||
"""The real extractor's output must be a superset of the documented
|
||||
contract — this is the test that catches accidental renames."""
|
||||
import pytz
|
||||
from src.base_classes.sports import SportsCore
|
||||
|
||||
event = {
|
||||
"id": "401570001",
|
||||
"date": "2026-07-16T23:05:00Z",
|
||||
"competitions": [{
|
||||
"status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in",
|
||||
"shortDetail": "Bot 7th"}},
|
||||
"competitors": [
|
||||
{"homeAway": "home", "id": "19",
|
||||
"team": {"abbreviation": "LAD"}, "score": "5",
|
||||
"records": [{"summary": "58-33"}]},
|
||||
{"homeAway": "away", "id": "26",
|
||||
"team": {"abbreviation": "SF"}, "score": "3",
|
||||
"records": [{"summary": "49-42"}]},
|
||||
],
|
||||
}],
|
||||
}
|
||||
probe = MagicMock()
|
||||
probe.logger = logging.getLogger("test_skin_system")
|
||||
probe.favorite_teams = []
|
||||
probe.config = {}
|
||||
probe.logo_dir = Path("assets/logos")
|
||||
probe._get_timezone.return_value = pytz.utc
|
||||
probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th"
|
||||
|
||||
details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event)
|
||||
assert details is not None
|
||||
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
||||
assert not missing, (
|
||||
f"_extract_game_details_common no longer emits {missing}. "
|
||||
"These keys are part of the frozen skin view-model contract "
|
||||
"(VIEW_MODEL_VERSION) — renaming or removing them breaks every "
|
||||
"published skin. Add a compat shim or bump the major version.")
|
||||
|
||||
|
||||
class TestPluginMatching:
|
||||
def test_matches_by_sport_token_and_sport_key(self, tmp_path):
|
||||
write_skin(tmp_path, "bb-skin") # targets sports=["baseball"]
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
||||
assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins)
|
||||
|
||||
def test_matches_by_explicit_plugin_list(self, tmp_path):
|
||||
skin_dir = write_skin(tmp_path, "exact-skin")
|
||||
manifest = json.loads((skin_dir / "skin.json").read_text())
|
||||
manifest["targets"] = {"plugins": ["my-custom-plugin"]}
|
||||
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||
assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins)
|
||||
assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
||||
|
||||
|
||||
class TestSchemaInjection:
|
||||
def _manager(self):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
return SchemaManager()
|
||||
|
||||
def test_injects_enum_with_installed_and_configured_skins(self):
|
||||
sm = self._manager()
|
||||
schema = {"type": "object", "properties": {}}
|
||||
out = sm.inject_skin_selector(schema, "baseball-scoreboard",
|
||||
current_value="gone-skin")
|
||||
enum = out["properties"]["skin"]["enum"]
|
||||
assert enum[0] == "built-in"
|
||||
assert "example-classic-baseball" in enum
|
||||
# an uninstalled-but-configured skin must stay selectable so the
|
||||
# saved config never becomes invalid in the UI
|
||||
assert "gone-skin" in enum
|
||||
assert "skin" not in schema["properties"] # source schema untouched
|
||||
|
||||
def test_no_matching_skins_leaves_schema_alone(self):
|
||||
sm = self._manager()
|
||||
schema = {"type": "object", "properties": {}}
|
||||
out = sm.inject_skin_selector(schema, "totally-unrelated-plugin")
|
||||
assert "skin" not in out.get("properties", {})
|
||||
|
||||
def test_validation_accepts_skin_keys_without_enum(self):
|
||||
sm = self._manager()
|
||||
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
|
||||
ok, errors = sm.validate_config_against_schema(
|
||||
{"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}},
|
||||
schema, "baseball-scoreboard")
|
||||
assert ok, errors
|
||||
ok, errors = sm.validate_config_against_schema(
|
||||
{"skin": {"live": "a", "recent": "built-in"}}, schema, "p")
|
||||
assert ok, errors
|
||||
|
||||
|
||||
class TestExampleSkin:
|
||||
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
||||
@pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)])
|
||||
def test_renders_all_modes_and_sizes(self, mode, size):
|
||||
skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball")
|
||||
assert skin is not None
|
||||
host = _make_host()
|
||||
host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255))
|
||||
with open(FIXTURES_DIR / f"baseball_{mode}.json") as f:
|
||||
game = json.load(f)
|
||||
ctx = skin_runtime.build_context(host, game, size=size)
|
||||
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
||||
assert ctx.canvas.convert("L").getbbox() is not None
|
||||
|
||||
|
||||
class TestRenderSkinCard:
|
||||
"""render_skin_card (vegas cards) shares _render_game's 3-strike counter.
|
||||
|
||||
Both paths reset the counter on success — transient failures must not
|
||||
accumulate across a session and disable a working skin.
|
||||
"""
|
||||
|
||||
def _probe(self, skin):
|
||||
from src.base_classes.sports import SportsCore
|
||||
probe = _FallbackProbe(skin)
|
||||
probe.render_skin_card = (
|
||||
lambda game, size: SportsCore.render_skin_card(probe, game, size))
|
||||
return probe
|
||||
|
||||
def test_vegas_card_returned_when_skin_provides_one(self):
|
||||
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
||||
|
||||
class CardSkin(ScoreboardSkin):
|
||||
def render_vegas_card(self, ctx, game):
|
||||
return card_img
|
||||
|
||||
probe = self._probe(CardSkin({}, {}))
|
||||
assert probe.render_skin_card({}, (96, 32)) is card_img
|
||||
|
||||
def test_vegas_card_none_falls_through_to_mode_renderer(self):
|
||||
class ModeOnlySkin(ScoreboardSkin):
|
||||
def render_live(self, ctx, game):
|
||||
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
|
||||
return True
|
||||
|
||||
probe = self._probe(ModeOnlySkin({}, {}))
|
||||
card = probe.render_skin_card({}, (96, 32))
|
||||
assert card is not None
|
||||
assert card.size == (96, 32)
|
||||
assert card.convert("L").getbbox() is not None
|
||||
|
||||
def test_skin_declining_returns_none(self):
|
||||
probe = self._probe(ScoreboardSkin({}, {})) # all renders -> False
|
||||
assert probe.render_skin_card({}, (96, 32)) is None
|
||||
assert probe._skin_failures == 0 # declining is not a failure
|
||||
|
||||
def test_no_skin_returns_none(self):
|
||||
probe = self._probe(None)
|
||||
assert probe.render_skin_card({}, (96, 32)) is None
|
||||
|
||||
def test_card_failures_count_toward_shared_disable(self):
|
||||
class BrokenCardSkin(ScoreboardSkin):
|
||||
calls = 0
|
||||
|
||||
def render_vegas_card(self, ctx, game):
|
||||
BrokenCardSkin.calls += 1
|
||||
raise ValueError("kaboom")
|
||||
|
||||
probe = self._probe(BrokenCardSkin({}, {}))
|
||||
for _ in range(5):
|
||||
assert probe.render_skin_card({}, (96, 32)) is None
|
||||
# Skin stopped being consulted after the 3rd failure...
|
||||
assert BrokenCardSkin.calls == 3
|
||||
assert probe._skin_failures == 3
|
||||
# ...and the shared counter also disables _render_game's skin path.
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
assert probe.builtin_calls == 1
|
||||
assert BrokenCardSkin.calls == 3 # not consulted again
|
||||
|
||||
def test_card_success_resets_strikes(self):
|
||||
"""A successful card render clears accumulated strikes (mirroring
|
||||
_render_game) — 2 failures + a success + 1 failure leaves the skin
|
||||
enabled with a single strike, instead of disabling it."""
|
||||
card_img = Image.new("RGB", (96, 32), (0, 0, 255))
|
||||
|
||||
class FlakyCardSkin(ScoreboardSkin):
|
||||
fail = True
|
||||
|
||||
def render_vegas_card(self, ctx, game):
|
||||
if FlakyCardSkin.fail:
|
||||
raise ValueError("kaboom")
|
||||
return card_img
|
||||
|
||||
probe = self._probe(FlakyCardSkin({}, {}))
|
||||
FlakyCardSkin.fail = True
|
||||
probe.render_skin_card({}, (96, 32))
|
||||
probe.render_skin_card({}, (96, 32))
|
||||
assert probe._skin_failures == 2
|
||||
|
||||
FlakyCardSkin.fail = False
|
||||
assert probe.render_skin_card({}, (96, 32)) is card_img
|
||||
assert probe._skin_failures == 0 # success cleared the strikes
|
||||
|
||||
FlakyCardSkin.fail = True
|
||||
probe.render_skin_card({}, (96, 32))
|
||||
assert probe._skin_failures == 1 # counting from the reset state
|
||||
FlakyCardSkin.fail = False
|
||||
assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled
|
||||
|
||||
def test_card_success_via_mode_renderer_also_resets_strikes(self):
|
||||
"""The fallthrough path (render_vegas_card None -> mode renderer
|
||||
True) resets the counter as well."""
|
||||
class ModeOnlySkin(ScoreboardSkin):
|
||||
def render_live(self, ctx, game):
|
||||
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
|
||||
return True
|
||||
|
||||
probe = self._probe(ModeOnlySkin({}, {}))
|
||||
probe._skin_failures = 2
|
||||
assert probe.render_skin_card({}, (96, 32)) is not None
|
||||
assert probe._skin_failures == 0
|
||||
|
||||
def test_render_game_success_also_resets_strikes(self):
|
||||
"""Same reset contract on the display path, for symmetry."""
|
||||
class GoodSkin(ScoreboardSkin):
|
||||
def render_live(self, ctx, game):
|
||||
return True
|
||||
|
||||
probe = _FallbackProbe(GoodSkin({}, {}))
|
||||
probe._skin_failures = 2
|
||||
probe._render_game({"status_text": "Q1"})
|
||||
assert probe._skin_failures == 0
|
||||
@@ -9,9 +9,9 @@ can never disagree. (Historically the store used raw string equality, which
|
||||
reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even
|
||||
DOWNGRADED locally-ahead plugins; this file's tests killed that.)
|
||||
|
||||
Two other version parsers legitimately remain and are pinned here so they
|
||||
don't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
||||
range-spec oriented) and `skin_runtime._major` (skin API major gate).
|
||||
One other version parser legitimately remains and is pinned here so it
|
||||
doesn't drift: `compatibility.parse_semver` (the install-compatibility gate,
|
||||
range-spec oriented).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -21,7 +21,6 @@ import pytest
|
||||
from packaging.version import parse as pkg_parse
|
||||
|
||||
from src.plugin_system.compatibility import is_update_available, parse_semver
|
||||
from src.skin_system.skin_runtime import _major
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
from web_interface.blueprints.api_v3 import _is_plugin_update_available
|
||||
|
||||
@@ -142,25 +141,6 @@ class TestStoreManagerUsesSharedComparator:
|
||||
reinstall.assert_not_called()
|
||||
|
||||
|
||||
class TestSkinRuntimeMajor:
|
||||
def test_plain_versions(self):
|
||||
assert _major("1.0.0") == 1
|
||||
assert _major("2.1") == 2
|
||||
|
||||
def test_int_input_tolerated(self):
|
||||
assert _major(2) == 2
|
||||
|
||||
def test_garbage_returns_none(self):
|
||||
assert _major("garbage") is None
|
||||
assert _major(None) is None
|
||||
|
||||
def test_v_prefix_not_tolerated(self):
|
||||
# Unlike parse_semver, _major does NOT strip a leading 'v' —
|
||||
# a skin.json declaring "v1.0.0" fails the API gate. Characterized
|
||||
# so a manifest-format loosening elsewhere doesn't silently diverge.
|
||||
assert _major("v1.0.0") is None
|
||||
|
||||
|
||||
class TestParseSemverAgreesWithPackaging:
|
||||
"""parse_semver and packaging must agree on ordering for plain X.Y.Z —
|
||||
the region where the two ecosystems overlap and must never diverge."""
|
||||
|
||||
Reference in New Issue
Block a user