fix: settings the display ignored, a memory leak, and the plugin card handler (#605)

* fix(errors): stop affected_plugins growing without bound

Each repeat of an error pattern appended every plugin in the time window to
the pattern's list again, so a plugin failing in a loop grew the display
process's memory without limit: 3,000 errors from three plugins reached 2.5
million entries. Keep the list unique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): load a BDF font at its native size instead of PIL's default

FreeType rejects any size but a BDF strike's own, and FontManager answered
that with ImageFont.load_default() -- a different typeface -- so 5x7.bdf
requested at 8 or 10px rendered as PIL's default font. Retry at the native
strike, as element_style already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): plugin toggle failures no longer claim "operation in progress"

Every exception in POST /plugins/toggle was mapped to
PLUGIN_OPERATION_CONFLICT, so any failure told the user "A plugin operation
is already in progress". Report the failure as what it is, and record the
plugin id in the operation history for form posts too (it read a `data`
variable that only the JSON path set).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): route plugin card clicks through handlePluginAction

The document-level delegation checked `typeof handlePluginAction`, which is
scoped inside the plugin-manager IIFE and so never visible to it. Every card
click took a copied fallback that stopped propagation (the grid's own
listener never ran), confirmed an uninstall twice, and sent Starlark app
uninstalls to POST /plugins/uninstall instead of DELETE /starlark/apps/<id>.
Expose the handler on window and delegate to it.

Also run every test/js/unit suite under pytest: they need only node, but CI
ran one of the eight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): apply Rotation durations, WiFi messages and Vegas settings

Three settings the web UI saves never reached the display:

- Rotation & Durations: display.display_durations was never read. Every
  plugin inherits get_display_duration() and the plugin was asked first. A
  saved value now wins. The page shows unsaved screens blank with the
  plugin's own duration as a placeholder, and saving a blank removes the
  override, so one save no longer pins every screen.
- WiFi status overlay: the controller looked for wifi_status.json one
  directory above the repo. Both sides now use
  wifi_manager.get_wifi_status_path(). The message is written by rename so
  the display never reads it half-written, and the resumed plugin redraws the
  whole panel afterwards.
- Vegas: nothing called coordinator.update_config(), so saved Vegas settings
  never reached a running scroll. They are now queued when
  display.vegas_scroll changes, and applied while Vegas is stopped too, so a
  disable then re-enable works. The follower's scroll-speed default (75) now
  matches VegasModeConfig's (50).

Also throttles Vegas's per-frame live-priority scan to 4Hz. It cost 139us
per frame on a Pi 4 with two scoreboards (1.7% of a 125fps frame) and grows
with each plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep affected_plugins order when serialized; guard non-Element targets

ErrorPattern.to_dict() ran the now-ordered list through set(), so
get_error_summary() listed plugins in an unstable order. The document-level
card-action listener called event.target.closest() without checking the
target is an Element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 10:29:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0903f9055f
commit 342e9164b8
23 changed files with 645 additions and 183 deletions
+3 -1
View File
@@ -15,7 +15,8 @@ npm install # jsdom, for the DOM suites only
node run_all.js
```
The unit suites need nothing but node. The DOM suites additionally need a
The unit suites need nothing but node; `test/test_js_unit_suites.py` runs every
`unit/*.js` under pytest, so CI covers them. The DOM suites additionally need a
running web interface, because they test against the **real** server-rendered
HTML and the **real** API rather than fixtures:
@@ -41,6 +42,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
+2 -1
View File
@@ -18,7 +18,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
@@ -0,0 +1,127 @@
// Installed-plugin card actions go through handlePluginAction exactly once.
//
// The document-level delegation in plugins_manager.js tested
// `typeof handlePluginAction`, which lives inside the plugin-manager IIFE and
// so was never visible to it. Every click took a copied fallback instead,
// which stopped propagation (the grid's own listener never ran), asked to
// confirm an uninstall twice, and sent Starlark app uninstalls to the plugin
// endpoint instead of DELETE /starlark/apps/<id>.
//
// Runs the shipped global delegation and handlePluginAction, sliced out of
// plugins_manager.js, against a minimal fake DOM.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
function slice(startMarker, endMarker) {
const a = SRC.indexOf(startMarker);
const b = SRC.indexOf(endMarker, a);
if (a < 0 || b < 0) throw new Error(`could not find ${startMarker} .. ${endMarker}`);
return SRC.slice(a, b);
}
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function setup() {
const listeners = {};
const calls = { confirm: 0, fetch: [], uninstallPlugin: [], togglePlugin: [] };
const window = {
installedPlugins: [{ id: 'clock', enabled: false }],
uninstallPlugin: id => calls.uninstallPlugin.push(id),
togglePlugin: (id, on) => calls.togglePlugin.push([id, on]),
};
const ctx = {
window,
document: {
addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push(fn); },
},
confirm: () => { calls.confirm++; return true; },
fetch: (url, opts) => { calls.fetch.push([url, opts && opts.method]); return new Promise(() => {}); },
alert: () => {},
console,
setTimeout,
debugLog: () => {},
getInstalledFilter: () => null,
};
vm.createContext(ctx);
// The handler lives inside the plugin-manager IIFE in the real file, so it
// runs in one here too: the global delegation must not see it by name.
vm.runInContext(GLOBAL_DELEGATION + '\n(function() {\n' + HANDLER + '\n})();', ctx);
const click = (action, pluginId) => {
const el = {
getAttribute: name => ({ 'data-action': action, 'data-plugin-id': pluginId })[name],
type: 'button',
};
let stopped = false;
const event = {
type: 'click',
target: { closest: () => el },
preventDefault() {},
stopPropagation() { stopped = true; },
};
for (const fn of listeners.click || []) fn(event);
return stopped;
};
return { window, calls, click, listeners };
}
(async () => {
console.log('\n-- plugin card action delegation --');
{
const t = setup();
ok('handlePluginAction is exposed on window', typeof t.window.handlePluginAction === 'function');
ok('document-level click listener registered', (t.listeners.click || []).length === 1);
}
{
// A non-Element target (a text node, the document) has no closest().
const t = setup();
let threw = null;
try { for (const fn of t.listeners.click) fn({ type: 'click', target: {} }); } catch (e) { threw = e; }
ok('non-Element event target is ignored', threw === null, threw && threw.message);
}
{
const t = setup();
t.click('uninstall', 'starlark:analogclock');
ok('Starlark uninstall confirms once', t.calls.confirm === 1, t.calls.confirm);
ok('Starlark uninstall hits DELETE /starlark/apps/<id>',
t.calls.fetch.length === 1 && t.calls.fetch[0][0] === '/api/v3/starlark/apps/analogclock'
&& t.calls.fetch[0][1] === 'DELETE', t.calls.fetch);
ok('Starlark uninstall does not go to the plugin uninstaller', t.calls.uninstallPlugin.length === 0);
}
{
const t = setup();
t.click('uninstall', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('plugin uninstall: handler itself does not confirm (uninstallPlugin does)', t.calls.confirm === 0,
t.calls.confirm);
ok('plugin uninstall calls uninstallPlugin once', t.calls.uninstallPlugin.length === 1
&& t.calls.uninstallPlugin[0] === 'clock', t.calls.uninstallPlugin);
}
{
const t = setup();
const stopped = t.click('toggle', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('toggle flips the stored state', JSON.stringify(t.calls.togglePlugin) === '[["clock",true]]',
t.calls.togglePlugin);
ok('handled once (propagation stopped)', stopped === true);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})();
+13
View File
@@ -210,6 +210,19 @@ class TestVegasCycleDurations:
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations']['clock_duration'] == 45
def test_per_mode_duration_saves_and_blank_clears_it(self, api_v3_client, saved, api_v3_module):
# The Rotation page leaves a mode blank to mean "the plugin's own
# duration"; a saved value overrides the plugin, so blank must remove
# it rather than 400 or pin a number.
stored = copy.deepcopy(STORED)
stored['display']['display_durations'] = {'weather_current': 40, 'clock': 20}
api_v3_module.api_v3.config_manager.load_config.side_effect = lambda *a, **k: copy.deepcopy(stored)
resp = _post_json(api_v3_client, {'__form_section': 'durations',
'duration__clock': '45',
'duration__weather_current': ''})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations'] == {'clock': 45}
class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture
+29
View File
@@ -0,0 +1,29 @@
"""POST /plugins/toggle reports what actually went wrong.
Every failure used to be mapped to PLUGIN_OPERATION_CONFLICT, so the user was
told "A plugin operation is already in progress" when, say, the config could
not be read.
"""
import json
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
def test_failure_is_not_reported_as_a_conflict(api_v3_client, api_v3_module, monkeypatch):
monkeypatch.setattr(api_v3_module, '_discovered_plugin_manifests',
lambda *a, **k: {'clock': {}})
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError('disk gone')
resp = api_v3_client.post('/api/v3/plugins/toggle',
data=json.dumps({'plugin_id': 'clock', 'enabled': True}),
content_type='application/json')
assert resp.status_code == 500
body = resp.get_json()
assert body['error_code'] != 'PLUGIN_OPERATION_CONFLICT'
assert 'already in progress' not in body['message']
assert body['message'] == 'Failed to enable plugin clock'
history = api_v3_module.api_v3.operation_history.record_operation
history.assert_called_once()
assert history.call_args.kwargs['plugin_id'] == 'clock'
@@ -110,12 +110,14 @@ class TestCachedConfigValues:
assert controller._normal_brightness == expected
def test_scroll_speed_cached(self, controller):
"""_scroll_speed must equal what the config says."""
"""_scroll_speed must equal what the config says, defaulting to Vegas's
own default so a follower dead-reckons at the leader's speed."""
from src.vegas_mode.config import VegasModeConfig
expected = (
controller.config
.get("display", {})
.get("vegas_scroll", {})
.get("scroll_speed", 75)
.get("scroll_speed", VegasModeConfig.from_config({}).scroll_speed)
)
assert controller._scroll_speed == expected
+121
View File
@@ -0,0 +1,121 @@
"""DisplayController settings that the web UI saves but the display ignored.
- Rotation & Durations: display.display_durations was never read, because
every plugin inherits get_display_duration() and the plugin was asked first.
- WiFi status overlay: the controller looked for wifi_status.json one level
above the repo, so WiFiManager's messages never reached the panel.
- Vegas: settings saved in the web UI never reached the running coordinator,
and the follower's scroll-speed default (75) disagreed with Vegas's (50).
"""
import os
import threading
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(config=None, plugin_modes=None):
dc = object.__new__(DisplayController)
dc.config = config or {}
dc.plugin_modes = plugin_modes or {}
return dc
def _plugin(duration):
plugin = MagicMock()
plugin.get_display_duration.return_value = duration
return plugin
class TestDisplayDuration:
def test_saved_duration_overrides_the_plugin(self):
dc = _controller({'display': {'display_durations': {'clock': 45}}},
{'clock': _plugin(15.0)})
assert dc._get_display_duration('clock') == 45.0
def test_unsaved_mode_uses_the_plugin_duration(self):
dc = _controller({'display': {'display_durations': {'other': 45}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0
def test_invalid_saved_values_fall_back_to_the_plugin(self):
for bad in (0, -5, True, '30', None):
dc = _controller({'display': {'display_durations': {'clock': bad}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0, bad
def test_unknown_mode_without_a_plugin_gets_the_default(self):
assert _controller()._get_display_duration('nothing') == 30
def test_hot_reloaded_config_is_used(self):
dc = _controller({}, {'clock': _plugin(15.0)})
dc._refresh_config_cache({'display': {'display_durations': {'clock': 60}}})
assert dc._get_display_duration('clock') == 60.0
class TestWifiStatusPath:
def test_reader_and_writer_agree(self, test_display_controller):
from src.wifi_manager import get_wifi_status_path
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = get_wifi_status_path()
assert path.name == 'wifi_status.json'
# Inside this checkout's config/, not the directory above the repo.
assert os.path.normcase(str(path.parent.parent)) == os.path.normcase(repo)
assert test_display_controller.wifi_status_file == path
def test_status_message_is_written_whole(self, tmp_path, monkeypatch):
import json
import src.wifi_manager as wm
target = tmp_path / 'wifi_status.json'
monkeypatch.setattr(wm, 'LED_STATUS_FILE', target)
# The display polls this file and deletes one it can't parse, so it
# must appear by rename, never be seen half-written.
renames = []
real_replace = os.replace
monkeypatch.setattr(wm.os, 'replace',
lambda src, dst: (renames.append(dst), real_replace(src, dst)))
manager = object.__new__(wm.WiFiManager)
manager._show_led_message('Connecting to home...', duration=10)
assert json.loads(target.read_text())['message'] == 'Connecting to home...'
assert renames == [target]
assert not (tmp_path / 'wifi_status.json.tmp').exists()
class TestVegasSettings:
def _controller(self):
dc = _controller({'display': {'vegas_scroll': {'scroll_speed': 40}}})
dc._reconcile_flag_lock = threading.Lock()
dc._pending_plugin_reconcile = False
dc._enabled_set_changed = lambda old, new: False
dc._enabled_plugin_not_running = lambda new: False
dc.vegas_coordinator = MagicMock()
return dc
def test_changed_vegas_settings_reach_the_coordinator(self):
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 80}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_called_once_with(new)
assert dc._scroll_speed == 80.0
def test_unrelated_saves_leave_vegas_alone(self):
# Applying a Vegas config rebuilds the strip, so only do it on change.
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 50}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 90}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_not_called()
def test_scroll_speed_default_matches_vegas_config(self):
from src.vegas_mode.config import VegasModeConfig
assert DisplayController._vegas_scroll_speed({}) == VegasModeConfig.from_config({}).scroll_speed
def test_stopped_coordinator_applies_queued_config(self):
dc = self._controller()
dc.on_demand_active = False
dc._is_vegas_mode_active()
dc.vegas_coordinator.apply_pending_config_if_idle.assert_called_once()
+10
View File
@@ -154,6 +154,16 @@ class TestPatternDetection:
assert "ValueError" not in aggregator._patterns
def test_affected_plugins_stays_bounded(self):
"""Each repeat used to append every plugin in the window again, so a
plugin failing in a loop grew this list without limit (3,000 errors
from three plugins reached 2.5 million entries)."""
aggregator = ErrorAggregator(pattern_threshold=2, pattern_window_minutes=60)
for i in range(300):
aggregator.record_error(error=ValueError("loop"), plugin_id=f"p{i % 3}")
assert aggregator._patterns["ValueError"].affected_plugins == ["p0", "p1", "p2"]
assert aggregator._patterns["ValueError"].to_dict()["affected_plugins"] == ["p0", "p1", "p2"]
def test_pattern_severity_increases_with_count(self):
"""Pattern severity should increase with more occurrences."""
aggregator = ErrorAggregator(
+8
View File
@@ -45,6 +45,14 @@ class TestGetFont:
font = fm.get_font("five_by_seven", 7)
assert isinstance(font, freetype.Face)
def test_bdf_at_a_size_it_lacks_uses_its_native_strike(self, fm):
# FreeType rejects any size but the strike's own. This used to hand
# back PIL's default font, a different typeface, for 5x7 at 8 or 10.
for size in (8, 10):
font = fm.get_font("five_by_seven", size)
assert isinstance(font, freetype.Face), size
assert font.size.y_ppem == 7
def test_repeat_call_returns_cached_identity(self, fm):
first = fm.get_font("press_start", 8)
hits_before = fm.performance_stats["cache_hits"]
+27
View File
@@ -0,0 +1,27 @@
"""Run every web-interface JS unit suite (test/js/unit/*.js) under pytest.
They need nothing but node, but CI ran only one of them, from
test/web_interface/test_update_all_plugins.py. The DOM suites need jsdom and
a running server, so they stay with test/js/run_all.js.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
UNIT_DIR = Path(__file__).resolve().parent / 'js' / 'unit'
SUITES = sorted(UNIT_DIR.glob('test_*.js'))
def test_suites_found():
assert SUITES, f"no JS unit suites under {UNIT_DIR}"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
@pytest.mark.parametrize('suite', SUITES, ids=[s.name for s in SUITES])
def test_js_unit_suite(suite):
result = subprocess.run([shutil.which('node'), str(suite)], capture_output=True,
text=True, timeout=120, cwd=str(UNIT_DIR.parent))
assert result.returncode == 0, result.stdout + result.stderr
+92
View File
@@ -0,0 +1,92 @@
"""VegasModeCoordinator: settings updates and the per-frame live check.
- Settings saved in the web UI are queued with update_config() and applied
between frames. run_frame() returns early once Vegas has stopped, so a
disable followed by a re-enable used to be queued forever.
- The live-priority scan asks every plugin mode whether it is live. It ran on
every frame (125fps); it now runs at most every _LIVE_PRIORITY_CHECK_INTERVAL.
"""
import threading
from unittest.mock import MagicMock
from src.vegas_mode import coordinator as coordinator_module
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(active=True):
# Built without __init__ so no display, stream or render stack is needed.
c = VegasModeCoordinator.__new__(VegasModeCoordinator)
c.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {'enabled': True}}})
c.render_pipeline = MagicMock()
c.render_pipeline.has_deferred.return_value = False
c.render_pipeline.needs_extension.return_value = False
c.render_pipeline.is_cycle_complete.return_value = False
c.stream_manager = MagicMock()
c.plugin_adapter = MagicMock()
c.stats = {'cycles_completed': 0, 'config_updates': 0}
c._state_lock = threading.Lock()
c._is_active = active
c._is_paused = False
c._should_stop = False
c._pending_config_update = False
c._pending_config = None
c._config_version = 0
c._live_priority_check = None
c._live_priority_active = False
c._interrupt_check = None
c.sync_manager = None
return c
class TestConfigWhileStopped:
def test_queued_config_applies_while_stopped(self):
c = _coordinator(active=False)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.start = MagicMock()
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed == 90
assert c._pending_config_update is False
def test_reenabling_after_a_disable_takes_effect(self):
c = _coordinator(active=False)
c.vegas_config = VegasModeConfig.from_config({}) # disabled
c.start = MagicMock()
c.update_config({'display': {'vegas_scroll': {'enabled': True}}})
c.apply_pending_config_if_idle()
assert c.is_enabled
c.start.assert_called_once()
def test_running_coordinator_waits_for_the_next_frame(self):
c = _coordinator(active=True)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed != 90
assert c._pending_config_update is True
class TestLivePriorityThrottle:
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
c.run_frame()
assert c._live_priority_check.call_count == 1
now[0] += coordinator_module._LIVE_PRIORITY_CHECK_INTERVAL
c.run_frame()
assert c._live_priority_check.call_count == 2
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
c.pause.assert_called_once()
c.render_pipeline.render_frame.assert_not_called()
+22
View File
@@ -186,6 +186,28 @@ def test_durations_page_groups_by_plugin(client):
assert "Other saved entries" in body
def test_durations_page_leaves_unsaved_modes_blank(client):
"""A saved duration overrides the plugin's own, so the page must not
pre-fill one for every mode: the first save would pin them all. Unsaved
modes are blank, with the plugin's duration as the placeholder."""
import copy
from web_interface.blueprints import pages_v3 as pv
config = copy.deepcopy(SMOKE_CONFIG)
config["clock"]["display_duration"] = 20
config["display"]["display_durations"] = {"weather_current": 40}
pv.pages_v3.config_manager.load_config.return_value = config
body = client.get("/partials/durations").get_data(as_text=True)
def field(mode):
start = body.index(f'id="duration__{mode}"')
return body[start:body.index(">", start)]
assert 'value=""' in field("clock") and 'placeholder="20"' in field("clock")
assert 'value="40"' in field("weather_current")
assert 'value=""' in field("weather_daily")
assert 'placeholder="15"' in field("weather_daily")
def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"')