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:
Chuck
2026-09-23 09:55:07 -04:00
co-authored by Claude Opus 5.5
parent 967f3a0567
commit 46be239ec3
32 changed files with 5 additions and 2694 deletions
-9
View File
@@ -671,15 +671,6 @@
"POST"
]
],
[
"/api/v3/skins",
"api_v3.list_skins",
[
"GET",
"HEAD",
"OPTIONS"
]
],
[
"/api/v3/starlark/apps",
"api_v3.get_starlark_apps",
-27
View File
@@ -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
-377
View File
@@ -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
-571
View File
@@ -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
+3 -23
View File
@@ -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."""