fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success

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
This commit is contained in:
Claude
2026-08-07 00:44:33 +00:00
parent dc643b4369
commit 206eca078e
8 changed files with 200 additions and 124 deletions
+26 -16
View File
@@ -455,10 +455,8 @@ class TestExampleSkin:
class TestRenderSkinCard:
"""render_skin_card (vegas cards) shares _render_game's 3-strike counter.
The asymmetry pinned here: _render_game resets the counter on success,
render_skin_card does NOT — card successes never clear strikes, so
failures accumulated across card renders (however far apart) still
disable the skin for the session.
Both paths reset the counter on success — transient failures must not
accumulate across a session and disable a working skin.
"""
def _probe(self, skin):
@@ -518,11 +516,10 @@ class TestRenderSkinCard:
assert probe.builtin_calls == 1
assert BrokenCardSkin.calls == 3 # not consulted again
def test_card_success_does_not_reset_strikes(self):
"""Characterized asymmetry: unlike _render_game (which resets the
counter on success, core.py _render_game), a successful card render
leaves accumulated strikes in place — 2 failures + N successes + 1
failure still disables the skin."""
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):
@@ -540,17 +537,30 @@ class TestRenderSkinCard:
assert probe._skin_failures == 2
FlakyCardSkin.fail = False
for _ in range(10):
assert probe.render_skin_card({}, (96, 32)) is card_img
assert probe._skin_failures == 2 # successes did NOT clear strikes
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 == 3
assert probe.render_skin_card({}, (96, 32)) is None # disabled
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_render_game_success_does_reset_strikes(self):
"""The other half of the asymmetry, for contrast with the above."""
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