fix(plugins): address review -- no manifest fallback for live plugins, keep mode names whole, send registered spelling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-05 17:30:07 -04:00
co-authored by Claude Sonnet 5.5
parent 6dbec2e0b1
commit b440a27cdb
5 changed files with 39 additions and 2 deletions
+2
View File
@@ -244,6 +244,8 @@ class PluginCatalog:
if live and any(m.lower() == wanted for m in live): if live and any(m.lower() == wanted for m in live):
return plugin_id return plugin_id
for plugin_id, manifest in manifests.items(): for plugin_id, manifest in manifests.items():
if self._live_display_modes(plugin_id):
continue # the display's list is the truth for this plugin
modes = manifest.get('display_modes') modes = manifest.get('display_modes')
if isinstance(modes, list) and any( if isinstance(modes, list) and any(
isinstance(m, str) and m.lower() == wanted for m in modes): isinstance(m, str) and m.lower() == wanted for m in modes):
+3 -1
View File
@@ -161,7 +161,9 @@ def _published_modes(modes: Any) -> Optional[List[str]]:
"""The registered display modes as a snapshot carries them, or None.""" """The registered display modes as a snapshot carries them, or None."""
if not isinstance(modes, list): if not isinstance(modes, list):
return None return None
return [_clip(m, _ID_CHARS) for m in modes[:_MAX_MODES] if isinstance(m, str)] # A name is a key the display matches exactly: drop one too long to
# carry whole rather than clip it into a different name.
return [m for m in modes if isinstance(m, str) and len(m) <= _ID_CHARS][:_MAX_MODES]
def build_runtime_snapshot(state_manager: Any, *, started_at: float, def build_runtime_snapshot(state_manager: Any, *, started_at: float,
+12 -1
View File
@@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks.
""" """
import json import json
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList:
side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed")) side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed"))
body = api_v3_client.get('/api/v3/display/modes').get_json() body = api_v3_client.get('/api/v3/display/modes').get_json()
assert 'SEC123' not in json.dumps(body) assert 'SEC123' not in json.dumps(body)
class TestOnDemandUsesTheRegisteredSpelling:
def test_a_mode_differing_in_case_is_sent_as_registered(self, client):
with patch('web_interface.blueprints.api_v3.display._deliver_on_demand',
return_value=('socket', None)) as deliver:
response = client.post('/api/v3/display/on-demand/start',
json={'plugin_id': 'football-scoreboard',
'mode': 'NFL_LIVE', 'start_service': False})
assert response.status_code == 200, response.get_json()
assert deliver.call_args.args[0]['mode'] == 'nfl_live'
+13
View File
@@ -165,6 +165,12 @@ class TestSnapshotAndView:
published = snapshot["plugins"]["soccer-scoreboard"]["modes"] published = snapshot["plugins"]["soccer-scoreboard"]["modes"]
assert len(published) == rt._MAX_MODES assert len(published) == rt._MAX_MODES
def test_a_mode_name_is_kept_whole_or_dropped(self):
long_mode = "x" * (rt._ID_CHARS + 1)
snapshot = build_runtime_snapshot(_loaded_states(["ok", long_mode]),
started_at=1.0, now=self.NOW)
assert snapshot["plugins"]["soccer-scoreboard"]["modes"] == ["ok"]
def test_non_strings_from_a_hand_made_snapshot_are_dropped(self): def test_non_strings_from_a_hand_made_snapshot_are_dropped(self):
snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True, snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True,
"published_at": self.NOW, "plugins": { "published_at": self.NOW, "plugins": {
@@ -202,6 +208,13 @@ class TestCatalog:
assert catalog.get_plugin_display_modes("clock-simple") == ["clock"] assert catalog.get_plugin_display_modes("clock-simple") == ["clock"]
assert catalog.find_plugin_for_mode("clock") == "clock-simple" assert catalog.find_plugin_for_mode("clock") == "clock-simple"
def test_a_mode_the_display_dropped_does_not_resolve_by_manifest(
self, plugins_dir, shared_cache):
display_cache, web_cache = shared_cache
PluginRuntimePublisher(display_cache, _loaded_states(CUSTOM)).tick()
catalog = self._catalog(plugins_dir, web_cache)
assert catalog.find_plugin_for_mode("soccer_eng.1_live") is None
def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache): def test_a_stopped_display_falls_back_to_manifests(self, plugins_dir, shared_cache):
display_cache, web_cache = shared_cache display_cache, web_cache = shared_cache
publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)) publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED))
@@ -279,6 +279,15 @@ def start_on_demand_display():
if not resolved_plugin: if not resolved_plugin:
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404 return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
# The display matches mode names exactly: pass the registered spelling
# when the caller's differs only in case.
if api_v3.plugin_catalog and resolved_plugin and resolved_mode:
wanted = resolved_mode.strip().lower()
for registered in api_v3.plugin_catalog.get_plugin_display_modes(resolved_plugin):
if isinstance(registered, str) and registered.lower() == wanted:
resolved_mode = registered
break
# On-demand works with disabled plugins: the running display loads one # On-demand works with disabled plugins: the running display loads one
# for the session and unloads it afterwards, leaving config.json alone # for the session and unloads it afterwards, leaving config.json alone
# (DisplayController._load_plugin_for_on_demand). Logged for debugging. # (DisplayController._load_plugin_for_on_demand). Logged for debugging.