mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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>
212 lines
9.3 KiB
Python
212 lines
9.3 KiB
Python
"""
|
|
Drift guard: three components independently answer "where is plugin X?" and
|
|
their answers must stay coherent — plus the `.standalone-backup-` naming
|
|
contract that install/rollback shares with discovery.
|
|
|
|
The three resolvers:
|
|
1. PluginManager._scan_directory_for_plugins — scans ONLY the configured dir.
|
|
2. PluginStoreManager._find_plugin_path — configured dir, then a sibling
|
|
`plugins/` fallback derived from the configured dir's parent.
|
|
3. SchemaManager.get_schema_path — configured dir, then project-root
|
|
`plugins/`, then `plugin-repos/`, then case-insensitive scans.
|
|
|
|
The divergence is characterized (a plugin visible to the store/schema
|
|
fallbacks but invisible to discovery is a real support-issue shape) so any
|
|
change to the fallback chains is a deliberate one.
|
|
|
|
The `.standalone-backup-` contract: store_manager renames a plugin dir aside
|
|
with that substring during install/rollback; discovery MUST skip such dirs
|
|
or a half-finished install would surface a ghost plugin. The substring is
|
|
duplicated as a literal in both files — this test breaks if either side
|
|
changes it unilaterally.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
from src.plugin_system.schema_manager import SchemaManager
|
|
from src.plugin_system.store_manager import PluginStoreManager
|
|
|
|
|
|
def _write_plugin(base: Path, plugin_id: str, dir_name: str = None):
|
|
plugin_dir = base / (dir_name or plugin_id)
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "manifest.json").write_text(json.dumps({
|
|
"id": plugin_id, "name": plugin_id, "version": "1.0.0",
|
|
}))
|
|
(plugin_dir / "config_schema.json").write_text(json.dumps({
|
|
"type": "object", "properties": {"enabled": {"type": "boolean"}},
|
|
}))
|
|
return plugin_dir
|
|
|
|
|
|
def _scanner():
|
|
"""A PluginManager stripped to just its discovery machinery — the full
|
|
constructor wires config/schema/health managers this test doesn't need."""
|
|
pm = object.__new__(PluginManager)
|
|
pm.logger = logging.getLogger("test_discovery_path_contract")
|
|
pm._discovery_lock = threading.Lock()
|
|
pm.plugin_manifests = {}
|
|
pm.plugin_directories = {}
|
|
return pm
|
|
|
|
|
|
class TestResolversAgreeOnConfiguredDir:
|
|
def test_all_three_find_a_plugin_in_the_configured_dir(self, tmp_path):
|
|
plugins_dir = tmp_path / "plugin-repos"
|
|
plugin_dir = _write_plugin(plugins_dir, "demo-plugin")
|
|
|
|
found = _scanner()._scan_directory_for_plugins(plugins_dir)
|
|
assert found == ["demo-plugin"]
|
|
|
|
store = PluginStoreManager(
|
|
plugins_dir=str(plugins_dir),
|
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
|
assert store._find_plugin_path("demo-plugin") == plugin_dir
|
|
|
|
schema = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
|
assert schema.get_schema_path("demo-plugin") == \
|
|
plugin_dir / "config_schema.json"
|
|
|
|
|
|
class TestFallbackDivergence:
|
|
def test_plugin_only_in_plugins_dir_fallback(self, tmp_path):
|
|
"""Characterized divergence: configured dir is plugin-repos/, but the
|
|
plugin sits in a sibling plugins/. The store and schema fallbacks
|
|
find it; discovery does NOT — so the plugin is installable/
|
|
configurable but never loads. Pinned so a change to any fallback
|
|
chain shows up here."""
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
legacy_dir = _write_plugin(tmp_path / "plugins", "legacy-plugin")
|
|
|
|
# Discovery: invisible.
|
|
assert _scanner()._scan_directory_for_plugins(configured) == []
|
|
|
|
# Store fallback: visible (parent-of-configured / 'plugins').
|
|
store = PluginStoreManager(
|
|
plugins_dir=str(configured),
|
|
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
|
|
assert store._find_plugin_path("legacy-plugin") == legacy_dir
|
|
|
|
# Schema fallback: visible (project_root / 'plugins').
|
|
schema = SchemaManager(plugins_dir=configured, project_root=tmp_path)
|
|
assert schema.get_schema_path("legacy-plugin") == \
|
|
legacy_dir / "config_schema.json"
|
|
|
|
def test_schema_manager_probes_plugins_before_plugin_repos(self, tmp_path):
|
|
# Documented order (also in CLAUDE.md): plugins/ wins over
|
|
# plugin-repos/ when the same id exists in both.
|
|
in_plugins = _write_plugin(tmp_path / "plugins", "dupe")
|
|
_write_plugin(tmp_path / "plugin-repos", "dupe")
|
|
schema = SchemaManager(plugins_dir=None, project_root=tmp_path)
|
|
assert schema.get_schema_path("dupe") == \
|
|
in_plugins / "config_schema.json"
|
|
|
|
def test_schema_manager_case_insensitive_fallback(self, tmp_path):
|
|
plugin_dir = _write_plugin(tmp_path / "plugins", "MyPlugin",
|
|
dir_name="MyPlugin")
|
|
schema = SchemaManager(plugins_dir=None, project_root=tmp_path)
|
|
assert schema.get_schema_path("myplugin") == \
|
|
plugin_dir / "config_schema.json"
|
|
|
|
|
|
class TestRegistryIdVersusDirectoryName:
|
|
"""install_plugin() renames a plugin's directory to the MANIFEST id when it
|
|
differs from the REGISTRY id, so `stocks` in the registry lands in
|
|
`ledmatrix-stocks/`. Every path lookup is by directory name, so
|
|
update_plugin("stocks") used to find nothing and report the plugin as not
|
|
installed -- silently. Four installed plugins hit this in practice
|
|
(leaderboard, music, stocks, weather): the user clicks update, nothing
|
|
happens, no error, and they stay on a stale version indefinitely.
|
|
"""
|
|
|
|
def test_resolves_a_plugin_whose_directory_name_differs_from_its_id(self, tmp_path):
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
plugin_dir = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("stocks") == plugin_dir
|
|
|
|
def test_direct_directory_hit_still_wins(self, tmp_path):
|
|
"""The manifest scan is a last resort. A directory named for the id
|
|
must still be preferred, so the two documented lookups above keep
|
|
their exact meaning."""
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
direct = _write_plugin(configured, "demo", dir_name="demo")
|
|
# A second directory whose manifest claims the same id.
|
|
_write_plugin(configured, "demo", dir_name="zz-other-demo")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("demo") == direct
|
|
|
|
def test_unknown_id_is_still_not_found(self, tmp_path):
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
_write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("no-such-plugin") is None
|
|
|
|
def test_half_finished_installs_are_not_resurrected(self, tmp_path):
|
|
"""A directory renamed aside during install/rollback carries a valid
|
|
manifest. Matching one would report a ghost plugin as installed."""
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
_write_plugin(configured, "ghost",
|
|
dir_name="ghost.standalone-backup-1234")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("ghost") is None
|
|
|
|
def test_unreadable_manifest_does_not_break_the_scan(self, tmp_path):
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
broken = configured / "broken-plugin"
|
|
broken.mkdir()
|
|
(broken / "manifest.json").write_text("{ not json")
|
|
wanted = _write_plugin(configured, "stocks", dir_name="ledmatrix-stocks")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("stocks") == wanted
|
|
|
|
def test_sibling_plugins_dir_is_searched_too(self, tmp_path):
|
|
configured = tmp_path / "plugin-repos"
|
|
configured.mkdir()
|
|
legacy = _write_plugin(tmp_path / "plugins", "weather",
|
|
dir_name="ledmatrix-weather")
|
|
|
|
store = PluginStoreManager(plugins_dir=str(configured))
|
|
assert store._find_plugin_path("weather") == legacy
|
|
|
|
|
|
class TestStandaloneBackupContract:
|
|
def test_discovery_skips_backup_dirs(self, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
_write_plugin(plugins_dir, "real-plugin")
|
|
# A rollback-in-progress dir with a valid manifest must NOT surface.
|
|
_write_plugin(plugins_dir, "real-plugin",
|
|
dir_name="real-plugin.standalone-backup-migrating")
|
|
|
|
found = _scanner()._scan_directory_for_plugins(plugins_dir)
|
|
assert found == ["real-plugin"]
|
|
|
|
def test_backup_substring_literal_matches_across_files(self):
|
|
"""The substring is duplicated in plugin_manager (skip check) and
|
|
store_manager (rename-aside names). If either side changes it, the
|
|
other silently stops honoring the contract — this test is the
|
|
tripwire."""
|
|
root = Path(__file__).resolve().parents[1]
|
|
pm_text = (root / "src/plugin_system/plugin_manager.py").read_text()
|
|
sm_text = (root / "src/plugin_system/store_manager.py").read_text()
|
|
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
|
assert ".standalone-backup-" in sm_text
|
|
|