mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 07:15:09 +00:00
169 lines
7.7 KiB
Python
169 lines
7.7 KiB
Python
"""GET /api/v3/display/modes -- the list of modes an on-demand request can name.
|
|
|
|
Anything driving the display from outside the web UI needs two things that no
|
|
existing endpoint gave it: the set of display modes, and which plugin owns each
|
|
one. /plugins/installed carries neither, so callers read every plugin's
|
|
manifest.json off disk and reimplemented PluginManager's own fallbacks.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
|
|
api_v3_client, api_v3_module,
|
|
)
|
|
|
|
|
|
MANIFESTS = {
|
|
'clock-simple': {'name': 'Simple Clock', 'display_modes': ['clock-simple']},
|
|
'football-scoreboard': {
|
|
'name': 'Football Scoreboard',
|
|
'display_modes': ['nfl_live', 'nfl_recent', 'nfl_upcoming'],
|
|
},
|
|
'ledmatrix-weather': {'name': 'Weather', 'display_modes': ['weather']},
|
|
}
|
|
|
|
CONFIG = {
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': {'enabled': True},
|
|
'ledmatrix-weather': {'enabled': False},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client(api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value=CONFIG)
|
|
return api_v3_client
|
|
|
|
|
|
def _modes(response):
|
|
return {m['mode']: m for m in response.get_json()['data']['modes']}
|
|
|
|
|
|
class TestTheModeListing:
|
|
def test_enabled_plugins_contribute_every_mode(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert set(modes) == {'clock-simple', 'nfl_live', 'nfl_recent', 'nfl_upcoming'}
|
|
|
|
def test_each_mode_names_its_plugin(self, client):
|
|
"""on-demand/start's find_plugin_for_mode fallback cannot see generated
|
|
modes, so the caller has to send plugin_id -- it must come from here."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['plugin_id'] == 'football-scoreboard'
|
|
|
|
def test_disabled_plugins_are_left_out_by_default(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert 'weather' not in modes
|
|
|
|
def test_disabled_plugins_can_be_asked_for(self, client):
|
|
"""They are still valid on-demand targets: the controller enables them
|
|
for the duration of the request."""
|
|
modes = _modes(client.get('/api/v3/display/modes?include_disabled=1'))
|
|
assert modes['weather']['enabled'] is False
|
|
|
|
def test_a_single_mode_plugin_is_labelled_with_its_own_name(self, client):
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['clock-simple']['name'] == 'Simple Clock'
|
|
|
|
def test_a_multi_mode_plugin_labels_each_mode_distinctly(self, client):
|
|
"""There is no per-mode name anywhere, and three modes all called
|
|
"Football Scoreboard" are not a usable dropdown."""
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['nfl_live']['name'] == 'nfl_live'
|
|
assert modes['nfl_live']['plugin_name'] == 'Football Scoreboard'
|
|
|
|
|
|
class TestItWorksForACallerThatNeverOpensTheDashboard:
|
|
def test_discovery_is_triggered(self, client, api_v3_module):
|
|
"""Discovery is lazy and normally runs because a person loaded the
|
|
dashboard; a bridge or script would otherwise get an empty list."""
|
|
client.get('/api/v3/display/modes')
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins.assert_called_once()
|
|
|
|
def test_no_plugin_manager_is_a_clean_error(self, api_v3_module, api_v3_client):
|
|
api_v3_module.api_v3.plugin_catalog = None
|
|
response = api_v3_client.get('/api/v3/display/modes')
|
|
assert response.status_code == 500
|
|
assert response.get_json()['status'] == 'error'
|
|
|
|
def test_a_plugin_with_no_declared_modes_still_appears(self, client, api_v3_module):
|
|
"""Its mode is its own id -- the same fallback the controller uses."""
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = {'starlark-apps': {'name': 'Starlark Apps', 'display_modes': []}}
|
|
pm.get_plugin_display_modes = MagicMock(return_value=[])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(
|
|
return_value={'starlark-apps': {'enabled': True}})
|
|
|
|
modes = _modes(client.get('/api/v3/display/modes'))
|
|
assert modes['starlark-apps']['plugin_id'] == 'starlark-apps'
|
|
|
|
|
|
class TestOneBadConfigSectionDoesNotBlankTheList:
|
|
"""config.json can hold a non-dict under a plugin id.
|
|
|
|
DisplayController guards the same shape, so it happens in practice. Here it
|
|
used to raise AttributeError mid-loop and answer 500 with no modes at all --
|
|
and the MQTT bridge builds every one of its entities from this list, so one
|
|
hand-edited section would empty the Home Assistant dropdown.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def client_with_bad_section(self, api_v3_module, api_v3_client):
|
|
pm = api_v3_module.api_v3.plugin_catalog
|
|
pm.plugin_manifests = MANIFESTS
|
|
pm.discover_plugins = MagicMock(return_value=list(MANIFESTS))
|
|
pm.get_plugin_display_modes = MagicMock(
|
|
side_effect=lambda pid: MANIFESTS[pid]['display_modes'])
|
|
api_v3_module.api_v3.config_manager.load_config = MagicMock(return_value={
|
|
'clock-simple': {'enabled': True},
|
|
'football-scoreboard': "true", # a string, not an object
|
|
'ledmatrix-weather': {'enabled': True},
|
|
})
|
|
return api_v3_client
|
|
|
|
def test_the_endpoint_still_answers(self, client_with_bad_section):
|
|
assert client_with_bad_section.get('/api/v3/display/modes').status_code == 200
|
|
|
|
def test_the_healthy_plugins_are_still_listed(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'clock-simple' in modes and 'weather' in modes
|
|
|
|
def test_the_bad_section_is_treated_as_disabled(self, client_with_bad_section):
|
|
modes = _modes(client_with_bad_section.get('/api/v3/display/modes'))
|
|
assert 'nfl_live' not in modes
|
|
|
|
def test_a_failure_is_reported_the_way_every_other_handler_reports_one(
|
|
self, api_v3_module, api_v3_client):
|
|
"""describe_exception, per test_web_error_detail's contract -- an
|
|
opaque "see logs for details" is what that test exists to prevent."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
side_effect=RuntimeError("disk is gone"))
|
|
resp = api_v3_client.get('/api/v3/display/modes')
|
|
assert resp.status_code == 500
|
|
assert 'disk is gone' in resp.get_json()['details']
|
|
|
|
def test_credentials_in_the_exception_are_redacted(self, api_v3_module, api_v3_client):
|
|
"""describe_exception is what makes returning detail safe."""
|
|
api_v3_module.api_v3.plugin_catalog.discover_plugins = MagicMock(
|
|
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'
|