mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-07 11:48:06 +00:00
Fixes the three suspected bugs this PR's characterization tests pinned, flipping those tests to assert the corrected behavior: - plugins/store: ONE shared update comparator. New compatibility.is_update_available() (PEP 440 via packaging) is now used by both the web UI's update badge (api_v3._is_plugin_update_available is a thin alias) and store_manager.update_plugin's reinstall decision. Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0' triggered a full reinstall the UI called unnecessary, and a locally- ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED. Now equivalent spellings skip the reinstall and locally-ahead versions are never downgraded; unparseable versions still reconcile by reinstalling from the registry. - config: save_config and save_config_atomic now refuse (ConfigError) when config_secrets.json exists but cannot be loaded. Both previously proceeded without stripping, writing the merged secrets into config.json in plaintext. The shared _load_secrets_for_save() helper raises with an actionable message instead; a missing secrets file is still fine (nothing to strip), and _migrate_config's catch-all keeps boot resilient. - skins: render_skin_card resets _skin_failures on both success paths (vegas card returned, or mode renderer handled), mirroring _render_game. Transient card failures no longer accumulate across a session until they permanently disable a working skin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
572 lines
23 KiB
Python
572 lines
23 KiB
Python
"""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
|