mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Merge remote-tracking branch 'origin/main' into claude/fix-on-demand-edges
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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;');
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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)]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user