mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge commit 'b9416ef803028ec37e0f2f756c051e52d52622d7' into claude/hdpi-scroll-performance-antialiasing-4ae609
# Conflicts: # src/vegas_mode/config.py # src/vegas_mode/coordinator.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 () {};
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
*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);
|
||||
@@ -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}: " input stays inert`, out === '&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'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) === '""');
|
||||
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("'", "'")
|
||||
// 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 (/['"`]"['"`]|['"`]'['"`]/.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');
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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()
|
||||
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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user