mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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> * 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>
156 lines
6.7 KiB
Python
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})"
|