mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
Merge origin/main into claude/deprecate-unused-plugin-api
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -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
@@ -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);
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,65 @@
|
||||
"""retry() in scripts/install/one-shot-install.sh retries, and reports failure.
|
||||
|
||||
It used `if ! "$@"; then status=$?`, where $? is the status of the negation,
|
||||
always 0: a failed command was never retried and retry() returned success,
|
||||
so a failed `git clone` carried on until a later check noticed the missing
|
||||
checkout. The apt steps now retry for real but stay non-fatal, as they
|
||||
effectively were; a clone that keeps failing stops the install.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ONE_SHOT = Path(__file__).resolve().parent.parent / "scripts" / "install" / "one-shot-install.sh"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not sys.platform.startswith("linux"), reason="runs the installer's bash under Linux"
|
||||
)
|
||||
|
||||
|
||||
def _function(name):
|
||||
text = ONE_SHOT.read_text(encoding="utf-8")
|
||||
m = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", text, re.S | re.M)
|
||||
assert m, f"{name}() not found in one-shot-install.sh"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def _run(snippet):
|
||||
script = (
|
||||
"set -Eeuo pipefail\n"
|
||||
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
|
||||
"print_error() { echo \"E: $*\" >&2; }\n"
|
||||
"print_warning() { echo \"W: $*\" >&2; }\n"
|
||||
"sleep() { :; }\n"
|
||||
f"{_function('retry')}\n"
|
||||
f"{snippet}\n"
|
||||
)
|
||||
return subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_failure_is_retried_and_reported():
|
||||
r = _run("n=0; f() { n=$((n+1)); return 7; }\n"
|
||||
"if retry f; then echo OK; else echo \"FAILED $? after $n\"; fi")
|
||||
assert r.stdout.strip() == "FAILED 7 after 3", r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_success_on_a_later_attempt():
|
||||
r = _run("n=0; f() { n=$((n+1)); [ $n -ge 2 ]; }\n"
|
||||
"retry f && echo \"OK after $n\"")
|
||||
assert r.stdout.strip() == "OK after 2", r.stdout + r.stderr
|
||||
|
||||
|
||||
def test_a_plain_call_that_keeps_failing_stops_the_script():
|
||||
r = _run("retry false\necho SHOULD_NOT_RUN")
|
||||
assert "SHOULD_NOT_RUN" not in r.stdout
|
||||
assert "ERR_TRAP_FIRED" in r.stderr
|
||||
|
||||
|
||||
def test_apt_steps_stay_non_fatal():
|
||||
text = ONE_SHOT.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if re.search(r"\bretry (sudo )?apt-get ", line):
|
||||
assert "||" in line, f"apt step would now abort the install: {line.strip()}"
|
||||
@@ -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()
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -129,3 +129,18 @@ def test_every_granted_helper_is_hardened_in_first_time_install_after_chown():
|
||||
assert loop.start() > project_chown, (
|
||||
"helper hardening runs before Step 11's project-wide chown, which undoes it")
|
||||
assert _granted_helpers() <= set(loop.group(1).split())
|
||||
|
||||
|
||||
def test_no_grant_runs_a_file_the_web_user_can_edit():
|
||||
"""Every project file granted as root must be a fix_perms helper, which
|
||||
both installers chown root:root (checked above). Anything else under the
|
||||
project root is owned by the user after Step 11's chown, so a NOPASSWD
|
||||
rule for it lets the web user rewrite the file and run it as root. The
|
||||
grants for display_controller.py, start_display.sh and stop_display.sh
|
||||
were exactly that, and nothing ever ran them through sudo."""
|
||||
for installer in (FIRST_TIME, CONFIGURE):
|
||||
for _, command in _grants(installer):
|
||||
for token in command.split():
|
||||
if token.startswith("$PROJECT_ROOT/"):
|
||||
assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), (
|
||||
f"{installer.name} grants root on a user-owned file: {command}")
|
||||
|
||||
Reference in New Issue
Block a user