Files
LEDMatrix/test/test_version_comparison_consistency.py
ChuckandClaude Opus 5.5 61e462c635 refactor: remove the skin system and the unused src/base_classes package (#615)
* 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>

* fix(config): drop retired skin/skin_options keys instead of validating them

A config.json written while the skin system existed can carry skin and
skin_options in any plugin section, and most plugin schemas set
additionalProperties: false. They are no longer core plugin properties;
RETIRED_PLUGIN_KEYS in schema_manager lists them and
drop_retired_plugin_keys removes them (unless the plugin's own schema
declares the name) in prepare_plugin_config, which loading, hot reload,
GET /plugins/config and both web saves already share, and in
validate_config_against_schema for callers that validate a raw section.
POST /plugins/config and /config/main also drop them from the stored
section they merge into, so they leave config.json on the next save.

Tests cover the load path (real PluginManager.load_plugin: no schema
warning, not degraded), raw and prepared validation,
validate_all_plugin_configs, and the JSON, form and /config/main saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: remove the unused src/base_classes package

No scoreboard plugin builds on src.base_classes: the nine monorepo
scoreboards ship their own sports.py and share code through src/common
(docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins
imports it. The one import anywhere, baseball-scoreboard's
rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing
instantiates.

Removed the package and the eight test files that only tested it
(test_api_extractors, test_data_sources, test_sports_base_characterization,
test_sports_capabilities, test_sports_core_promotions,
test_sports_logo_cache_bounded, test_sports_modes_promotions,
test_sports_odds_fanout). test_common_is_hardware_free no longer lists
src.base_classes as a forbidden import, and comments in sports_helpers.py
and base_odds_manager.py stop pointing at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: drop the skin system and src/base_classes from the docs

Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to
them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins
section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the
term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was
removed and shared code lives in src/common, in the Layering section and
the view-model-contract rule. Other docs stop pointing at the removed
package. CHANGELOG records both removals under Unreleased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): hide and refuse registry entries that aren't plugins

The skin filters went with the skin system, but a custom registry can still
list "type": "skin" entries, and installing one as a plugin would unpack it
into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing
type means plugin) now hides non-plugin entries from the store and
custom-registry listings, and install refuses them, in the route with a
clear 400 and in _install_plugin_impl for any other caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:33:24 -04:00

156 lines
6.7 KiB
Python

"""
Drift guard for version comparison.
There is now ONE shared "should this plugin update?" comparator —
`src.plugin_system.compatibility.is_update_available` — used by both the web
UI's update badge (`api_v3._is_plugin_update_available`) and the store's
`update_plugin` reinstall decision, so the badge and the actual reinstall
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.)
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
from unittest.mock import patch
import pytest
from packaging.version import parse as pkg_parse
from src.plugin_system.compatibility import is_update_available, parse_semver
from src.plugin_system.store_manager import PluginStoreManager
from web_interface.blueprints.api_v3 import _is_plugin_update_available
# (installed, registry) -> update available?
CASES = [
(("1.2.0", "1.2.0"), False), # identical
(("v1.2.0", "1.2.0"), False), # cosmetic v-prefix, semantically equal
(("1.2", "1.2.0"), False), # short form, semantically equal
(("1.2.0", "1.2.0-rc1"), False), # rc of same release is not newer
(("1.2.0", "1.3.0"), True), # registry genuinely newer
(("2.0.0", "1.9.0"), False), # locally ahead — never downgrade
(("abc.def", "1.0.0"), True), # unparseable — surface the mismatch
(("", "1.0.0"), False), # missing either side — nothing to do
(("1.0.0", ""), False),
]
class TestSharedComparatorMalformedInputs:
def test_truthy_non_string_surfaces_mismatch(self):
# A malformed manifest can carry version as a number; packaging would
# raise TypeError on it. The comparator must not raise.
assert is_update_available(1.2, "1.2.0") is True
assert is_update_available("1.2.0", 1.3) is True
def test_falsy_non_string_means_nothing_to_do(self):
assert is_update_available(None, "1.0.0") is False
assert is_update_available("1.0.0", None) is False
assert is_update_available(0, "1.0.0") is False
class TestSharedComparator:
@pytest.mark.parametrize("pair,expected", CASES)
def test_is_update_available(self, pair, expected):
installed, latest = pair
assert is_update_available(installed, latest) is expected
@pytest.mark.parametrize("pair,expected", CASES)
def test_api_v3_helper_agrees(self, pair, expected):
# The UI badge helper must be a pure alias of the shared comparator.
installed, latest = pair
assert _is_plugin_update_available(installed, latest) is expected
class TestStoreManagerUsesSharedComparator:
"""Drive update_plugin's real code path to its version check."""
def _store(self, tmp_path, local_version, registry_version):
plugin_dir = tmp_path / "plugins" / "demo-plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "demo-plugin", "version": local_version,
}))
store = PluginStoreManager(
plugins_dir=str(tmp_path / "plugins"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"),
)
registry_info = {
"id": "demo-plugin",
"repo": "https://github.com/example/ledmatrix-plugins",
"latest_version": registry_version,
}
return store, registry_info
def _run_update(self, store, registry_info):
with patch.object(store, "fetch_registry", return_value={"plugins": [registry_info]}), \
patch.object(store, "get_plugin_info", return_value=registry_info), \
patch.object(store, "_reinstall_with_rollback", return_value=True) as reinstall:
result = store.update_plugin("demo-plugin")
return result, reinstall
def test_equal_strings_skip_reinstall(self, tmp_path):
store, info = self._store(tmp_path, "1.2.0", "1.2.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_v_prefix_equivalent_skips_reinstall(self, tmp_path):
# "v1.2.0" == "1.2.0" semantically — no pointless reinstall.
store, info = self._store(tmp_path, "v1.2.0", "1.2.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_locally_ahead_version_is_never_downgraded(self, tmp_path):
# A plugin ahead of the registry (local dev build) must not be
# "updated" — that would be a downgrade.
store, info = self._store(tmp_path, "2.0.0", "1.9.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_registry_newer_triggers_reinstall(self, tmp_path):
store, info = self._store(tmp_path, "1.2.0", "1.3.0")
result, reinstall = self._run_update(store, info)
reinstall.assert_called_once()
assert result is True
def test_unparseable_version_surfaces_via_reinstall(self, tmp_path):
# Direction unknowable → reconcile by reinstalling from the registry.
store, info = self._store(tmp_path, "abc.def", "1.0.0")
result, reinstall = self._run_update(store, info)
reinstall.assert_called_once()
assert result is True
def test_empty_local_version_follows_comparator_no_reinstall(self, tmp_path):
# The comparator says "nothing to do" for a missing version, and the
# store must agree with the UI badge — no reinstall.
store, info = self._store(tmp_path, "", "1.0.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_empty_registry_version_follows_comparator_no_reinstall(self, tmp_path):
store, info = self._store(tmp_path, "1.0.0", "")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
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."""
PLAIN = ["0.1.0", "1.0.0", "1.2.0", "1.2.3", "1.10.0", "2.0.0", "10.0.1"]
def test_pairwise_ordering_matches(self):
for a in self.PLAIN:
for b in self.PLAIN:
ours = parse_semver(a) < parse_semver(b)
theirs = pkg_parse(a) < pkg_parse(b)
assert ours == theirs, f"ordering diverges on ({a}, {b})"