Merge branch 'claude/hdpi-scroll-performance-antialiasing-4ae609' into claude/offscreen-rendering

# Conflicts:
#	src/display_manager.py
#	src/vegas_mode/config.py
#	src/vegas_mode/render_pipeline.py
This commit is contained in:
Chuck
2026-09-24 17:46:58 -04:00
89 changed files with 2887 additions and 5491 deletions
-1
View File
@@ -105,7 +105,6 @@ def _reset_display_manager_globals():
# An instance left here is what the next module's DisplayManager() call
# gets back -- potentially one built against a MagicMock matrix.
dm_mod.DisplayManager._instance = None
dm_mod.DisplayManager._initialized = False
# Put a binding back if a patch outlived the module that started it.
# Restoring rather than failing: a leak reported against an innocent module
+3 -7
View File
@@ -43,12 +43,7 @@ function get(path) {
window.debugLog = () => {};
window.PLUGIN_DEBUG = false;
window.installedPlugins = installed;
window.escapeHtml = function (text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
require('../led_escape').install(window);
// Load the helper as a real <script>.
const s = document.createElement('script');
@@ -69,7 +64,8 @@ function get(path) {
const s2 = document.createElement('script');
s2.textContent = `
var installedPlugins = window.installedPlugins;
var escapeHtml = window.escapeHtml, pluginLog = window.pluginLog, debugLog = window.debugLog;
var escapeHtml = window.LEDEscape.html, escapeAttribute = window.LEDEscape.attr;
var pluginLog = window.pluginLog, debugLog = window.debugLog;
var PLUGIN_DEBUG = false;
${src.slice(a, b)}
${src.slice(b, c)}
+1
View File
@@ -50,6 +50,7 @@ const get = p => new Promise((res, rej) =>
const s0 = window.document.createElement('script');
s0.textContent = `
window.debugLog = function () {};
${require('../led_escape').source}
window.showNotification = function () {};
window.showError = function () {};
window.updateSystemStatus = function () {};
+27
View File
@@ -0,0 +1,27 @@
// window.LEDEscape, taken verbatim from web_interface/static/v3/js/app-early.js.
//
// Scripts that the suites evaluate on their own (a slice of plugins_manager.js,
// a widget file) call window.LEDEscape, which the page defines in app-early.js
// before anything else runs. `source` is that definition, for a jsdom <script>;
// install(win) evaluates it onto a plain object standing in for window.
const fs = require('fs');
const path = require('path');
const APP_EARLY = path.resolve(__dirname, '../../web_interface/static/v3/js/app-early.js');
const text = fs.readFileSync(APP_EARLY, 'utf8');
const start = text.indexOf('window.LEDEscape = (function() {');
const endMark = '})();';
const end = text.indexOf(endMark, start);
if (start < 0 || end < 0) {
console.error('FAIL: cannot find the window.LEDEscape definition in app-early.js');
process.exit(1);
}
const source = text.slice(start, end + endMark.length);
function install(win) {
// eslint-disable-next-line no-new-func
new Function('window', source)(win);
return win.LEDEscape;
}
module.exports = { source, install };
+1 -1
View File
@@ -19,7 +19,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.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_plugin_action_delegation.js'];
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
+212
View File
@@ -0,0 +1,212 @@
// The image list and schedule editor of the file-upload widget
// (web_interface/static/v3/js/widgets/file-upload.js), which owns them since
// plugins_manager.js stopped shipping its own copies.
//
// 1. Upload-supplied strings (file name, id) reach the page as text and data
// attributes only, and the delete button hands them back intact.
// 2. Card and editor ids use the same rule as plugin_config.html
// (img_id|replace('.', '_')|replace('-', '_')), so the schedule button of a
// server-rendered card with a UUID id finds its editor.
// 3. A schedule edit saves to the hidden input and updates the card's summary
// in place: the open editor is not rebuilt, so what the user just changed
// stays on screen and keeps focus.
// 4. Re-rendering the list (upload, delete) keeps an open editor open.
//
// Plain node with a minimal DOM shim, like the other unit suites.
const fs = require('fs');
const path = require('path');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/js/widgets/file-upload.js'), 'utf8');
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).slice(0, 400) : '')));
// ── DOM shim ───────────────────────────────────────────────────────────────
class ClassList {
constructor(el) { this.el = el; }
get list() { return (this.el.className || '').split(/\s+/).filter(Boolean); }
contains(c) { return this.list.includes(c); }
add(...cs) { this.el.className = [...new Set([...this.list, ...cs])].join(' '); }
remove(...cs) { this.el.className = this.list.filter(c => !cs.includes(c)).join(' '); }
}
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = []; this.parent = null; this.attrs = {}; this.dataset = {}; this.style = {};
this.className = ''; this.id = ''; this._text = ''; this._html = null; this.listeners = {};
this.classList = new ClassList(this);
}
appendChild(c) { c.parent = this; this.children.push(c); return c; }
append(...nodes) { nodes.forEach(n => this.appendChild(typeof n === 'string' ? new TextNode(n) : n)); }
replaceChildren(...nodes) { this.children = []; this._text = ''; this._html = null; this.append(...nodes); }
setAttribute(n, v) { this.attrs[n] = String(v); }
getAttribute(n) { return this.attrs[n]; }
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }
click() { (this.listeners.click || []).forEach(f => f.call(this, { target: this })); }
set textContent(v) { this.children = []; this._html = null; this._text = String(v); }
get textContent() { return this._text + this.children.map(c => c.textContent).join(''); }
// Markup written with innerHTML is kept as a string; the shim never parses it.
set innerHTML(v) { this.children = []; this._text = ''; this._html = String(v); }
get innerHTML() {
if (this._html !== null) return this._html;
return this._text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
*walk() { for (const c of this.children) { yield c; yield* c.walk(); } }
querySelector(sel) {
const tests = {
'[id^="schedule_"]:not(.hidden)': e => e instanceof El && e.id.startsWith('schedule_') && !e.classList.contains('hidden'),
'.image-schedule-summary': e => e instanceof El && e.classList.contains('image-schedule-summary'),
};
if (!tests[sel]) throw new Error('shim: unsupported selector ' + sel);
for (const e of this.walk()) if (tests[sel](e)) return e;
return null;
}
}
class TextNode { constructor(t) { this.textContent = String(t); } *walk() {} }
const root = new El('body');
const docListeners = {};
global.document = {
createElement: t => new El(t),
createTextNode: t => new TextNode(t),
getElementById: id => {
if (root.id === id) return root;
for (const e of root.walk()) if (e.id === id) return e;
return null;
},
addEventListener: (t, f) => { (docListeners[t] ||= []).push(f); },
};
global.window = global;
require('../led_escape').install(window);
window.LEDMatrixWidgets = { register() {} };
window.currentPluginConfig = null;
window.getUploadConfig = () => ({ plugin_id: 'static-image' });
// eslint-disable-next-line no-eval
eval(SRC);
window.getUploadConfig = () => ({ plugin_id: 'static-image' });
function mount(fieldId, images) {
root.children = [];
const list = root.appendChild(new El('div')); list.id = `${fieldId}_image_list`;
const hidden = root.appendChild(new El('input')); hidden.id = `${fieldId}_images_data`;
hidden.value = JSON.stringify(images);
return { list, hidden };
}
const buttons = card => [...card.walk()].filter(e => e.tagName === 'BUTTON');
const SQ = "x' onmouseover='alert(1)";
const DQ = 'x" onmouseover="alert(1)';
const UUID = '1b2c3d4e-aaaa-bbbb-cccc-123456789abc';
console.log('\n1. upload-supplied strings stay text and data');
{
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
const { list } = mount('f', []);
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
const card = list.children[0];
const all = [...card.walk()];
ok('no element carries markup written with innerHTML', all.every(e => !(e instanceof El) || e._html === null || e._html === ''),
all.filter(e => e instanceof El && e._html).map(e => e._html));
const p = all.find(e => e.tagName === 'P');
ok('file name is text', p.textContent === name, p.textContent);
const img = all.find(e => e.tagName === 'IMG');
ok('alt is the stored filename', img.alt === DQ, img.alt);
ok('thumbnail loads lazily', img.loading === 'lazy' && img.decoding === 'async', [img.loading, img.decoding]);
const [sched, del] = buttons(card);
ok('buttons are labelled for screen readers',
sched.attrs['aria-label'] === 'Schedule image ' + name && del.attrs['aria-label'] === 'Delete image ' + name,
[sched.attrs, del.attrs]);
const calls = [];
window.deleteUploadedImage = (...a) => calls.push(a);
del.click();
ok('delete gets field, image and plugin ids intact',
calls.length === 1 && calls[0][0] === 'f' && calls[0][1] === SQ && calls[0][2] === 'static-image', calls);
sched.click();
const editor = card.children[1];
ok('editor opens for a hostile id', !editor.classList.contains('hidden'), editor.className);
// The editor is built with DOM calls: the id must arrive as attribute data,
// and no element may carry markup written with innerHTML.
const editorEls = [...editor.walk()].filter(e => e instanceof El);
const withId = editorEls.filter(e => e.attrs['data-image-id'] !== undefined);
ok('editor stores the id as attribute data, not markup',
withId.length > 0 && withId.every(e => e.attrs['data-image-id'] === SQ) && editorEls.every(e => e._html === null),
withId.map(e => e.attrs['data-image-id']));
}
console.log('\n2. ids match the server-rendered template');
{
const { list } = mount('g', [{ id: UUID, path: 'a.png', filename: 'a.png' }]);
window.updateImageList('g', [{ id: UUID, path: 'a.png', filename: 'a.png' }]);
const want = UUID.replace(/[.-]/g, '_');
ok('card id replaces "-" like the template', list.children[0].id === 'img_' + want, list.children[0].id);
ok('editor id replaces "-" like the template', list.children[0].children[1].id === 'schedule_' + want,
list.children[0].children[1].id);
// A card as plugin_config.html renders it: its button passes the raw UUID.
root.children = [];
const hidden = root.appendChild(new El('input')); hidden.id = 'g_images_data';
hidden.value = JSON.stringify([{ id: UUID, path: 'a.png', filename: 'a.png' }]);
const editor = root.appendChild(new El('div'));
editor.id = 'schedule_' + want; editor.className = 'hidden mt-3';
window.openImageSchedule('g', UUID, 0);
ok('schedule button of a server-rendered card opens its editor',
!editor.classList.contains('hidden') && editor.textContent.includes('Schedule Settings'), editor.className);
}
console.log('\n3. a schedule edit saves without rebuilding the editor');
{
const images = [{ id: UUID, path: 'a.png', filename: 'a.png' }];
const { list, hidden } = mount('h', images);
window.updateImageList('h', images);
window.openImageSchedule('h', UUID, 0);
const editor = list.children[0].children[1];
const before = editor.innerHTML;
// The shim does not parse the editor markup, so stand in the controls the
// update functions read.
const want = UUID.replace(/[.-]/g, '_');
const check = root.appendChild(new El('input')); check.id = 'schedule_enabled_' + want; check.checked = true;
const mode = root.appendChild(new El('select')); mode.id = 'schedule_mode_' + want; mode.value = 'time_range';
window.toggleImageScheduleEnabled('h', UUID, 0);
window.updateImageScheduleMode('h', UUID, 0);
const saved = JSON.parse(hidden.value)[0].schedule;
ok('hidden input holds the new schedule', saved.enabled === true && saved.mode === 'time_range', saved);
ok('editor was not rebuilt', editor.innerHTML === before && !editor.classList.contains('hidden'));
const summary = list.children[0].querySelector('.image-schedule-summary');
ok('card summary updated in place', summary.textContent === '08:00 - 18:00 (daily)', summary.textContent);
}
console.log('\n4. re-rendering the list keeps an open editor open');
{
const images = [{ id: UUID, path: 'a.png', filename: 'a.png', schedule: { enabled: true, mode: 'per_day', days: {} } },
{ id: 'other', path: 'b.png', filename: 'b.png' }];
const { list } = mount('k', images);
window.updateImageList('k', images);
window.openImageSchedule('k', UUID, 0);
window.updateImageList('k', images.slice(0, 1));
const editor = list.children[0].children[1];
ok('editor still open after re-render', !editor.classList.contains('hidden'), editor.className);
const perDay = [...editor.walk()].find(e => e instanceof El && e.tagName === 'OPTION' && e.attrs.value === 'per_day');
ok('editor rebuilt from the saved schedule', !!perDay && 'selected' in perDay.attrs);
}
console.log('\n5. the delegated change listener routes editor controls');
{
const calls = [];
const saved = window.updateImageScheduleDay;
window.updateImageScheduleDay = (...a) => calls.push(a);
const control = new El('input');
control.dataset = { scheduleControl: 'day', fieldId: SQ, imageId: DQ, imageIdx: '2', day: 'monday' };
control.closest = () => control;
(docListeners.change || []).forEach(f => f({ target: control }));
window.updateImageScheduleDay = saved;
ok('day control reaches updateImageScheduleDay with its data intact',
calls.length === 1 && calls[0][0] === SQ && calls[0][1] === DQ && calls[0][2] === 2 && calls[0][3] === 'monday', calls);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
+73 -5
View File
@@ -11,9 +11,10 @@
// name" onfocus="alert(1)
//
// CodeQL reported 83 js/incomplete-html-attribute-sanitization alerts for
// exactly this. This suite pins the fix at the source: it reads each real
// implementation out of the shipped file and runs it, so an escaper that loses
// its quote handling again fails here rather than in a scanner run weeks later.
// exactly this. The web UI now has one implementation, window.LEDEscape in
// app-early.js, and the old per-file escapers are one-line names for it. This
// suite runs LEDEscape and every one of those names as shipped, and fails if a
// hand-rolled escaper appears anywhere else in web_interface/.
const fs = require('fs');
const path = require('path');
@@ -41,6 +42,7 @@ class FakeEl {
}
global.document = { createElement: () => new FakeEl() };
global.window = global;
const LEDEscape = require('../led_escape').install(window);
// ── source extraction ──────────────────────────────────────────────────────
// Pull a function out of a real source file by its opening line and balanced
@@ -66,6 +68,7 @@ function extract(file, opener) {
// Evaluate an extracted escaper and return it as a callable.
function loadFn(file, opener, name, { method = false } = {}) {
if (file === null) return LEDEscape[name];
const body = extract(file, opener);
// Class/object methods (`escapeHtml(text) {...}`) are not valid statements on
// their own -- wrap them in an object literal so they can be evaluated.
@@ -78,6 +81,8 @@ function loadFn(file, opener, name, { method = false } = {}) {
// ── the escapers, as shipped ───────────────────────────────────────────────
const ESCAPERS = [
['app-early.js (LEDEscape.html)', null, null, 'html'],
['app-early.js (LEDEscape.attr)', null, null, 'attr'],
['base-widget.js (BaseWidget.escapeHtml)',
'static/v3/js/widgets/base-widget.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (top-level escapeHtml)',
@@ -90,8 +95,26 @@ const ESCAPERS = [
'static/v3/js/widgets/json-file-manager.js', '_esc(str) {', '_esc', true],
['plugin-file-manager.js (escHtml)',
'static/v3/js/widgets/plugin-file-manager.js', 'function escHtml(s) {', 'escHtml', false],
['app-shell.js (escapeHtml)',
'static/v3/js/app-shell.js', 'escapeHtml(text) {', 'escapeHtml', true],
['plugins_manager.js (escapeAttribute)',
'static/v3/plugins_manager.js', 'function escapeAttribute(text) {', 'escapeAttribute', false],
['notification.js (escapeHtml)',
'static/v3/js/widgets/notification.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['google-calendar-picker.js (escapeHtml)',
'static/v3/js/widgets/google-calendar-picker.js', 'function escapeHtml(str) {', 'escapeHtml', false],
['text-input.js (escapeHtml)',
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['slider.js (escapeAttr)',
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['backup_restore.html (escapeHtml)',
'templates/v3/partials/backup_restore.html', 'function escapeHtml(value) {', 'escapeHtml', false],
['operation_history.html (escapeHtml)',
'templates/v3/partials/operation_history.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
['cache.html (escapeHtml)',
@@ -135,6 +158,51 @@ for (const [label, file, opener, name, method] of ESCAPERS) {
ok(`${label}: &quot; input stays inert`, out === '&amp;quot;', out);
}
console.log('\n4b. LEDEscape.jsStringAttr: a JS string literal that survives an attribute');
{
const decode = s => s.replace(/&(quot|#39|lt|gt|amp);/g, (m, e) =>
({ quot: '"', '#39': "'", lt: '<', gt: '>', amp: '&' })[e]);
for (const v of ["x' onmouseover='alert(1)", 'x" onmouseover="alert(1)', '</script><b>', 'a&#39;b']) {
const out = LEDEscape.jsStringAttr(v);
ok(`${JSON.stringify(v)}: no raw quote or bracket`, !/["'<>]/.test(out), out);
// eslint-disable-next-line no-eval
ok(`${JSON.stringify(v)}: decodes back to the same string`, eval(decode(out)) === v, out);
}
ok('null and undefined become the empty string',
LEDEscape.html(null) === '' && LEDEscape.html(undefined) === '' && LEDEscape.jsStringAttr(null) === '&quot;&quot;');
ok('numbers are kept', LEDEscape.html(0) === '0', LEDEscape.html(0));
}
console.log('\n4c. no hand-rolled escaper outside app-early.js');
{
const skip = new Set(['static/v3/js/app-early.js',
// documentation example, kept self-contained on purpose
'static/v3/js/widgets/example-color-picker.js']);
const found = [];
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
const p = path.join(dir, e.name);
const rel = path.relative(ROOT, p).split(path.sep).join('/');
if (e.isDirectory()) { if (e.name !== 'vendor') walk(p); return; }
if (!/\.(js|html)$/.test(e.name) || /\.min\.js$/.test(e.name) || skip.has(rel)) return;
// Writing the entity for a quote is what an escaper does; nothing else in
// the UI needs to.
let text = fs.readFileSync(p, 'utf8');
// In templates only the inline scripts count; Jinja's own |replace("'", "&#39;")
// escaping of server-rendered values is not a JS escaper.
if (e.name.endsWith('.html')) text = (text.match(/<script[^>]*>[\s\S]*?<\/script\s*>/gi) || []).join('\n');
if (/['"`]&quot;['"`]|['"`]&#39;['"`]/.test(text)) found.push(rel);
});
walk(path.join(ROOT, 'static'));
walk(path.join(ROOT, 'templates'));
ok('every escaper is window.LEDEscape', found.length === 0, found);
// Plugin pages may call these globals; they stay, as aliases.
const early = fs.readFileSync(path.join(ROOT, 'static/v3/js/app-early.js'), 'utf8');
ok('window.escapeHtml is kept as an alias of LEDEscape.html',
early.includes('window.escapeHtml = window.LEDEscape.html;'));
ok('window.escapeAttribute is kept as an alias of LEDEscape.attr',
early.includes('window.escapeAttribute = window.LEDEscape.attr;'));
}
// ── url-input scheme handling (js/xss-through-dom) ─────────────────────────
console.log('\n5. url-input never treats a scriptable scheme as a valid URL');
{
+3 -26
View File
@@ -7,9 +7,8 @@
//
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
// handler of its own. The live window.updateImageList (plugins_manager.js loads
// last, so its copy beats the file-upload widget's) put the uploaded file's
// original name into the markup unescaped.
// handler of its own. (The uploaded-image list is no longer built here: the
// file-upload widget owns it, and test_file_upload_widget.js covers it.)
//
// Each case renders with the shipped function, parses the tag the way a browser
// does (quoted attribute values, entities decoded), and checks two things: no
@@ -54,6 +53,7 @@ global.document = {
createElement: () => new TextEl(),
};
global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {};
global.isStorePluginInstalled = () => false;
global.isNewPlugin = () => false;
@@ -68,8 +68,6 @@ eval([
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
// eslint-disable-next-line no-eval
eval(extract('window.updateImageList = function(fieldId, images) {'));
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
function decodeEntities(s) {
@@ -190,26 +188,5 @@ for (const hostile of [SQ, DQ, AMP]) {
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
}
console.log('\n5. live window.updateImageList escapes the uploaded file name');
window.getUploadConfig = () => ({ plugin_id: SQ });
window.currentPluginConfig = null;
{
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
const html = els['f_image_list'].innerHTML;
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
const imgs = tags(html, 'img');
ok('one <img>, only the template attributes',
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
const buttons = tags(html, 'button');
ok('two buttons, no extra attributes',
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
buttons.map(names));
const del = runHandler(attr(buttons[1], 'onclick'));
ok('delete handler gets field, image and plugin ids intact',
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
}
console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
@@ -24,7 +24,7 @@ function slice(startMarker, endMarker) {
return SRC.slice(a, b);
}
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// GitHub Token Collapse Handler');
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
let pass = 0, fail = 0;
+2 -1
View File
@@ -30,12 +30,13 @@ global.document = {
createElement: () => new FakeEl(),
};
global.window = global;
require('../led_escape').install(window);
global.pluginLog = () => {};
global.PLUGIN_DEBUG = false;
global.debugLog = () => {};
function setupInstalledEventDelegation() {} // stubbed; tested separately
eval(slice('function escapeHtml(text)', '\nfunction ', ));
eval(slice('function escapeHtml(text)', '\nfunction isNewPlugin'));
eval(slice('function renderInstalledCards(plugins, total)',
'// Set up event delegation for plugin action buttons'));
+59
View File
@@ -0,0 +1,59 @@
"""DisplayController.cleanup() must tear down Vegas mode.
VegasModeCoordinator.cleanup() stops the scroll, resets the render pipeline and
stream manager and drops the adapter's content cache, but the controller never
called it, so none of that teardown ran at shutdown.
"""
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(vegas_coordinator):
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.config_service = MagicMock()
dc._font_usage_publisher = None
dc.display_manager = MagicMock()
dc.vegas_coordinator = vegas_coordinator
return dc
def test_cleanup_tears_down_vegas():
vegas = MagicMock()
_controller(vegas).cleanup()
vegas.cleanup.assert_called_once_with()
def test_vegas_is_torn_down_before_the_display_manager():
# Stopping Vegas resets the display's scrolling state, so the display
# manager has to still be there when it runs.
order = []
vegas = MagicMock()
vegas.cleanup.side_effect = lambda: order.append('vegas')
dc = _controller(vegas)
dc.display_manager.cleanup.side_effect = lambda: order.append('display')
dc.cleanup()
assert order == ['vegas', 'display']
def test_cleanup_without_vegas_does_not_raise():
dc = _controller(None)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
vegas = MagicMock()
vegas.cleanup.side_effect = RuntimeError('boom')
dc = _controller(vegas)
dc.cleanup()
dc.display_manager.cleanup.assert_called_once_with()
@@ -0,0 +1,77 @@
"""Startup validation runs each check once.
DisplayController ran StartupValidator.validate_all() twice -- once before the
plugin manager existed and again after -- so every config, cache, display and
systemd-unit warning was logged twice at every boot. The second pass only
needs the plugin checks.
"""
import logging
from unittest.mock import MagicMock, patch
class RecordingValidator:
"""Stands in for StartupValidator and records which checks ran."""
calls = []
def __init__(self, config_manager, plugin_manager=None, cache_manager=None):
self.plugin_manager = plugin_manager
self.errors = []
self.warnings = []
def validate_all(self):
RecordingValidator.calls.append(('validate_all', self.plugin_manager is not None))
self.warnings = ['config warning']
return True, [], list(self.warnings)
def _validate_plugins(self):
RecordingValidator.calls.append(('plugins',))
self.warnings = ['plugin warning']
def _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, config):
from src.display_controller import DisplayController
mock_config_manager.get_config.return_value = config
mock_config_manager.load_config.return_value = config
mock_pm = MagicMock()
mock_pm.discover_plugins.return_value = []
mock_pm.plugins = {}
mock_pm.plugin_manifests = {}
mock_pm.plugin_last_update = {}
mock_pm.health_tracker = None
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager', return_value=mock_pm), \
patch('src.startup_validator.StartupValidator', RecordingValidator):
controller = DisplayController()
return controller
def test_each_check_runs_once(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins,
emulator_mode, caplog):
RecordingValidator.calls = []
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
controller = _build_controller(mock_config_manager, mock_display_manager,
mock_cache_manager, test_config_with_plugins)
try:
full_passes = [c for c in RecordingValidator.calls if c[0] == 'validate_all']
assert len(full_passes) == 1, RecordingValidator.calls
plugin_checks = (
sum(1 for c in RecordingValidator.calls if c[0] == 'plugins')
+ sum(1 for c in full_passes if c[1]))
assert plugin_checks == 1, RecordingValidator.calls
config_warnings = [r for r in caplog.records
if 'config warning' in r.getMessage()]
assert len(config_warnings) == 1
assert any('plugin warning' in r.getMessage() for r in caplog.records)
finally:
controller.cleanup()
+3 -9
View File
@@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring:
fake_coordinator = MagicMock()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
from unittest.mock import patch
with patch('src.vegas_mode.VegasModeCoordinator',
MagicMock(return_value=fake_coordinator)):
dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
-4
View File
@@ -28,7 +28,6 @@ def dm(tmp_path_factory):
"""One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
@@ -60,7 +59,6 @@ def dm(tmp_path_factory):
"test module.")
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy:
@@ -230,7 +228,6 @@ class TestKillSwitch:
def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
try:
manager = DisplayManager({
"display": {
@@ -243,7 +240,6 @@ class TestKillSwitch:
assert manager._dirty_tracking_enabled is False
finally:
DisplayManager._instance = None
DisplayManager._initialized = False
if __name__ == "__main__":
+49
View File
@@ -0,0 +1,49 @@
"""display_manager's logger must follow the level run.py configures.
The module pinned its own logger to INFO at import, which overrides the root
level, so ``run.py -d`` never showed a single DEBUG line from the display
manager -- the dirty-tracking, fallback-mode and scrolling-state traces were
unreachable without editing the source.
"""
import logging
import os
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as dm
def test_the_module_sets_no_level_of_its_own():
assert dm.logger.level == logging.NOTSET
def test_scrolling_state_logs_only_when_it_changes(caplog):
# Vegas and every scrolling plugin set the state on each frame, so a line
# per call was ~120 DEBUG lines a second once debug output was visible.
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._frame_hold = 1
dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0}
with caplog.at_level(logging.DEBUG, logger='src.display_manager'):
for _ in range(5):
dm_obj.set_scrolling_state(True, frame_hold=2)
dm_obj.set_scrolling_state(False)
dm_obj.set_scrolling_state(False)
lines = [r.getMessage() for r in caplog.records
if r.getMessage().startswith('Scrolling state set to')]
assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False']
# The state itself still updates on every call.
assert dm_obj._scrolling_state['is_scrolling'] is False
assert dm_obj._frame_hold == 1
def test_debug_output_appears_when_the_root_is_at_debug():
root = logging.getLogger()
previous = root.level
root.setLevel(logging.DEBUG)
try:
assert dm.logger.isEnabledFor(logging.DEBUG)
finally:
root.setLevel(previous)
-1
View File
@@ -157,7 +157,6 @@ def vegas_coordinator(controller):
coord._update_callback = None
coord._update_tick_running = False
coord.sync_manager = None
coord._update_static_mode_plugins = lambda: None
coord._check_static_plugin_trigger = lambda: None
coord.frames = 0
+24 -19
View File
@@ -2,7 +2,7 @@
Tests for src/vegas_mode/config.py
Covers VegasModeConfig: from_config, to_dict, get_frame_interval,
is_plugin_included, get_ordered_plugins, validate.
get_ordered_plugins, validate.
"""
import pytest
@@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults:
cfg = VegasModeConfig()
assert len(cfg.excluded_plugins) == 0
def test_defaults_match_the_shipped_template(self):
# The template, the web UI help and CONFIG_REFERENCE all document
# these values. max_cycle_duration defaulted to 600 in code while all
# three said 240, so an install without the key ran iterations 2.5x
# longer than documented.
import json
from pathlib import Path
template = json.loads(
(Path(__file__).resolve().parent.parent / "config"
/ "config.template.json").read_text(encoding="utf-8"))
shipped = template["display"]["vegas_scroll"]
defaults = VegasModeConfig().to_dict()
mismatched = {k: (v, defaults[k]) for k, v in shipped.items()
if k in defaults and defaults[k] != v}
assert not mismatched, f"template vs code default: {mismatched}"
def test_missing_keys_read_the_field_defaults(self):
# from_config used to repeat every default; with no keys set it must
# produce exactly the dataclass defaults.
assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict()
assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict()
== VegasModeConfig().to_dict())
# ---------------------------------------------------------------------------
# from_config
@@ -147,24 +170,6 @@ class TestGetFrameInterval:
assert result == 1.0
# ---------------------------------------------------------------------------
# is_plugin_included
# ---------------------------------------------------------------------------
class TestIsPluginIncluded:
def test_not_excluded_is_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("good_plugin") is True
def test_excluded_plugin_not_included(self):
cfg = VegasModeConfig(excluded_plugins={"bad_plugin"})
assert cfg.is_plugin_included("bad_plugin") is False
def test_empty_exclusions_all_included(self):
cfg = VegasModeConfig()
assert cfg.is_plugin_included("anything") is True
# ---------------------------------------------------------------------------
# get_ordered_plugins
# ---------------------------------------------------------------------------
+2 -4
View File
@@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates:
# Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True
assert stream._pending_updates
stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False
assert not stream._pending_updates
assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self):
@@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates:
class TestContinuousModeReachesTheRefresh:
def _pipeline(self):
stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self):
@@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh:
pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called()
+57
View File
@@ -0,0 +1,57 @@
"""run_iteration() does no per-iteration static-mode bookkeeping.
Every iteration used to rebuild a set of STATIC-mode plugins -- asking each
plugin for its display mode and logging the result at INFO -- that nothing
ever read. The static pause is driven by _check_static_plugin_trigger(), which
looks at the next segment, not at that set.
"""
import logging
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(plugins):
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0}}})
coord.render_pipeline = MagicMock()
# The loop paces itself from these (#628); a MagicMock can't be compared.
coord.render_pipeline.frame_interval = 0.0
coord.render_pipeline.target_fps = 90
coord.stream_manager = MagicMock()
coord.display_manager = MagicMock()
coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get)
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
coord._state_lock = threading.Lock()
coord._is_active = True
coord._is_paused = False
coord._should_stop = False
coord._fps_last_health_log = 0.0
coord._fps_was_degraded = False
coord._live_priority_check = None
coord._live_priority_active = False
coord._interrupt_check = None
coord._interrupt_check_interval = 10
coord._update_callback = None
coord._update_tick_running = False
coord._check_static_plugin_trigger = lambda: None
coord.run_frame = lambda: True
return coord
def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
static = MagicMock()
static.get_vegas_display_mode.return_value = VegasDisplayMode.STATIC
coord = _coordinator({'static-plugin': static})
with caplog.at_level(logging.INFO, logger='src.vegas_mode.coordinator'):
assert coord.run_iteration() is True
static.get_vegas_display_mode.assert_not_called()
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
+3 -7
View File
@@ -416,7 +416,7 @@ class TestStreamGrouping:
from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count,
plugin_id=plugin_id, images=imgs,
display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self):
@@ -437,10 +437,6 @@ class TestStreamGrouping:
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate:
"""
@@ -984,8 +980,8 @@ class TestRotationAcrossMultipleCycles:
"""
The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the
cycle. First-pass-only tests never reach it.
once crashed the cut and lost that plugin's content for the cycle.
First-pass-only tests never reach it.
"""
def test_window_advances_over_many_cycles_without_error(self):
-49
View File
@@ -10,7 +10,6 @@ from src.vegas_mode.geometry import (
content_bounds,
dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content,
window_coverage_stats,
@@ -320,51 +319,3 @@ class TestSeparationGap:
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+1 -8
View File
@@ -24,7 +24,6 @@ class FakeDisplayManager:
def _make_pipeline(sync_manager=None):
stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager
return pipeline, stream_manager
@@ -37,7 +36,7 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_no_pending_updates_no_staging_does_not_recompose(self):
def test_no_pending_updates_does_not_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False
@@ -49,12 +48,6 @@ class TestShouldRecompose:
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True
def test_staging_buffer_content_triggers_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
"""Sync-mode deferral (PR #330's actual intent) must still hold:
pending updates alone must NOT trigger a mid-cycle hot-swap when a
+79
View File
@@ -0,0 +1,79 @@
"""StreamManager logs one INFO line per plugin-list refresh.
The refresh runs at each cycle start and every 30 seconds. It used to write
decorative "=" * 60 banners plus a line per plugin ("INCLUDED", "SKIPPED"),
and each fetch added its own banners and "FETCHING CONTENT" / "SEGMENT
CREATED" lines, all at INFO: dozens of journal lines a minute on a Pi, burying
anything that mattered. Per-plugin detail belongs at DEBUG.
"""
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.stream_manager import StreamManager
LOGGER = "src.vegas_mode.stream_manager"
class _Plugin:
def __init__(self, enabled=True, mode=VegasDisplayMode.SCROLL):
self.enabled = enabled
self._mode = mode
def get_vegas_display_mode(self):
return self._mode
def _stream(plugins):
adapter = MagicMock()
adapter.get_content_type.return_value = 'multi'
adapter.get_content.return_value = [Image.new('RGB', (20, 8))]
return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter)
def _info(caplog):
return [r for r in caplog.records
if r.name == LOGGER and r.levelno == logging.INFO]
def test_a_refresh_logs_a_single_info_summary(caplog):
stream = _stream({
'clock': _Plugin(),
'weather': _Plugin(),
'off': _Plugin(enabled=False),
})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
info = _info(caplog)
assert len(info) == 1, [r.getMessage() for r in info]
summary = info[0].getMessage()
assert 'clock' in summary and 'weather' in summary
assert 'off' not in summary.split(':', 1)[1]
# The per-plugin decisions are still there for anyone at DEBUG.
debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG]
assert any('off' in m and 'not enabled' in m for m in debug)
def test_fetching_content_logs_nothing_at_info(caplog):
stream = _stream({'clock': _Plugin()})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
segment = stream._fetch_plugin_content('clock')
assert segment is not None
assert _info(caplog) == []
def test_no_decorative_banners(caplog):
stream = _stream({'clock': _Plugin(), 'static': _Plugin(mode=VegasDisplayMode.STATIC)})
with caplog.at_level(logging.DEBUG, logger=LOGGER):
stream._refresh_plugin_list()
stream._fetch_plugin_content('clock')
stream._fetch_plugin_content('static')
assert not any(set(r.getMessage()) == {'='} for r in caplog.records)