Merge remote-tracking branch 'origin/main' into claude/fix-on-demand-edges

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Chuck
2026-10-03 22:30:50 -04:00
42 changed files with 2632 additions and 233 deletions
+4
View File
@@ -45,6 +45,10 @@ server has none.
|---|---|---|
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
| `unit/test_store_install.js` | no | The store's Install button, with the whole of `plugins_manager.js` run by `plugins_manager_sandbox.js` (a vm context, fake DOM and API): a fresh install reloads the list, then enables the id the plugin was installed as -- the answer's `plugin_id`, else the installed entry the store entry matches (Weather installs as `ledmatrix-weather`); a Reinstall leaves the enabled state alone |
| `unit/test_install_polling.js` | no | How long Install waits for a queued install (sandbox): at least the server's 300 s dependency-install timeout; when it stops waiting it reloads the installed list and warns, rather than reporting a failure or enabling anything |
| `unit/test_store_categories.js` | no | The store's category filter (sandbox): the template ships only All Categories, the rest come from the store's plugins (one per category whatever its case), choosing one filters to it, and a swapped-in select is refilled from the cache keeping the choice |
| `unit/test_github_url_install.js` | no | Install Single Plugin (sandbox, the button as `plugins.html` ships it): no inline `onclick`, so a click or Enter sends exactly one `install-from-url` request and raises no error |
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
+212
View File
@@ -0,0 +1,212 @@
// The whole of plugins_manager.js (and list_filter.js before it, as the page
// loads them), evaluated in a node vm context against a small fake DOM.
//
// For suites that drive the plugin manager's real flows -- install, polling,
// store filters, the GitHub-URL button -- rather than one function sliced
// out of the file. Nothing is mocked inside the script: only what the page
// gives it (document, fetch, timers, showNotification, LEDEscape).
//
// const sb = create({ route: (method, url, body) => ({ status, json }) });
// sb.el('plugin-store-grid'); // make an element exist by id
// sb.window.installPlugin('weather');
// await sb.until(() => sb.requests.some(r => r.url.includes('/toggle')));
//
// Timers ignore their delays and run on the next turn, so a poll loop that
// would take minutes in a browser finishes in milliseconds. The page is in
// readyState "loading" with no #installed-plugins-grid, so the script's own
// start-up does nothing until a suite asks for it (window.initPluginsPage()).
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ledEscape = require('./led_escape');
const V3 = path.resolve(__dirname, '../../web_interface/static/v3');
const PLUGINS_HTML = path.resolve(__dirname, '../../web_interface/templates/v3/partials/plugins.html');
class FakeClassList {
constructor() { this.set = new Set(); }
add(...c) { c.forEach(x => this.set.add(x)); }
remove(...c) { c.forEach(x => this.set.delete(x)); }
contains(c) { return this.set.has(c); }
toggle(c, force) {
const on = force === undefined ? !this.set.has(c) : !!force;
if (on) this.set.add(c); else this.set.delete(c);
return on;
}
}
function create({ route } = {}) {
const elements = new Map();
const requests = [];
const toasts = [];
const errors = [];
const restartNotes = [];
class FakeElement {
constructor(id, tag = 'div', attributes = {}) {
this.id = id;
this.tagName = tag.toUpperCase();
this.attributes = { ...attributes };
this.listeners = {};
this.children = [];
this.classList = new FakeClassList();
this.style = { removeProperty() {} };
this.dataset = {};
this.value = '';
this.textContent = '';
this.disabled = false;
this.parentNode = null;
this._html = '';
}
get innerHTML() { return this._html; }
set innerHTML(v) { this._html = String(v); this.children = []; }
getAttribute(n) { return n in this.attributes ? this.attributes[n] : null; }
setAttribute(n, v) { this.attributes[n] = String(v); }
hasAttribute(n) { return n in this.attributes; }
removeAttribute(n) { delete this.attributes[n]; }
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); }
removeEventListener(type, fn) {
this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn);
}
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
querySelector() { return null; }
querySelectorAll() { return []; }
closest() { return null; }
cloneNode() {
const copy = new FakeElement(this.id, this.tagName, this.attributes);
copy._html = this._html;
copy.value = this.value;
return copy;
}
replaceChild(next, prev) {
next.parentNode = this;
prev.parentNode = null;
if (next.id) elements.set(next.id, next);
return prev;
}
replaceWith(next) { if (this.parentNode) this.parentNode.replaceChild(next, this); }
// A browser runs an inline on<type> attribute first (it was set before
// any listener was added), then the listeners, and an exception in one
// does not stop the next: it is reported, which is what `errors` holds.
dispatch(type, init = {}) {
const event = {
type, target: this, currentTarget: this, key: init.key,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() {}, stopImmediatePropagation() {},
};
const inline = this.getAttribute('on' + type);
const handlers = [];
if (inline !== null) {
handlers.push(vm.runInContext(`(function(event) {\n${inline}\n})`, ctx));
}
handlers.push(...(this.listeners[type] || []));
for (const h of handlers) {
try { h.call(this, event); } catch (e) { errors.push(e); }
}
return event;
}
click() { return this.dispatch('click'); }
}
function el(id, tag, attributes) {
if (!elements.has(id)) {
const parent = new FakeElement(null);
parent.appendChild(new FakeElement(id, tag, attributes));
elements.set(id, parent.children[0]);
}
return elements.get(id);
}
const timers = [];
const ctx = {
// Warnings are the script noting elements this fake page doesn't have.
console: { log: console.log.bind(console), error: console.error.bind(console),
warn: () => {}, info: () => {}, debug: () => {} },
debugLog: () => {},
addEventListener() {},
URL,
document: {
readyState: 'loading',
body: { addEventListener() {} },
getElementById: id => elements.get(id) || null,
querySelector: () => null,
querySelectorAll: () => [],
addEventListener() {},
dispatchEvent() { return true; },
createElement: tag => new FakeElement(null, tag),
},
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
setTimeout: (fn, _ms, ...args) => { timers.push(setImmediate(() => fn(...args))); return timers.length; },
clearTimeout: () => {},
setInterval: () => 0,
clearInterval: () => {},
requestAnimationFrame: fn => setImmediate(fn),
getComputedStyle: () => ({ display: 'block' }),
scrollTo() {},
sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} },
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
confirm: () => true,
alert: () => {},
showNotification: (message, type) => {
toasts.push({ message: String(message),
type: type && typeof type === 'object' ? type.type : type });
},
noteRestartRequired: (body) => { restartNotes.push(body); },
fetch: async (url, opts = {}) => {
const method = (opts.method || 'GET').toUpperCase();
let body = null;
try { body = opts.body ? JSON.parse(opts.body) : null; } catch (e) { body = opts.body; }
requests.push({ method, url: String(url), body });
const answer = (route && route(method, String(url), body)) || { status: 200, json: { status: 'success' } };
const status = answer.status || 200;
return { ok: status < 400, status, json: async () => answer.json };
},
};
ctx.window = ctx;
vm.createContext(ctx);
ledEscape.install(ctx);
for (const file of ['js/plugins/list_filter.js', 'plugins_manager.js']) {
vm.runInContext(fs.readFileSync(path.join(V3, file), 'utf8'), ctx, { filename: file });
}
// Resolves once cond() is true, letting timers and promises run between
// checks; rejects if it never is.
async function until(cond, label = 'condition', turns = 20000) {
for (let i = 0; i < turns; i++) {
if (cond()) return;
await new Promise(r => setImmediate(r));
}
throw new Error('timed out waiting for ' + label);
}
// Lets every pending timer and promise run.
async function settle(turns = 50) {
for (let i = 0; i < turns; i++) await new Promise(r => setImmediate(r));
}
return { window: ctx, el, FakeElement, requests, toasts, errors, restartNotes, until, settle };
}
// The attributes of the element with this id in partials/plugins.html, as
// the template ships them (no Jinja on the tags these suites read).
function templateAttributes(id) {
const html = fs.readFileSync(PLUGINS_HTML, 'utf8');
const at = html.indexOf(`id="${id}"`);
if (at < 0) throw new Error(`no element with id ${id} in plugins.html`);
const start = html.lastIndexOf('<', at);
let end = start, quote = null;
for (; end < html.length; end++) {
const ch = html[end];
if (quote) { if (ch === quote) quote = null; } else if (ch === '"' || ch === "'") quote = ch;
else if (ch === '>') break;
}
const tag = html.slice(start, end + 1);
const attrs = {};
const re = /([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
let m;
while ((m = re.exec(tag))) attrs[m[1]] = m[3] !== undefined ? m[3] : m[4];
return { tag: tag.match(/^<(\w+)/)[1], attrs, source: tag };
}
module.exports = { create, templateAttributes };
+6 -1
View File
@@ -20,7 +20,12 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_update_all.js',
'unit/test_store_install.js',
'unit/test_install_polling.js',
'unit/test_store_categories.js',
'unit/test_github_url_install.js',
'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
+93
View File
@@ -0,0 +1,93 @@
// Plugin Manager > Install from GitHub > Install Single Plugin: one click,
// one request, no errors.
//
// The Install button carried an inline onclick calling
// window.handleGitHubPluginInstall, and attachInstallButtonHandler also gave
// it a click listener that installs. Both ran on every click. The inline one
// threw a ReferenceError (it called isGithubUrl, which lives inside the
// plugin-manager IIFE, from outside it), so only the listener's request went
// out -- and fixing that scope alone would have sent every install twice.
// The button now has the listener only.
//
// Runs the whole of plugins_manager.js in the sandbox, with the button as
// partials/plugins.html ships it.
const { create, templateAttributes } = require('../plugins_manager_sandbox');
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) : '')));
const URL = 'https://github.com/someone/ledmatrix-demo';
function route(method, url) {
if (method === 'POST' && url === '/api/v3/plugins/install-from-url') {
return { json: { status: 'success', message: 'Plugin demo installed successfully', plugin_id: 'demo' } };
}
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
return { json: { status: 'success' } };
}
function page() {
const sb = create({ route });
const button = templateAttributes('install-plugin-from-url');
sb.el('install-plugin-from-url', button.tag, button.attrs);
sb.el('github-plugin-url', 'input');
sb.el('github-plugin-status');
sb.el('plugin-branch-input', 'input');
return sb;
}
const installs = sb => sb.requests.filter(r => r.url === '/api/v3/plugins/install-from-url');
(async () => {
console.log('\nthe template');
{
const { attrs } = templateAttributes('install-plugin-from-url');
ok('the Install button has no inline onclick', !('onclick' in attrs), attrs.onclick);
}
console.log('\na click');
{
const sb = page();
sb.window.attachInstallButtonHandler();
// htmx:afterSettle runs it again on every swap; that must not add a handler.
sb.window.attachInstallButtonHandler();
sb.el('github-plugin-url').value = URL;
sb.window.document.getElementById('install-plugin-from-url').click();
await sb.settle();
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
ok('for the URL typed', installs(sb)[0] && installs(sb)[0].body.repo_url === URL, installs(sb));
ok('and reports the result', /Successfully installed: demo/.test(sb.el('github-plugin-status').innerHTML),
sb.el('github-plugin-status').innerHTML);
}
console.log('\nEnter in the URL field');
{
const sb = page();
sb.window.attachInstallButtonHandler();
const input = sb.el('github-plugin-url');
input.value = URL;
input.dispatch('keypress', { key: 'Enter' });
await sb.settle();
ok('raises no error', sb.errors.length === 0, sb.errors.map(String));
ok('sends exactly one install request', installs(sb).length === 1, installs(sb));
}
console.log('\na URL that is not GitHub');
{
const sb = page();
sb.window.attachInstallButtonHandler();
sb.el('github-plugin-url').value = 'https://example.com/x';
sb.window.document.getElementById('install-plugin-from-url').click();
await sb.settle();
ok('is refused without a request or an error',
installs(sb).length === 0 && sb.errors.length === 0 && /valid GitHub URL/.test(sb.el('github-plugin-status').innerHTML),
{ errors: sb.errors.map(String), status: sb.el('github-plugin-status').innerHTML });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+89
View File
@@ -0,0 +1,89 @@
// How long the store's Install waits for a queued install, and what it says
// when it stops waiting.
//
// It polled the operation 60 times, a second apart, then reported "Install
// operation timed out" as an error and did nothing else. The server is
// allowed far longer: the plugin's dependency install alone may take 300 s
// (install_requirements_file in src/plugin_system/store_install.py), after
// a download that fetches the plugin one file at a time. So an install that
// went on to succeed was reported as failed, never enabled, and missing from
// the installed list until the page was reloaded.
//
// Runs the whole of plugins_manager.js in the sandbox; its timers ignore
// their delays, so each poll here stands for one second on a real page.
const { create } = require('../plugins_manager_sandbox');
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) : '')));
// The server's dependency-install timeout, in polls (one a second).
const DEPENDENCY_INSTALL_TIMEOUT_POLLS = 300;
function server(completesAfterPolls) {
const state = { polls: 0, installed: [] };
state.route = (method, url, body) => {
if (url.startsWith('/api/v3/plugins/installed')) {
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
}
if (method === 'POST' && url === '/api/v3/plugins/install') {
return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
}
if (url === '/api/v3/plugins/operation/op-1') {
state.polls++;
if (completesAfterPolls === null || state.polls < completesAfterPolls) {
return { json: { status: 'success', data: { status: 'running' } } };
}
state.installed = [{ id: 'clock-simple', name: 'Clock', enabled: false }];
return { json: { status: 'success', data: { status: 'completed',
result: { success: true, message: 'installed', plugin_id: 'clock-simple' } } } };
}
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
return { json: { status: 'success', message: 'enabled' } };
}
return { json: { status: 'success' } };
};
return state;
}
(async () => {
console.log('\nan install that takes longer than a minute');
{
// 200 s: well inside what the server allows.
const srv = server(200);
const sb = create({ route: srv.route });
sb.window.installPlugin('clock-simple');
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|timed out|still/i.test(t.message)),
'the install to finish');
await sb.settle();
ok('is waited for until it completes', srv.polls === 200, srv.polls);
ok('is not reported as an error', !sb.toasts.some(t => t.type === 'error'), sb.toasts);
ok('and is enabled', sb.requests.some(r => r.url === '/api/v3/plugins/toggle' && r.body.plugin_id === 'clock-simple'),
sb.requests.filter(r => r.method === 'POST'));
}
console.log('\nan install that never reports back');
{
const srv = server(null);
const sb = create({ route: srv.route });
sb.window.installPlugin('clock-simple');
await sb.until(() => sb.toasts.length >= 3, 'the poller to give up');
await sb.settle();
ok(`is polled for at least the ${DEPENDENCY_INSTALL_TIMEOUT_POLLS} s dependency-install timeout`,
srv.polls >= DEPENDENCY_INSTALL_TIMEOUT_POLLS, srv.polls);
ok('...but not forever', srv.polls <= 1200, srv.polls);
const lastPoll = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/operation/op-1');
ok('then the installed list is reloaded, to show what actually happened',
sb.requests.slice(lastPoll + 1).some(r => r.url === '/api/v3/plugins/installed'),
sb.requests.slice(lastPoll + 1).map(r => r.url));
const last = sb.toasts[sb.toasts.length - 1];
ok('it says the install may still be running, as a warning, not a failure',
last && last.type === 'warning' && !/fail|timed out/i.test(last.message), sb.toasts);
ok('nothing is enabled on a guess', !sb.requests.some(r => r.url === '/api/v3/plugins/toggle'));
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+105
View File
@@ -0,0 +1,105 @@
// The Plugin Store's category filter offers the categories its plugins have.
//
// The template listed seven fixed categories. The registry uses about
// twenty (productivity, utility, transit, finance, ...), so roughly a third
// of the store could not be filtered to at all, and "Financial" missed the
// plugin filed under "finance". The options are now built from the store's
// plugins, as the Starlark section builds its own; the template ships only
// "All Categories".
//
// Runs the whole of plugins_manager.js in the sandbox.
const fs = require('fs');
const path = require('path');
const { create, templateAttributes } = require('../plugins_manager_sandbox');
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) : '')));
const STORE = [
{ id: 'nfl', name: 'NFL', category: 'sports' },
{ id: 'nba', name: 'NBA', category: 'Sports' },
{ id: 'todo', name: 'Todo', category: 'productivity' },
{ id: 'stocks', name: 'Stocks', category: 'finance' },
{ id: 'crypto', name: 'Crypto', category: 'financial' },
{ id: 'bus', name: 'Bus', category: 'transit' },
{ id: 'mystery', name: 'Mystery' },
];
function route(method, url) {
if (url.startsWith('/api/v3/plugins/store/list')) return { json: { status: 'success', data: { plugins: STORE } } };
if (url.startsWith('/api/v3/plugins/installed')) return { json: { status: 'success', data: { plugins: [] } } };
if (url.startsWith('/api/v3/plugins/store/github-status')) {
return { json: { status: 'success', data: { token_status: 'valid', authenticated: true, rate_limit: 5000 } } };
}
if (url.startsWith('/api/v3/plugins/saved-repositories')) {
return { json: { status: 'success', data: { repositories: [] } } };
}
if (url.startsWith('/api/v3/display/on-demand/status')) {
return { json: { status: 'success', data: { state: {}, service: {} } } };
}
return { json: { status: 'success' } };
}
const options = sel => sel.children.map(o => o.value);
const cardIds = sb => [...sb.el('plugin-store-grid').innerHTML.matchAll(/<h4[^>]*>([^<]*)<\/h4>/g)].map(m => m[1]);
(async () => {
console.log('\nthe template');
{
const html = fs.readFileSync(path.resolve(__dirname,
'../../../web_interface/templates/v3/partials/plugins.html'), 'utf8');
const start = html.indexOf('<select id="plugin-category"');
const block = html.slice(start, html.indexOf('</select>', start));
const shipped = [...block.matchAll(/<option value="([^"]*)"/g)].map(m => m[1]);
ok('ships only "All Categories"', JSON.stringify(shipped) === JSON.stringify(['']), shipped);
}
const sb = create({ route });
const attrs = templateAttributes('plugin-category').attrs;
const select = sb.el('plugin-category', 'select', attrs);
sb.el('plugin-store-grid');
sb.el('installed-plugins-grid');
sb.window.initPluginsPage();
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'the store list');
await sb.settle();
console.log('\noptions come from the store\'s plugins');
ok('"All Categories" is still the first choice', /<option value="">All Categories<\/option>/.test(select.innerHTML),
select.innerHTML);
ok('every category a plugin has is offered once, whatever its case',
JSON.stringify(options(select)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
options(select));
ok('labels are capitalised',
(select.children.find(o => o.value === 'productivity') || {}).textContent === 'Productivity');
console.log('\nchoosing one filters to it');
select.value = 'productivity';
select.dispatch('change');
ok('productivity shows its plugin', JSON.stringify(cardIds(sb)) === JSON.stringify(['Todo']), cardIds(sb));
select.value = 'finance';
select.dispatch('change');
ok('finance is not lost to "financial"', JSON.stringify(cardIds(sb)) === JSON.stringify(['Stocks']), cardIds(sb));
select.value = 'sports';
select.dispatch('change');
ok('one option covers both spellings of sports',
JSON.stringify(cardIds(sb).sort()) === JSON.stringify(['NBA', 'NFL']), cardIds(sb));
console.log('\nthe partial is swapped back in (tab switch)');
{
// A fresh <select> from the template, the store list still cached.
const fresh = new sb.FakeElement('plugin-category', 'select', attrs);
select.parentNode.replaceChild(fresh, select);
sb.window.searchPluginStore(false);
await sb.settle();
ok('the new select is filled from the cache',
JSON.stringify(options(fresh)) === JSON.stringify(['finance', 'financial', 'productivity', 'sports', 'transit']),
options(fresh));
ok('keeping the chosen category', fresh.value === 'sports', fresh.value);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+138
View File
@@ -0,0 +1,138 @@
// The store's Install button: which plugin it enables afterwards, and when.
//
// 1. Weather, Music, Stocks and Leaderboard are registry entries (`weather`)
// whose manifests declare another id (`ledmatrix-weather`). The plugin
// list, its config section and /plugins/toggle know them by that id, but
// the button enabled the registry id: /plugins/toggle answered 404
// "Plugin not found" and the plugin stayed disabled behind "installed,
// but enabling it failed". It now enables the id the install answer
// names (`plugin_id`), or, from an answer without one, the installed
// entry the store entry matches (its id, plugin_path name or aliases).
//
// 2. Reinstall (the same button on an installed plugin) enabled it too, so
// reinstalling a plugin the user had switched off switched it back on.
// Only a fresh install enables.
//
// Runs the whole of plugins_manager.js in the sandbox against a fake API.
const { create } = require('../plugins_manager_sandbox');
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) : '')));
const STORE = [
{ id: 'weather', name: 'Weather', category: 'weather', plugin_path: 'plugins/ledmatrix-weather',
aliases: ['ledmatrix-weather'] },
{ id: 'clock-simple', name: 'Clock', category: 'time', plugin_path: 'plugins/clock-simple', aliases: [] },
];
// A server with one install in flight. `queue` false answers the install
// directly; `names` false leaves plugin_id out of the answer (an older
// server); `installsAs` is the id the installed manifest declares.
function server({ installed = [], queue = true, names = true, installsAs }) {
const state = { installed: installed.map(p => ({ ...p })), polls: 0 };
const done = (id) => {
if (!state.installed.some(p => p.id === installsAs)) {
state.installed.push({ id: installsAs, name: id, enabled: false });
}
const result = { success: true, message: `Plugin ${id} installed successfully`, restart_required: false };
if (names) result.plugin_id = installsAs;
return result;
};
state.route = (method, url, body) => {
if (url.startsWith('/api/v3/plugins/store/list')) {
return { json: { status: 'success', data: { plugins: STORE } } };
}
if (url.startsWith('/api/v3/plugins/installed')) {
return { json: { status: 'success', data: { plugins: state.installed.map(p => ({ ...p })) } } };
}
if (method === 'POST' && url === '/api/v3/plugins/install') {
if (queue) return { json: { status: 'success', message: 'queued', data: { operation_id: 'op-1' } } };
return { json: { status: 'success', message: 'Plugin installed successfully', ...done(body.plugin_id) } };
}
if (url === '/api/v3/plugins/operation/op-1') {
state.polls++;
if (state.polls < 3) return { json: { status: 'success', data: { status: 'running' } } };
return { json: { status: 'success', data: { status: 'completed', result: done('weather') } } };
}
if (method === 'POST' && url === '/api/v3/plugins/toggle') {
const plugin = state.installed.find(p => p.id === body.plugin_id);
if (!plugin) return { status: 404, json: { status: 'error', message: 'Plugin not found' } };
plugin.enabled = body.enabled;
return { json: { status: 'success', message: `Plugin ${body.plugin_id} enabled successfully` } };
}
return { json: { status: 'success' } };
};
return state;
}
async function install(pluginId, opts) {
const srv = server(opts);
const sb = create({ route: srv.route });
sb.window.searchPluginStore();
await sb.until(() => sb.requests.some(r => r.url.startsWith('/api/v3/plugins/store/list')), 'store list');
await sb.window.pluginManager.loadInstalledPlugins(true);
await sb.settle();
sb.requests.length = 0;
sb.toasts.length = 0;
sb.window.installPlugin(pluginId);
await sb.until(() => sb.toasts.some(t => /installed and enabled|enabling it failed|reinstalled/.test(t.message)),
'the install to finish');
await sb.settle();
const toggles = sb.requests.filter(r => r.url === '/api/v3/plugins/toggle').map(r => r.body);
return { sb, srv, toggles };
}
(async () => {
console.log('\n1. a fresh install enables the id the plugin was installed as');
{
const { srv, toggles, sb } = await install('weather', { installsAs: 'ledmatrix-weather' });
ok('enables ledmatrix-weather, not the registry id',
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
ok('...which the server enabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === true, srv.installed);
ok('says so', sb.toasts.some(t => t.type === 'success' && /installed and enabled/.test(t.message)), sb.toasts);
ok('no "Plugin not found"', !sb.toasts.some(t => /not found|failed/.test(t.message)), sb.toasts);
const lastList = sb.requests.map(r => r.url).lastIndexOf('/api/v3/plugins/installed');
const toggleAt = sb.requests.findIndex(r => r.url === '/api/v3/plugins/toggle');
ok('the installed list is reloaded before enabling, so the new card is there to update',
lastList >= 0 && lastList < toggleAt, sb.requests.map(r => r.method + ' ' + r.url));
}
{
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', names: false });
ok('an answer without plugin_id: the installed entry the store entry matches (its alias)',
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
}
{
const { toggles } = await install('weather', { installsAs: 'ledmatrix-weather', queue: false });
ok('without the operation queue, from the direct answer',
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'ledmatrix-weather', enabled: true }]), toggles);
}
{
const { toggles } = await install('clock-simple', { installsAs: 'clock-simple', names: false });
ok('a plugin installed under its registry id is enabled by that id',
JSON.stringify(toggles) === JSON.stringify([{ plugin_id: 'clock-simple', enabled: true }]), toggles);
}
console.log('\n2. a reinstall leaves the plugin as the user had it');
{
const { srv, toggles, sb } = await install('weather', {
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: false }],
});
ok('sends no toggle', toggles.length === 0, toggles);
ok('the plugin stays disabled', srv.installed.find(p => p.id === 'ledmatrix-weather').enabled === false);
ok('says it was reinstalled', sb.toasts.some(t => t.type === 'success' && /reinstalled/.test(t.message)), sb.toasts);
ok('and reloads the list',
sb.requests.some(r => r.url === '/api/v3/plugins/installed'), sb.requests.map(r => r.url));
}
{
const { toggles } = await install('weather', {
installsAs: 'ledmatrix-weather', installed: [{ id: 'ledmatrix-weather', name: 'Weather', enabled: true }],
});
ok('an enabled plugin is not toggled either', toggles.length === 0, toggles);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+2 -1
View File
@@ -52,7 +52,8 @@ global.installedPlugins = [];
// eslint-disable-next-line no-eval
eval([
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
'function isStorePluginInstalled(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
'function isStorePluginInstalled(pluginIdOrPlugin) {',
'function findInstalledStorePlugin(pluginIdOrPlugin) {', 'function renderPluginStore(plugins) {',
].map(extract).join('\n') + '\nglobal.renderPluginStore = renderPluginStore;'
+ '\nglobal.isStorePluginInstalled = isStorePluginInstalled;');
+60 -3
View File
@@ -52,7 +52,7 @@ function fakeApi(behaviour = {}) {
};
}
function setup(api, { stateList, windowList } = {}) {
function setup(api, { stateList, windowList, pluginManager } = {}) {
global.window = {
PluginAPI: api,
installedPlugins: windowList,
@@ -60,6 +60,7 @@ function setup(api, { stateList, windowList } = {}) {
installedPlugins: stateList,
loadInstalledPlugins: async () => stateList,
},
pluginManager,
};
}
@@ -92,12 +93,68 @@ const noSleep = { sleep: async () => {} };
ok('progress total counts only what is sent',
progress.length === EXPECTED.length && progress.every(([, n]) => n === EXPECTED.length), progress);
}
{
// A page without the plugin manager has no window.installedPlugins.
const api = fakeApi();
setup(api, { stateList: INSTALLED });
await Manager.updateAll(null, noSleep);
ok('the PluginStateManager list (no live list) is filtered the same way',
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
}
console.log('\na second run sends the live list, not the first run\'s snapshot');
{
// Run 1 leaves PluginStateManager holding a, b, c. Then c is uninstalled
// and d installed: plugins_manager.js publishes that only as
// window.installedPlugins. Run 2 used to send a, b, c -- c failed as
// "plugin not found" and d, which had an update waiting, was skipped.
const api = fakeApi({
c: () => { throw { error_code: 'PLUGIN_UPDATE_FAILED', message: 'Plugin update failed: plugin not found' }; },
});
const stale = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
setup(api, { stateList: stale, windowList: [{ id: 'a' }, { id: 'b' }, { id: 'd' }] });
const results = await Manager.updateAll(null, noSleep);
ok('sends exactly what is installed now',
JSON.stringify(api.calls) === JSON.stringify(['a', 'b', 'd']), api.calls);
ok('...so nothing fails over an uninstalled plugin', results.every(r => r.success), results);
}
{
const api = fakeApi();
setup(api, { stateList: INSTALLED, windowList: [] });
const results = await Manager.updateAll(null, noSleep);
ok('an empty live list means nothing is installed: nothing is sent',
api.calls.length === 0 && results.length === 0, api.calls);
}
console.log('\nthe end-of-run refresh redraws the installed grid');
{
// PluginStateManager's refresh replaced window.installedPlugins and
// nothing else: the cards kept "Update to vX" and the Updates badge
// kept its count. The plugin manager's load renders the grid.
const loads = [];
let stateLoads = 0;
const pluginManager = { loadInstalledPlugins: async (force) => { loads.push(force); } };
setup(fakeApi(), { stateList: INSTALLED, windowList: INSTALLED, pluginManager });
window.PluginStateManager.loadInstalledPlugins = async () => { stateLoads++; };
await Manager.updateAll(null, noSleep);
ok('the PluginStateManager list is filtered the same way',
JSON.stringify(api.calls) === JSON.stringify(EXPECTED), api.calls);
ok('reloads through the plugin manager once, forced past its caches',
JSON.stringify(loads) === JSON.stringify([true]), loads);
ok('...instead of PluginStateManager', stateLoads === 0, stateLoads);
}
{
const pluginManager = { loadInstalledPlugins: async () => { throw new Error('offline'); } };
const answer = { status: 'success', data: { update_status: 'updated' }, restart_required: true };
setup(fakeApi({ 'ledmatrix-flights': () => answer }), { windowList: INSTALLED, pluginManager });
const warn = console.warn;
console.warn = () => {};
let results;
try {
results = await Manager.updateAll(null, noSleep);
} finally {
console.warn = warn;
}
ok('a failed plugin-manager reload still returns the results with their restart flag',
Array.isArray(results) && Manager.restartRequest(results) === answer);
}
{
const api = fakeApi();
@@ -0,0 +1,104 @@
"""POST /plugins/install says which id the plugin was installed as.
A registry entry can install under another id: `weather` (aliases
`ledmatrix-weather`) installs a directory whose manifest declares
`ledmatrix-weather`, and that is the id the plugin list, the plugin's config
section and /plugins/toggle know it by. The store's Install button enabled
the new plugin by the registry id, which /plugins/toggle answered with 404
"Plugin not found", so Weather, Music, Stocks and Leaderboard installed
disabled behind an "enabling it failed" warning.
The answer -- the queued operation's result, or the direct response --
carries `plugin_id`: the id the installed manifest declares, found the way
the store's update and uninstall find an install.
"""
import json
from unittest.mock import MagicMock
import pytest
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
INSTALL = "/api/v3/plugins/install"
@pytest.fixture
def store(api_v3_module, tmp_path):
manager = api_v3_module.api_v3.plugin_store_manager
manager.install_plugin.return_value = True
manager.get_registry_info.return_value = None
manager._find_plugin_path.return_value = None
def installed_as(directory, manifest):
path = tmp_path / directory
path.mkdir()
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
manager._find_plugin_path.side_effect = (
lambda pid: path if pid == "weather" else None)
return path
manager.installed_as = installed_as
return manager
@pytest.fixture
def queued(api_v3_module):
queue = MagicMock()
def enqueue(operation_type, plugin_id, operation_callback=None):
queue.callback_result = operation_callback(MagicMock())
return "op-1"
queue.enqueue_operation.side_effect = enqueue
api_v3_module.api_v3.operation_queue = queue
return queue
class TestDirectInstall:
def test_an_aliased_entry_reports_the_manifest_id(self, api_v3_client, store):
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["status"] == "success"
assert body["plugin_id"] == "ledmatrix-weather"
store._find_plugin_path.assert_called_with("weather")
def test_an_entry_installed_under_its_own_id_reports_that(self, api_v3_client, store):
store.installed_as("weather", {"id": "weather"})
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["plugin_id"] == "weather"
def test_an_install_that_cannot_be_found_reports_the_requested_id(self, api_v3_client, store):
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["status"] == "success"
assert body["plugin_id"] == "weather"
def test_a_manifest_id_that_is_not_a_plain_name_is_not_passed_on(self, api_v3_client, store):
store.installed_as("ledmatrix-weather", {"id": "../elsewhere"})
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["plugin_id"] == "weather"
def test_an_unreadable_manifest_reports_the_requested_id(self, api_v3_client, store):
path = store.installed_as("ledmatrix-weather", {})
(path / "manifest.json").write_text("[not json", encoding="utf-8")
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["plugin_id"] == "weather"
def test_the_restart_fields_are_still_sent(self, api_v3_client, store):
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert "restart_required" in body
class TestQueuedInstall:
def test_the_operation_result_names_the_manifest_id(self, api_v3_client, store, queued):
store.installed_as("ledmatrix-weather", {"id": "ledmatrix-weather"})
body = api_v3_client.post(INSTALL, json={"plugin_id": "weather"}).get_json()
assert body["data"]["operation_id"] == "op-1"
assert queued.callback_result["success"] is True
assert queued.callback_result["plugin_id"] == "ledmatrix-weather"
def test_an_install_that_cannot_be_found_names_the_requested_id(
self, api_v3_client, store, queued):
api_v3_client.post(INSTALL, json={"plugin_id": "weather"})
assert queued.callback_result["plugin_id"] == "weather"
@@ -0,0 +1,59 @@
"""GET /api/v3/plugins/installed carries each plugin's ``display_modes``.
The on-demand modal (plugins_manager.js) fills its Display Mode list from
``plugin.display_modes``, but the route never included the field, so every
plugin offered one option -- its own id -- under "This plugin exposes a
single display mode". The display turns that id into the plugin's first
mode, so a multi-mode plugin could only be started, and pinned, on that one.
The modes come from the plugin catalog (the manifests the web process
discovered), the same source /display/modes and on-demand/start use.
"""
from unittest.mock import MagicMock
import pytest
from test._api_v3_test_helpers import ( # noqa: F401 - fixtures
api_v3_client, api_v3_module,
)
@pytest.fixture
def installed(api_v3_module, api_v3_client, tmp_path):
def _get(declared_modes):
api = api_v3_module.api_v3
# The listing's own metadata says nothing about modes: what the
# route reports must come from the catalog.
info = {'id': 'football-scoreboard', 'name': 'Football', 'version': '1.0.0'}
api.plugin_catalog.plugins_dir = str(tmp_path) # no manifest on disk
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[info])
api.plugin_catalog.get_plugin_display_modes = MagicMock(return_value=declared_modes)
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
api.config_manager.load_config = MagicMock(return_value={})
response = api_v3_client.get('/api/v3/plugins/installed')
assert response.status_code == 200
plugins = [p for p in response.get_json()['data']['plugins']
if p['id'] == 'football-scoreboard']
assert len(plugins) == 1
api.plugin_catalog.get_plugin_display_modes.assert_any_call('football-scoreboard')
return plugins[0]
return _get
def test_every_declared_mode_is_listed_in_order(installed):
modes = ['nfl_live', 'nfl_recent', 'nfl_upcoming']
assert installed(modes)['display_modes'] == modes
def test_a_single_mode_plugin_lists_its_one_mode(installed):
assert installed(['clock-simple'])['display_modes'] == ['clock-simple']
def test_no_declared_modes_is_an_empty_list(installed):
# The modal falls back to the plugin id for an empty list.
assert installed([])['display_modes'] == []
def test_a_hand_edited_manifest_cannot_put_non_strings_in_the_list(installed):
assert installed(['nfl_live', 7, None, {'x': 1}])['display_modes'] == ['nfl_live']
+27
View File
@@ -13,6 +13,7 @@ The invariants that keep this change safe:
inline path exactly.
"""
import asyncio
import os
import sys
import threading
@@ -209,6 +210,32 @@ class TestFailurePaths:
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
pm.get_plugin_lock(plugin_id).release()
@pytest.mark.parametrize("raised", [asyncio.CancelledError, SystemExit])
def test_update_raising_a_base_exception_still_releases_the_plugin(self, pm, raised):
"""asyncio.CancelledError and SystemExit derive from BaseException,
not Exception. Raised from update() on the worker, one skipped the
bookkeeping entirely: the plugin kept its lock and stayed RUNNING for
the life of the process -- never updated again, and every display()
skipped as busy."""
class CancellingPlugin(SlowPlugin):
def update(self):
self.update_calls += 1
raise raised()
plugin_id = _install(pm, CancellingPlugin())
pm.run_scheduled_updates()
deadline = time.monotonic() + 3
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.2)
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
pm.get_plugin_lock(plugin_id).release()
assert pm.state_manager.can_execute(plugin_id) is True
assert pm.plugin_last_update.get(plugin_id, 0) > 0
error = pm.state_manager.get_error_info(plugin_id)
assert error is not None and error["error_type"] == raised.__name__
def test_unloaded_while_queued_is_harmless(self, pm):
"""Exercise the public unload_plugin() lifecycle rather than
deleting pm.plugins directly: queue the target's update behind a
+99
View File
@@ -0,0 +1,99 @@
"""A cache key too long to be a filename still gets a cache file.
The calendar plugin's key joins every calendar id the user picked; on a real
install it passed 300 bytes, and since ext4 caps a filename at 255 every write
failed with ENAMETOOLONG -- logged as "permission denied", every update.
"""
import logging
import os
from unittest.mock import patch
from src.cache.disk_cache import DiskCache, _MAX_KEY_FILENAME_BYTES, _filename_stem
from src.cache_manager import CacheManager
# The shape of the key that failed on hdpi, ids anonymised.
CALENDAR_KEY = (
"calendar_events_someone@example.com_en.usa#holiday@group.v.calendar.google.com_"
"family13997378751670666433@group.calendar.google.com_ncaaf_-m-07kbp5_"
"%47eorgia+%42ulldogs+football#sports@group.v.calendar.google.com_nfl_-m-07l24_"
"%54ampa+%42ay+%42uccaneers#sports@group.v.calendar.google.com_primary"
)
# ext4/xfs/btrfs NAME_MAX; set()'s temp file adds 15 bytes to the stem.
NAME_MAX = 255
TEMP_OVERHEAD = len(".") + len(".json") + len(".") + 8
def test_the_real_key_was_too_long_to_write():
assert len((CALENDAR_KEY + ".json").encode()) > NAME_MAX - 10
def test_a_long_key_round_trips(tmp_path):
cache = DiskCache(str(tmp_path))
cache.set(CALENDAR_KEY, {"events": [1, 2, 3]})
assert cache.get(CALENDAR_KEY, max_age=None) == {"events": [1, 2, 3]}
path = cache.get_cache_path(CALENDAR_KEY)
assert os.path.isfile(path)
stem = os.path.basename(path)[:-len(".json")]
assert len(stem.encode()) + TEMP_OVERHEAD <= NAME_MAX
def test_short_keys_keep_their_filename(tmp_path):
cache = DiskCache(str(tmp_path))
exactly = "k" * _MAX_KEY_FILENAME_BYTES
assert cache.get_cache_path("weather_current") == str(tmp_path / "weather_current.json")
assert cache.get_cache_path(exactly) == str(tmp_path / f"{exactly}.json")
assert cache.get_cache_path(exactly + "k") != str(tmp_path / f"{exactly}k.json")
def test_long_keys_sharing_a_prefix_stay_apart(tmp_path):
cache = DiskCache(str(tmp_path))
first, second = CALENDAR_KEY + "_a", CALENDAR_KEY + "_b"
cache.set(first, {"which": "a"})
cache.set(second, {"which": "b"})
assert cache.get_cache_path(first) != cache.get_cache_path(second)
assert cache.get(first, max_age=None) == {"which": "a"}
assert cache.get(second, max_age=None) == {"which": "b"}
def test_the_prefix_never_splits_a_character():
key = "news_" + "é" * 300 # two bytes each, so the cut lands mid-character
stem = _filename_stem(key)
assert stem.startswith("news_é")
assert len(stem.encode("utf-8")) <= _MAX_KEY_FILENAME_BYTES
stem.encode("utf-8").decode("utf-8") # well-formed
def test_a_stem_listed_by_the_web_ui_deletes_the_same_file(tmp_path):
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
manager = CacheManager()
try:
manager.save_cache(CALENDAR_KEY, {"events": []})
listed = [entry["key"] for entry in manager.list_cache_files()]
assert len(listed) == 1
manager.clear_cache(listed[0])
assert [n for n in os.listdir(tmp_path) if n.endswith(".json")] == []
finally:
manager.stop_cleanup_thread()
def test_a_failed_write_names_the_real_error(tmp_path, monkeypatch, caplog):
blocker = tmp_path / "a-file"
blocker.write_text("")
# No writable fallback either, so set() gives up and says why.
monkeypatch.setattr(os.path, "expanduser", lambda _p: str(blocker / "home"))
cache = DiskCache(str(tmp_path / "missing"))
with caplog.at_level(logging.WARNING):
cache.set("weather_current", {"t": 1})
gave_up = [r.getMessage() for r in caplog.records if "Could not write cache" in r.getMessage()]
assert len(gave_up) == 1
assert "permission denied" not in gave_up[0]
assert os.strerror(2) in gave_up[0] # ENOENT: the directory does not exist
+95
View File
@@ -0,0 +1,95 @@
"""The memory tier never serves a record older than the reader asked for.
A record read from disk went into the memory tier timed from the read, not
from when it was written, so get(max_age=300) could hand out data up to twice
that old: after a restart, after the memory sweep, or in a second process that
loaded a record once and kept serving it.
"""
import time
from unittest.mock import patch
import pytest
from src.cache_manager import CacheManager
class Clock:
def __init__(self, now):
self.now = now
def __call__(self):
return self.now
@pytest.fixture
def clock(monkeypatch):
fake = Clock(1_800_000_000.0)
monkeypatch.setattr(time, "time", fake)
return fake
def _manager(path):
# No disk sweep: it judges files by their real mtime against the fake
# clock and would delete them as months old.
with patch('src.cache_manager.CacheManager._get_writable_cache_dir',
return_value=str(path)), \
patch('src.cache_manager.CacheManager.start_cleanup_thread'):
return CacheManager()
def test_a_record_loaded_late_expires_on_its_own_timestamp(tmp_path, clock):
writer = _manager(tmp_path)
writer.set("weather_current", {"t": 1})
reader = _manager(tmp_path) # a restart, or the other process
clock.now += 250
assert reader.get("weather_current", max_age=300) == {"t": 1}
clock.now += 100 # the data is 350 s old; it sat in memory for 100 s
assert reader.get("weather_current", max_age=300) is None
def test_a_stored_ttl_bounds_the_memory_copy_too(tmp_path, clock):
writer = _manager(tmp_path)
writer.set("odds_espn_football_nfl_401", {"spread": 6.5}, ttl=60)
reader = _manager(tmp_path)
clock.now += 55
assert reader.get("odds_espn_football_nfl_401", max_age=3600) == {"spread": 6.5}
clock.now += 60
assert reader.get("odds_espn_football_nfl_401", max_age=3600) is None
def test_a_stale_memory_copy_gives_way_to_a_newer_write_on_disk(tmp_path, clock):
writer = _manager(tmp_path)
reader = _manager(tmp_path)
writer.set("stocks_AAPL", {"price": 1})
assert reader.get("stocks_AAPL", max_age=300) == {"price": 1}
clock.now += 280
writer.set("stocks_AAPL", {"price": 2})
clock.now += 40 # reader's copy: 40 s in memory, 320 s old
assert reader.get("stocks_AAPL", max_age=300) == {"price": 2}
def test_fresh_records_are_still_served_from_memory(tmp_path, clock):
manager = _manager(tmp_path)
manager.set("news_NFL", {"items": []})
clock.now += 100
with patch.object(manager._disk_cache_component, "get") as disk_get:
assert manager.get("news_NFL", max_age=300) == {"items": []}
disk_get.assert_not_called()
def test_max_age_none_and_records_without_a_timestamp_never_expire(tmp_path, clock):
manager = _manager(tmp_path)
manager.set("plugin_health_x", {"ok": True})
manager.save_cache("raw_record", {"no": "timestamp"})
clock.now += 10 ** 6
assert manager.get("plugin_health_x", max_age=None) == {"ok": True}
assert manager.get_cached_data("raw_record", max_age=None) == {"no": "timestamp"}
+199
View File
@@ -0,0 +1,199 @@
"""ConfigService notifies subscribers outside its lock, in order.
Subscribers ran while _load_config held the service's lock. The display's
per-plugin subscriber is PluginManager.apply_config_change, which waits up to
PLUGIN_LOCK_TIMEOUT (5 s) for a busy plugin. The same save that toggles a
plugin's ``enabled`` flags a reconcile, and the render thread runs it: its
get_config() -- and the unsubscribe() of a plugin it disables -- waited behind
every slow callback, freezing the panel for up to 5 s per busy plugin.
What callers could rely on before still holds: one reload's notifications
finish before the next reload's start, and a callback unsubscribe() removed is
not running, and will not run, once unsubscribe() returns.
"""
import itertools
import json
import os
import threading
import time
import pytest
from src.config_manager import ConfigManager
from src.config_service import ConfigService
SLOW = 2.0 # how long a blocked callback waits before giving up
@pytest.fixture
def service(tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"display": {"brightness": 50},
"weather": {"enabled": True}}),
encoding="utf-8")
manager = ConfigManager(str(config_path), str(tmp_path / "config_secrets.json"))
manager.template_path = str(tmp_path / "no-template.json")
svc = ConfigService(manager, enable_hot_reload=False)
yield svc, config_path
svc.shutdown()
_saves = itertools.count(1)
def _save(config_path, **sections):
config = json.loads(config_path.read_text(encoding="utf-8"))
config.update(sections)
config_path.write_text(json.dumps(config), encoding="utf-8")
# ConfigManager re-reads only when (mtime, size) moves. Two quick saves of
# the same size can share an mtime tick (about 16 ms on Windows), so step
# it forward explicitly.
st = config_path.stat()
os.utime(config_path, ns=(st.st_atime_ns, st.st_mtime_ns + next(_saves) * 50_000_000))
def _reload_in_background(svc):
thread = threading.Thread(target=svc._load_config, daemon=True)
thread.start()
return thread
def test_get_config_does_not_wait_for_a_slow_subscriber(service):
svc, config_path = service
entered, release = threading.Event(), threading.Event()
def slow(_old, _new):
entered.set()
release.wait(SLOW)
svc.subscribe(slow, plugin_id="weather")
_save(config_path, weather={"enabled": False})
reload = _reload_in_background(svc)
assert entered.wait(SLOW)
start = time.monotonic()
config = svc.get_config()
waited = time.monotonic() - start
release.set()
reload.join(SLOW)
assert waited < 0.5
# Swapped before anyone was told: a subscriber that reads it sees the new one.
assert config["weather"]["enabled"] is False
def test_unsubscribing_another_callback_does_not_wait(service):
svc, config_path = service
entered, release = threading.Event(), threading.Event()
def slow(_old, _new):
entered.set()
release.wait(SLOW)
def other(_old, _new):
pass
svc.subscribe(slow, plugin_id="weather")
svc.subscribe(other, plugin_id="clock")
_save(config_path, weather={"enabled": False})
reload = _reload_in_background(svc)
assert entered.wait(SLOW)
start = time.monotonic()
svc.unsubscribe(other, plugin_id="clock")
waited = time.monotonic() - start
release.set()
reload.join(SLOW)
assert waited < 0.5
def test_a_callback_unsubscribed_mid_notification_is_not_called(service):
svc, config_path = service
entered, release = threading.Event(), threading.Event()
called = []
def slow_global(_old, _new): # global subscribers are notified first
entered.set()
release.wait(SLOW)
def weather(_old, _new):
called.append("weather")
svc.subscribe(slow_global)
svc.subscribe(weather, plugin_id="weather")
_save(config_path, weather={"enabled": False})
reload = _reload_in_background(svc)
assert entered.wait(SLOW)
svc.unsubscribe(weather, plugin_id="weather")
release.set()
reload.join(SLOW)
assert called == []
def test_unsubscribe_waits_for_its_own_callback_to_return(service):
svc, config_path = service
entered, release = threading.Event(), threading.Event()
returned = threading.Event()
def slow(_old, _new):
entered.set()
release.wait(SLOW)
returned.set()
svc.subscribe(slow, plugin_id="weather")
_save(config_path, weather={"enabled": False})
reload = _reload_in_background(svc)
assert entered.wait(SLOW)
threading.Timer(0.2, release.set).start()
svc.unsubscribe(slow, plugin_id="weather")
assert returned.is_set()
reload.join(SLOW)
def test_a_callback_may_read_config_and_unsubscribe_itself(service):
svc, config_path = service
seen = []
def once(_old, _new):
seen.append(svc.get_config()["weather"]["enabled"])
svc.unsubscribe(once, plugin_id="weather")
svc.subscribe(once, plugin_id="weather")
_save(config_path, weather={"enabled": False})
reload = _reload_in_background(svc)
reload.join(SLOW)
assert not reload.is_alive()
assert seen == [False]
def test_two_reloads_notify_in_order(service):
svc, config_path = service
entered, release = threading.Event(), threading.Event()
seen = []
def record(old, new):
seen.append((old["brightness"], new["brightness"]))
if len(seen) == 1:
entered.set()
release.wait(SLOW)
svc.subscribe(record, plugin_id="display")
_save(config_path, display={"brightness": 60})
first = _reload_in_background(svc)
assert entered.wait(SLOW)
_save(config_path, display={"brightness": 100})
second = _reload_in_background(svc)
time.sleep(0.2)
release.set()
first.join(SLOW)
second.join(SLOW)
assert seen == [(50, 60), (60, 100)]
+112
View File
@@ -0,0 +1,112 @@
"""A plugin duration that is not a number must not stop the display.
Several plugins return their ``display_duration`` setting as it is in
config.json (``return self.config.get('display_duration', 15.0)``), so a
value saved as ``"20"`` or ``null`` -- from the raw config editor, or by
hand -- reached run() as a string or None. _resolve_durations then compared
it with 0, the TypeError went past every handler in the loop, and the
display service exited; systemd restarted it into the same screen and the
same crash.
"""
import logging
import math
import os
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
import pytest
from src.display_controller import DisplayController
from test._run_loop_harness import FakePlugin, RunLoopHarness
def _controller(plugin_modes):
dc = object.__new__(DisplayController)
dc.config = {}
dc.plugin_modes = plugin_modes
return dc
def _plugin(duration, plugin_id='clock-simple'):
plugin = MagicMock()
plugin.plugin_id = plugin_id
plugin.get_display_duration.return_value = duration
return plugin
class TestPluginDurationIsCoerced:
@pytest.mark.parametrize('value, expected', [
('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)])
def test_numbers_and_numeric_strings_are_used(self, value, expected):
dc = _controller({'clock': _plugin(value)})
duration = dc._get_display_duration('clock')
assert duration == expected and isinstance(duration, float)
@pytest.mark.parametrize('value', [
None, '', 'twenty', True, False, float('nan'), float('inf'), 'inf',
[20], {'seconds': 20}])
def test_anything_but_a_finite_number_gets_the_default(self, value):
dc = _controller({'clock': _plugin(value)})
assert dc._get_display_duration('clock') == 30
@pytest.mark.parametrize('value', [0, -5, '-5', '0'])
def test_a_number_not_above_zero_still_gets_the_15s_rule(self, value):
"""Unchanged: _resolve_durations turns it into 15 s, with its warning."""
plugin = _plugin(value)
dc = _controller({'clock': plugin})
base = dc._get_display_duration('clock')
assert dc._resolve_durations(plugin, 'clock', base, False)[1] == 15.0
def test_a_raising_get_display_duration_gets_the_default(self):
plugin = _plugin(None)
plugin.get_display_duration.side_effect = KeyError('display_duration')
assert _controller({'clock': plugin})._get_display_duration('clock') == 30
def test_the_result_feeds_resolve_durations(self):
"""The two calls run() makes back to back, for one screen."""
plugin = _plugin('bad')
dc = _controller({'clock': plugin})
base = dc._get_display_duration('clock')
assert dc._resolve_durations(plugin, 'clock', base, False) == (30, 30)
def test_logged_once_per_plugin(self, caplog):
dc = _controller({'clock': _plugin('twenty'),
'clock_big': _plugin('twenty'),
'calendar': _plugin(None, plugin_id='calendar')})
# clock_big is a second mode of the same plugin.
dc.plugin_modes['clock_big'].plugin_id = 'clock-simple'
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
for _ in range(3):
for mode in ('clock', 'clock_big', 'calendar'):
dc._get_display_duration(mode)
warnings = [r for r in caplog.records if 'display duration' in r.getMessage()]
assert len(warnings) == 2
assert {'clock-simple', 'calendar'} == {
next(p for p in ('clock-simple', 'calendar') if p in r.getMessage())
for r in warnings}
def test_a_good_value_after_a_bad_one_is_used(self):
plugin = _plugin(None)
dc = _controller({'clock': plugin})
assert dc._get_display_duration('clock') == 30
plugin.get_display_duration.return_value = 45
assert dc._get_display_duration('clock') == 45.0
class TestRunLoopSurvives:
"""Through the real run() on the harness's fake clock."""
@pytest.mark.parametrize('duration, shown_for', [('20', 20.0), (None, 30.0),
('twenty', 30.0)])
def test_the_screen_runs_and_the_rotation_goes_on(self, tmp_path, duration, shown_for):
harness = RunLoopHarness(tmp_path, horizon=120)
harness.add_plugin(FakePlugin("weather", ["weather"], duration=30))
harness.add_plugin(FakePlugin("clock-simple", ["clock"], duration=duration))
# Before the fix run() returned at t=30, when the clock came up, and
# the harness raised "run() returned ... before the horizon".
rows = harness.run()["screens"]
clock = next(row for row in rows if row[1] == "clock")
assert math.isclose(clock[2], shown_for, abs_tol=1.0)
assert [row[1] for row in rows][:3] == ["weather", "clock", "weather"]
+46
View File
@@ -346,6 +346,52 @@ class TestSubscriptionStore:
assert client.snapshot_loop_age(snap, now_mono=104.0) is None
class TestReconnectBackoff:
"""StateSubscription._run's waits between connections, without a socket."""
def test_a_connection_that_got_a_snapshot_starts_the_backoff_over(self, hub,
monkeypatch):
"""Three failed tries, then the display is back twice, restarting
each time, then gone again. Each restart is retried after the
shortest wait, not after whatever the waits had grown to."""
sub = client.StateSubscription(paths=['/nowhere'])
script = ['refused', 'refused', 'refused', 'snapshot', 'snapshot', 'refused']
waits = []
def follow():
step = script.pop(0)
if step == 'snapshot': # subscribed, then the display restarted
sub._store(hub.snapshot(), full=True)
raise client.ControlError('closed', 'the display closed the connection')
raise client.ControlError(step)
def wait(seconds):
waits.append(seconds)
return not script # True ends _run, as stop() would
monkeypatch.setattr(sub, '_follow', follow)
monkeypatch.setattr(sub._stop, 'wait', wait)
sub._run()
first = client._RECONNECT_MIN_SECONDS
assert waits == [first, 2 * first, 4 * first, first, first, 2 * first]
def test_a_display_without_the_stream_is_still_retried_slowly(self, monkeypatch):
sub = client.StateSubscription(paths=['/nowhere'])
waits = []
def follow():
raise client.ControlError('unknown_command')
def wait(seconds):
waits.append(seconds)
return len(waits) == 2
monkeypatch.setattr(sub, '_follow', follow)
monkeypatch.setattr(sub._stop, 'wait', wait)
sub._run()
assert waits == [client._RECONNECT_MAX_SECONDS] * 2
# --- a real socket ------------------------------------------------------------------
def _wait_until(predicate, timeout=5.0):
@@ -58,3 +58,83 @@ def test_a_reloaded_plugin_still_gets_its_own_bare_module(plugins):
assert reloaded.WHO == "alpha"
assert sys.path.index(str(plugins["alpha"])) < sys.path.index(str(plugins["beta"]))
assert sys.path.count(str(plugins["alpha"])) == 1
# -- sub-packages ------------------------------------------------------------
#
# A plugin that keeps helpers in a package (``providers/feed.py``, imported as
# ``from providers.feed import ...``) leaves dotted entries in sys.modules.
# Only the bare ``providers`` used to be tracked, so ``providers.feed`` outlived
# the plugin: a reload after a store update re-ran the new manager.py against
# the old feed.py, until the display restarted. Elections (providers/),
# flights (enrichment/) and olympics (data/, renderers/) ship packages.
@pytest.fixture
def package_plugin(tmp_path):
before_path = list(sys.path)
before_modules = set(sys.modules)
plugin_dir = tmp_path / "pkgdemo"
(plugin_dir / "providers").mkdir(parents=True)
(plugin_dir / "providers" / "__init__.py").write_text("", encoding="utf-8")
(plugin_dir / "providers" / "feed.py").write_text("VERSION = 'v1'\n", encoding="utf-8")
(plugin_dir / "manager.py").write_text(
"from providers.feed import VERSION\n", encoding="utf-8")
yield plugin_dir
sys.path[:] = before_path
for key in set(sys.modules) - before_modules:
sys.modules.pop(key, None)
def test_a_reloaded_plugin_runs_its_updated_subpackage_module(package_plugin):
loader = PluginLoader()
assert loader.load_module("pkgdemo", package_plugin, "manager.py").VERSION == "v1"
_unload(loader, "pkgdemo")
# The store update: a different size, so no cached bytecode can match.
(package_plugin / "providers" / "feed.py").write_text(
"VERSION = 'v2 from the update'\n", encoding="utf-8")
reloaded = loader.load_module("pkgdemo", package_plugin, "manager.py")
assert reloaded.VERSION == "v2 from the update"
def test_unload_drops_the_plugins_subpackage_modules(package_plugin):
loader = PluginLoader()
loader.load_module("pkgdemo", package_plugin, "manager.py")
# Still importable while the plugin runs, as before.
assert "providers.feed" in sys.modules
_unload(loader, "pkgdemo")
assert not [k for k in sys.modules if k.startswith("providers")]
def test_a_failed_load_leaves_no_subpackage_module_behind(package_plugin):
(package_plugin / "manager.py").write_text(
"from providers.feed import VERSION\nraise RuntimeError('broken')\n",
encoding="utf-8")
loader = PluginLoader()
with pytest.raises(RuntimeError):
loader.load_module("pkgdemo", package_plugin, "manager.py")
assert not [k for k in sys.modules if k.startswith("providers")]
def test_unload_leaves_packages_from_outside_the_plugin_alone(package_plugin, tmp_path):
# A library the plugin imports is not the plugin's to drop.
lib_root = tmp_path / "site"
(lib_root / "extlib").mkdir(parents=True)
(lib_root / "extlib" / "__init__.py").write_text("", encoding="utf-8")
(lib_root / "extlib" / "sub.py").write_text("X = 1\n", encoding="utf-8")
sys.path.append(str(lib_root))
(package_plugin / "manager.py").write_text(
"import extlib.sub\nfrom providers.feed import VERSION\n", encoding="utf-8")
loader = PluginLoader()
loader.load_module("pkgdemo", package_plugin, "manager.py")
_unload(loader, "pkgdemo")
assert "extlib.sub" in sys.modules
assert "extlib" in sys.modules
+81
View File
@@ -0,0 +1,81 @@
"""A dev plugin linked in under a name its checkout does not share still loads.
``scripts/dev/dev_plugin_setup.sh`` links a checkout into the plugins
directory under the plugin's id: ``link-github foo <url>`` clones
``ledmatrix-foo`` (the repository naming convention) and links it as
``plugins/foo``. ``contained_plugin_dir`` resolved the link and looked for the
*target's* folder name, ``ledmatrix-foo``, among the plugins directory's
entries. There is none, so ``install_dependencies`` refused the plugin as
outside the plugins directory and the load failed with "Dependency
installation failed" -- even with no requirements.txt at all.
The containment it exists for still holds: the answer is always rebuilt from
an entry enumerated under the plugins directory.
Skipped where this process cannot create a symlink (Windows without the
privilege).
"""
import os
from unittest.mock import MagicMock, patch
import pytest
from src.plugin_system.plugin_loader import PluginLoader, contained_plugin_dir
def _symlink_or_skip(target, link):
try:
os.symlink(target, link, target_is_directory=True)
except (OSError, NotImplementedError) as e:
pytest.skip(f"cannot create a symlink here: {e}")
@pytest.fixture
def linked(tmp_path):
checkout = tmp_path / "dev-plugins" / "ledmatrix-foo"
checkout.mkdir(parents=True)
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
link = plugins_dir / "foo"
_symlink_or_skip(checkout, link)
return plugins_dir, link, checkout
def test_a_link_resolves_to_its_own_entry_in_the_plugins_dir(linked):
plugins_dir, link, _checkout = linked
assert contained_plugin_dir(link, plugins_dir) == os.path.join(
os.path.realpath(plugins_dir), "foo")
def test_a_linked_plugin_without_requirements_needs_no_install(linked):
plugins_dir, link, _checkout = linked
with patch("subprocess.run") as pip:
assert PluginLoader().install_dependencies(link, "foo", plugins_dir=plugins_dir) is True
pip.assert_not_called()
@patch("src.plugin_system.plugin_loader.requirements_are_satisfied", return_value=False)
def test_a_linked_plugins_requirements_are_installed_through_the_link(_satisfied, linked):
plugins_dir, link, checkout = linked
(checkout / "requirements.txt").write_text("package1==1.0.0\n", encoding="utf-8")
with patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")) as pip:
assert PluginLoader().install_dependencies(link, "foo", plugins_dir=plugins_dir) is True
argv = pip.call_args[0][0]
assert argv[argv.index("-r") + 1] == os.path.join(
os.path.realpath(plugins_dir), "foo", "requirements.txt")
def test_a_link_outside_the_plugins_dir_is_still_refused(linked, tmp_path):
plugins_dir, _link, checkout = linked
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
stray = elsewhere / "bar"
_symlink_or_skip(checkout, stray)
assert contained_plugin_dir(stray, plugins_dir) is None
assert contained_plugin_dir(plugins_dir / ".." / "elsewhere" / "bar", plugins_dir) is None
+18
View File
@@ -189,6 +189,24 @@ class TestPluginExecutor:
assert result is False
def test_a_base_exception_is_a_failure_not_a_timeout(self):
"""asyncio.CancelledError derives from BaseException. Uncaught on
the executor's thread it ended the thread with the call never marked
complete, so a call that failed at once was reported, and recorded,
as timing out."""
import asyncio
import pytest
from src.exceptions import PluginError
from src.plugin_system.plugin_executor import PluginExecutor
executor = PluginExecutor(default_timeout=5.0)
def cancelled():
raise asyncio.CancelledError()
with pytest.raises(PluginError) as raised:
executor.execute_with_timeout(cancelled, plugin_id="test_plugin")
assert isinstance(raised.value.__cause__, asyncio.CancelledError)
class TestPluginHealth:
"""Test plugin health monitoring."""
+112
View File
@@ -0,0 +1,112 @@
"""
ScrollHelper frames for a strip narrower than the panel, and other wraps.
A frame that runs past the end of the strip continues from its head: column
j of the frame is strip column (position + j) modulo the strip's width. The
wrap path sliced the strip's tail and then "the rest of the frame" from its
head, which assumed the head was at least that wide. For a strip narrower
than the panel it raised ValueError at every position, so a narrow strip
(Vegas composes one when its content is narrower than the chain, with its
lead-in of 0) logged a traceback every frame instead of drawing.
"""
import numpy as np
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
W, H = 128, 32
def _strip(width, height=H):
"""A strip whose every column is distinct: R and B are the column number."""
columns = np.arange(width)
pixels = np.zeros((height, width, 3), dtype=np.uint8)
pixels[:, :, 0] = columns % 256
pixels[:, :, 1] = 255 - (columns % 256)
pixels[:, :, 2] = columns // 256
return Image.fromarray(pixels, 'RGB')
def _helper(strip_width, sub_pixel=False):
sh = ScrollHelper(W, H)
sh.set_scrolling_image(_strip(strip_width))
sh.sub_pixel_scrolling = sub_pixel
return sh
def _frame(sh, position):
sh.scroll_position = position
frame = sh.get_visible_portion()
assert frame is not None and frame.size == (W, H) and frame.mode == 'RGB'
return np.asarray(frame)
def _wrapped(sh, start):
"""What the panel should show from ``start``: the strip, wrapping."""
return sh.cached_array[:, np.arange(start, start + W) % sh.cached_array.shape[1]]
class TestNarrowStrip:
@pytest.mark.parametrize('strip_width', [1, 40, 50, W - 1])
@pytest.mark.parametrize('position', [0, 10, 39])
def test_frame_repeats_the_strip_across_the_panel(self, strip_width, position):
sh = _helper(strip_width)
position %= strip_width
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
def test_a_composed_strip_without_lead_in(self):
# How Vegas builds its strip: lead_gap=0 (vegas_scroll.lead_in_width).
sh = ScrollHelper(W, H)
sh.create_scrolling_image([_strip(40)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.total_scroll_width == 40
for position in range(40):
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
def test_a_whole_pass_scrolls_without_raising(self):
sh = _helper(50)
sh.set_pixels_per_frame(3)
for _ in range(60):
sh.update_scroll_position()
assert sh.get_visible_portion().size == (W, H)
@pytest.mark.parametrize('position', [0.5, 10.25, 49.5])
def test_sub_pixel_blend_of_a_narrow_strip(self, position):
sh = _helper(50, sub_pixel=True)
frame = _frame(sh, position)
start = int(position)
near, far = _wrapped(sh, start), _wrapped(sh, start + 1)
lo, hi = np.minimum(near, far), np.maximum(near, far)
assert (frame >= lo).all() and (frame <= hi).all()
class TestWrapOfAWideStrip:
"""Unchanged: the tail, then the head."""
@pytest.mark.parametrize('position', [200 - W + 1, 150, 199])
def test_tail_then_head(self, position):
sh = _helper(200)
frame = _frame(sh, position)
tail = 200 - position
assert np.array_equal(frame[:, :tail], sh.cached_array[:, position:])
assert np.array_equal(frame[:, tail:], sh.cached_array[:, :W - tail])
def test_at_the_end_shows_the_head(self):
sh = _helper(200)
assert np.array_equal(_frame(sh, 200), sh.cached_array[:, :W])
def test_sub_pixel_at_the_last_column(self):
sh = _helper(200, sub_pixel=True)
assert _frame(sh, 199.5).shape == (H, W, 3)
def test_a_position_before_the_start_wraps_too(self):
# Slicing [-10:118] of the array was an empty slice: frombytes raised.
sh = _helper(200)
assert np.array_equal(_frame(sh, -10), _wrapped(sh, -10))
def test_a_zero_width_strip_is_a_black_frame():
sh = ScrollHelper(W, H)
sh.set_scrolling_image(Image.new('RGB', (0, H)))
assert not _frame(sh, 0).any()
+91
View File
@@ -13,6 +13,8 @@ body. That is what let all eight adopt this with byte-identical renders.
import logging
import json
import os
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
import pytest
@@ -169,6 +171,95 @@ class TestDateAndTime:
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
class TestWeekdayMatchesThePrintedDate:
"""The weekday is the printed date's, whichever zone printed it.
The extractor prints "M/D" in the plugin's resolved zone (its own
setting, else the global one, else the system zone). The card is handed
only the plugin's config, whose ``timezone`` ships as "" -- so a weekday
taken in card_tzinfo's zone was UTC's, and an evening kickoff in the
Americas read "Sat Oct 2" for a Friday game.
"""
WEEKDAY = {"timezone": "", "scroll_card": {"date_format": "weekday"}}
@staticmethod
def _as_printed(start_utc, zone):
"""The game dict and the date text, as the extractor builds them."""
local = datetime.fromisoformat(start_utc).astimezone(ZoneInfo(zone))
game = {"start_time_utc": datetime.fromisoformat(start_utc),
"game_date": f"{local.month}/{local.day}"}
want = f"{C.WEEKDAY_ABBR[local.weekday()]} {C.MONTH_ABBR[local.month - 1]} {local.day}"
return game, want
@pytest.mark.parametrize("start_utc, zone, want", [
# Friday 8 PM EDT is Saturday in UTC.
("2026-10-03T00:00:00+00:00", "America/New_York", "Fri Oct 2"),
# The night US clocks go back: 8:30 PM EDT Saturday, then 11 PM EST
# Sunday, each the next day in UTC.
("2026-11-01T00:30:00+00:00", "America/New_York", "Sat Oct 31"),
("2026-11-02T04:00:00+00:00", "America/New_York", "Sun Nov 1"),
# New Year's Eve on the west coast is New Year's Day in UTC.
("2027-01-01T04:00:00+00:00", "America/Los_Angeles", "Thu Dec 31"),
# Just east of the date line: Pago Pago's Friday evening.
("2026-10-03T05:00:00+00:00", "Pacific/Pago_Pago", "Fri Oct 2"),
# Just west of it, the other way: Saturday morning in Auckland is
# Friday in UTC -- and the 10 AM game on the day NZ clocks go forward.
("2026-10-02T20:00:00+00:00", "Pacific/Auckland", "Sat Oct 3"),
("2026-09-26T21:00:00+00:00", "Pacific/Auckland", "Sun Sep 27"),
# UTC+14, the furthest any zone sits from UTC.
("2026-10-02T11:00:00+00:00", "Pacific/Kiritimati", "Sat Oct 3"),
# A zone on UTC's own date needs nothing.
("2026-10-02T19:00:00+00:00", "Europe/London", "Fri Oct 2"),
])
def test_the_shipped_blank_timezone(self, log, start_utc, zone, want):
game, printed = self._as_printed(start_utc, zone)
assert printed == want # the case says what the extractor prints
assert C.format_game_date(self.WEEKDAY, log, game["game_date"], game) == want
def test_an_iso_string_start_reads_the_same(self, log):
game = {"start_time_utc": "2026-10-03T00:00:00Z"}
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Fri Oct 2"
assert C.format_game_date(self.WEEKDAY, log, "10/02", game) == "Fri Oct 2"
def test_a_plugin_level_zone_still_agrees(self, log):
game, want = self._as_printed("2026-10-03T00:00:00+00:00", "America/Chicago")
cfg = dict(self.WEEKDAY, timezone="America/Chicago")
assert C.format_game_date(cfg, log, game["game_date"], game) == want == "Fri Oct 2"
def test_a_date_no_zone_could_print_keeps_the_zone_weekday(self, log):
"""More than a day from the start: nothing to anchor to, so the
weekday is card_tzinfo's, as it always was."""
game = {"start_time_utc": datetime(2026, 10, 3, 0, 0, tzinfo=timezone.utc)}
assert C.format_game_date(self.WEEKDAY, log, "10/9", game) == "Sat Oct 9"
def test_a_start_without_an_offset_keeps_the_zone_weekday(self, log):
"""A naive time names no instant, so it cannot place the date."""
game = {"start_time_utc": datetime(2026, 10, 2, 20, 0)}
assert C.format_game_date(self.WEEKDAY, log, "10/3", game) == \
f"{C.weekday_for(self.WEEKDAY, log, game)} Oct 3"
@pytest.mark.parametrize("game", [None, {}, {"start_time_utc": "garbage"}])
def test_no_usable_start_draws_no_weekday(self, log, game):
assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Oct 2"
def test_the_scorebug_twin_formats_the_same(self, log):
"""Switch mode (SportsCoreSharedMixin) shares the formatter body."""
from src.common.sports_shared import SportsCoreSharedMixin
class Host(SportsCoreSharedMixin):
config = {"scroll_card": {"date_format": "weekday",
"switch_date_format": "inherit"}}
logger = log
def _get_timezone(self):
return ZoneInfo("America/New_York")
game, want = self._as_printed("2026-11-01T00:30:00+00:00", "America/New_York")
assert Host()._format_game_date(game["game_date"], game) == want
assert C.format_game_date(Host.config, log, game["game_date"], game) == want
class TestFontSizing:
def test_snaps_to_the_faces_pixel_grid(self):
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
+8 -5
View File
@@ -561,21 +561,24 @@ class TestPinnedDivergence:
assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9)
def test_weekday_zone_source(self):
# DIVERGENCE, user-visible: the scorebug asks the plugin's
# DIVERGENCE, not drawn: the scorebug asks the plugin's
# _get_timezone() (plugin setting -> global setting -> system zone);
# the card reads only config["timezone"] and falls back to UTC. The
# scoreboards' schemas default that key to "", and the scroll display
# hands the renderer the plugin config, so a board that sets only the
# global zone gets UTC weekdays in scroll mode: an evening kickoff in
# New York is labelled with the next day.
# hands the renderer the plugin config, so the bare weekday helpers
# still disagree for an evening kickoff in New York.
game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT
host = _Host({}, tz=ZoneInfo("America/New_York"))
assert host._weekday_for(game) == "Sat"
assert C.weekday_for({}, LOG, game) == "Sun"
# DECIDED: what a card draws is the printed date's own weekday, so
# the scroll card no longer labels that kickoff with the next day
# ("Sun Sep 19" before). Both formatters place the extractor's "M/D"
# against the start time instead of re-deriving the day in a zone.
cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}}
host = _Host(cfg, tz=ZoneInfo("America/New_York"))
assert host._format_game_date("9/19", game) == "Sat Sep 19"
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19"
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sat Sep 19"
def test_weekday_out_of_range_start(self):
# DIVERGENCE: the mixin catches OverflowError from astimezone() and
+90
View File
@@ -0,0 +1,90 @@
"""Removing a dev plugin linked into the plugins directory removes the link.
``scripts/dev/dev_plugin_setup.sh`` symlinks a checkout into the plugins
directory. ``PluginStoreManager._safe_remove_directory`` -- behind uninstall,
and behind discarding the set-aside copy after an install or update -- handed
the link to ``shutil.rmtree``, which refuses a symlink. Its fallback then
walked through the link and chmodded every directory and file of the linked
checkout to 0700, and the sudo stage refused a path outside the plugins
directory. So the uninstall failed, the link stayed, and the developer's
checkout lost its group/other permissions and gained execute bits.
Skipped where this process cannot create a symlink (Windows without the
privilege).
"""
import json
import os
from unittest.mock import MagicMock
import pytest
from src.plugin_system.store_manager import PluginStoreManager
PLUGIN_ID = "linked-demo"
def _symlink_or_skip(target, link):
try:
os.symlink(target, link, target_is_directory=True)
except (OSError, NotImplementedError) as e:
pytest.skip(f"cannot create a symlink here: {e}")
@pytest.fixture
def linked(tmp_path):
checkout = tmp_path / "dev-plugins" / PLUGIN_ID
checkout.mkdir(parents=True)
(checkout / "manifest.json").write_text(
json.dumps({"id": PLUGIN_ID, "name": "Linked", "class_name": "P",
"display_modes": ["linked"], "version": "1.0.0"}),
encoding="utf-8")
(checkout / "manager.py").write_text("X = 1\n", encoding="utf-8")
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
link = plugins_dir / PLUGIN_ID
_symlink_or_skip(checkout, link)
store = PluginStoreManager(plugins_dir=str(plugins_dir))
store.logger = MagicMock()
return store, link, checkout
def test_removing_a_linked_plugin_removes_only_the_link(linked):
store, link, checkout = linked
assert store._safe_remove_directory(link) is True
assert not os.path.lexists(link)
assert (checkout / "manager.py").read_text(encoding="utf-8") == "X = 1\n"
@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits")
def test_removing_a_linked_plugin_leaves_the_checkouts_permissions(linked):
store, link, checkout = linked
os.chmod(checkout, 0o755)
os.chmod(checkout / "manager.py", 0o644)
store._safe_remove_directory(link)
assert checkout.stat().st_mode & 0o777 == 0o755
assert (checkout / "manager.py").stat().st_mode & 0o777 == 0o644
def test_uninstalling_a_linked_plugin_removes_the_link(linked):
store, link, checkout = linked
assert store.uninstall_plugin(PLUGIN_ID) is True
assert not os.path.lexists(link)
assert (checkout / "manifest.json").exists()
def test_a_dangling_link_is_removed_too(linked):
store, link, checkout = linked
for child in checkout.iterdir():
child.unlink()
checkout.rmdir()
assert store._safe_remove_directory(link) is True
assert not os.path.lexists(link)