diff --git a/src/plugin_system/plugin_catalog.py b/src/plugin_system/plugin_catalog.py index 9483630d..bd80f2ab 100644 --- a/src/plugin_system/plugin_catalog.py +++ b/src/plugin_system/plugin_catalog.py @@ -244,6 +244,8 @@ class PluginCatalog: if live and any(m.lower() == wanted for m in live): return plugin_id 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') if isinstance(modes, list) and any( isinstance(m, str) and m.lower() == wanted for m in modes): diff --git a/src/plugin_system/plugin_runtime.py b/src/plugin_system/plugin_runtime.py index 643de58c..25f79468 100644 --- a/src/plugin_system/plugin_runtime.py +++ b/src/plugin_system/plugin_runtime.py @@ -161,7 +161,9 @@ def _published_modes(modes: Any) -> Optional[List[str]]: """The registered display modes as a snapshot carries them, or None.""" if not isinstance(modes, list): 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, diff --git a/test/test_api_v3_display_modes.py b/test/test_api_v3_display_modes.py index a59913f0..5258c043 100644 --- a/test/test_api_v3_display_modes.py +++ b/test/test_api_v3_display_modes.py @@ -7,7 +7,7 @@ manifest.json off disk and reimplemented PluginManager's own fallbacks. """ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -155,3 +155,14 @@ class TestOneBadConfigSectionDoesNotBlankTheList: side_effect=RuntimeError("GET https://x/y?api_key=SEC123 failed")) body = api_v3_client.get('/api/v3/display/modes').get_json() 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' diff --git a/test/test_live_display_modes.py b/test/test_live_display_modes.py index fb09f091..05e695fe 100644 --- a/test/test_live_display_modes.py +++ b/test/test_live_display_modes.py @@ -165,6 +165,12 @@ class TestSnapshotAndView: published = snapshot["plugins"]["soccer-scoreboard"]["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): snapshot = {"schema": rt.SNAPSHOT_SCHEMA, "running": True, "published_at": self.NOW, "plugins": { @@ -202,6 +208,13 @@ class TestCatalog: assert catalog.get_plugin_display_modes("clock-simple") == ["clock"] 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): display_cache, web_cache = shared_cache publisher = PluginRuntimePublisher(display_cache, _loaded_states(REGISTERED)) diff --git a/web_interface/blueprints/api_v3/display.py b/web_interface/blueprints/api_v3/display.py index a071d1c8..74652433 100644 --- a/web_interface/blueprints/api_v3/display.py +++ b/web_interface/blueprints/api_v3/display.py @@ -279,6 +279,15 @@ def start_on_demand_display(): if not resolved_plugin: 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 # for the session and unloads it afterwards, leaving config.json alone # (DisplayController._load_plugin_for_on_demand). Logged for debugging.