mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
Merge origin/main
# Conflicts: # CHANGELOG.md
This commit is contained in:
+3
-1
@@ -59,7 +59,7 @@ server has none.
|
||||
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
|
||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
@@ -70,6 +70,8 @@ server has none.
|
||||
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
|
||||
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
|
||||
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
|
||||
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
|
||||
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
|
||||
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
|
||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// The Display tab as a page module (static/v3/js/pages/display.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial, the real
|
||||
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
|
||||
// behind ctx.visibility, and the real API's answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="display"
|
||||
// * after five swaps: one mounted page, the Vegas order drawn once, each
|
||||
// control acting once (brightness, resolution, Vegas and double-sided
|
||||
// toggles, the Advanced section toggle, one debounced scroll-speed hint
|
||||
// request)
|
||||
// * the sync status is polled only while the Display tab is on screen and
|
||||
// the browser tab visible: no interval runs while hidden, and none after
|
||||
// the partial is swapped out
|
||||
// * sync states drawn as text; a failed poll says "unavailable", a login
|
||||
// redirect draws nothing
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting starts nothing
|
||||
// * window.updateSyncUI's entry point still works
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/display');
|
||||
const realSync = JSON.parse(await get('/api/v3/sync/status'));
|
||||
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
|
||||
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
const displayPage = await load('pages/display.js');
|
||||
|
||||
console.log('\n── Display tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
|
||||
ok('the Advanced toggle names its action',
|
||||
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
|
||||
ok('the real sync status answers in the shape the page reads',
|
||||
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
|
||||
ok('the real scroll-speed advice answers in the shape the page reads',
|
||||
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
|
||||
&& rough.data.alternatives.length > 0, rough);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('display-content');
|
||||
|
||||
// The browser tab's visibility and the app's active tab, under test control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
|
||||
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
const plugins = [
|
||||
{ id: 'clock', name: 'Clock', enabled: true },
|
||||
{ id: 'weather', name: HOSTILE, enabled: true },
|
||||
];
|
||||
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
|
||||
let syncMode = 'ok';
|
||||
let advice = smooth;
|
||||
const requests = [];
|
||||
function fakeFetch(url, init = {}) {
|
||||
requests.push(url);
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
|
||||
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
|
||||
if (url === '/api/v3/sync/status') {
|
||||
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
return respond(200, syncAnswer);
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
window.fetch = fakeFetch;
|
||||
|
||||
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
|
||||
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
|
||||
// the shared toggleSection the Advanced button calls.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
const toggled = [];
|
||||
window.toggleSection = id => toggled.push(id);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
|
||||
const widget = window.PluginOrderList;
|
||||
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
registry.register('display', displayPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="display"]');
|
||||
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
|
||||
const syncPolls = () => count('/api/v3/sync/status');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
|
||||
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
|
||||
|
||||
setTab('display');
|
||||
await registry.start();
|
||||
await tick(200);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
|
||||
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
|
||||
ok('the saved role is standalone: no sync request, no interval work',
|
||||
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
|
||||
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
|
||||
&& [...intervals.values()][0].ms === 5000, intervals.size);
|
||||
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
|
||||
|
||||
// ── five swaps ──────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
await tick(200);
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('one sync interval, not six', intervals.size === 1, intervals.size);
|
||||
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
|
||||
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
|
||||
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
|
||||
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
|
||||
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
|
||||
|
||||
// ── the controls ────────────────────────────────────────────────────────
|
||||
$('brightness').value = '42';
|
||||
fire($('brightness'), 'input');
|
||||
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
|
||||
|
||||
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
|
||||
fire($('parallel'), 'input');
|
||||
ok('the resolution readout is cols x chain by rows x parallel',
|
||||
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
|
||||
$('orientation').value = '90';
|
||||
fire($('orientation'), 'change');
|
||||
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
|
||||
$('display-resolution-value').textContent);
|
||||
$('rows').value = '';
|
||||
fire($('rows'), 'input');
|
||||
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
|
||||
|
||||
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
|
||||
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
|
||||
$(box).checked = true; fire($(box), 'change');
|
||||
const on = $(settings).style.display;
|
||||
$(box).checked = false; fire($(box), 'change');
|
||||
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
|
||||
[on, $(settings).style.display]);
|
||||
}
|
||||
|
||||
root().querySelector('[data-action="toggle-section"]').click();
|
||||
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
|
||||
|
||||
// ── the scroll-speed hint ───────────────────────────────────────────────
|
||||
const hints = count('/api/v3/config/scroll-speed-advice');
|
||||
advice = rough;
|
||||
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
|
||||
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
|
||||
await tick(250);
|
||||
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
|
||||
count('/api/v3/config/scroll-speed-advice') - hints);
|
||||
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
|
||||
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
|
||||
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
|
||||
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
|
||||
buttons[0].click();
|
||||
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
|
||||
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
|
||||
advice = smooth;
|
||||
await tick(250);
|
||||
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
|
||||
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
|
||||
$('vegas_scroll_speed_hint').textContent);
|
||||
|
||||
// ── sync: the role ──────────────────────────────────────────────────────
|
||||
setRole('leader');
|
||||
await tick(20);
|
||||
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
|
||||
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
|
||||
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
|
||||
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
|
||||
$('sync_status_content').textContent);
|
||||
setRole('follower');
|
||||
await tick(20);
|
||||
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
|
||||
|
||||
// ── sync: the poll runs only while on screen ────────────────────────────
|
||||
let polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
|
||||
setTab('logs');
|
||||
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('display');
|
||||
await tick(20);
|
||||
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
|
||||
// ── sync: states ────────────────────────────────────────────────────────
|
||||
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
|
||||
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
|
||||
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
|
||||
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
|
||||
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
|
||||
&& !$('sync_error_detail').querySelector('img'));
|
||||
syncAnswer = { status: 'success', data: realSync.data };
|
||||
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
|
||||
syncMode = 'network';
|
||||
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
|
||||
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
|
||||
syncMode = 'ok';
|
||||
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
|
||||
syncMode = 'login';
|
||||
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
|
||||
syncMode = 'ok';
|
||||
|
||||
// ── standalone stops asking ─────────────────────────────────────────────
|
||||
setRole('standalone');
|
||||
polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('standalone hides the bar and the poll asks nothing',
|
||||
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
|
||||
|
||||
// ── window.updateSyncUI ─────────────────────────────────────────────────
|
||||
$('sync_role').value = 'leader';
|
||||
polls = syncPolls();
|
||||
displayPage.updateSyncUI();
|
||||
await tick(20);
|
||||
ok('updateSyncUI() applies the role and asks once',
|
||||
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
|
||||
|
||||
// ── swapped out ─────────────────────────────────────────────────────────
|
||||
await swap('<p>another tab</p>');
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
ok('no interval left running', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
|
||||
const hintsGone = count('/api/v3/config/scroll-speed-advice');
|
||||
await swap();
|
||||
$('vegas_scroll_speed').value = '40';
|
||||
fire($('vegas_scroll_speed'), 'input');
|
||||
await swap('<p>another tab</p>');
|
||||
await tick(250);
|
||||
ok('a hint still waiting out its debounce at the swap is never asked for',
|
||||
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.PluginOrderList;
|
||||
const beforeLate = count('/api/v3/plugins/installed');
|
||||
await swap();
|
||||
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
|
||||
window.PluginOrderList = widget;
|
||||
await tick(150);
|
||||
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
|
||||
delete window.PluginOrderList;
|
||||
await swap();
|
||||
const beforeGone = count('/api/v3/plugins/installed');
|
||||
await swap('<p>another tab</p>');
|
||||
window.PluginOrderList = widget;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,211 @@
|
||||
// The page-visibility service (static/v3/js/core/visibility.js), in a real
|
||||
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
|
||||
// real page registry, wired the way core/boot.js wires them (each mount gets
|
||||
// ctx.visibility from mountContext):
|
||||
//
|
||||
// * whileVisible(start, stop) runs start() only while the page's tab is the
|
||||
// active tab AND the browser tab is visible, stop() when either changes
|
||||
// * every(ms, fn) calls fn at once and then on an interval while visible;
|
||||
// no interval is left running while hidden
|
||||
// * everything a page registered stops when the page is swapped out, and a
|
||||
// page that registers after it was destroyed starts nothing
|
||||
// * registrations never replace each other (two timers on one page, two
|
||||
// pages, or a classic partial's own LEDVisibility key)
|
||||
// * without LEDVisibility, the browser tab's visibility alone decides
|
||||
//
|
||||
// Needs jsdom but no server.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
|
||||
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
|
||||
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
|
||||
// The browser tab's visibility, under the test's control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
function setHidden(value) {
|
||||
hidden = value;
|
||||
doc.dispatchEvent(new window.Event('visibilitychange'));
|
||||
}
|
||||
function setTab(tab) {
|
||||
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
}
|
||||
|
||||
// Intervals, counted: the point is that none is left running.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
|
||||
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
|
||||
// test: they open stand-in EventSources.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: {},
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
|
||||
const log = [];
|
||||
let polls = 0;
|
||||
const handles = [];
|
||||
registry.register('display', {
|
||||
init(root, ctx) {
|
||||
handles.push(ctx.visibility);
|
||||
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
|
||||
ctx.visibility.every(5000, () => { polls++; });
|
||||
},
|
||||
});
|
||||
let otherRuns = 0;
|
||||
registry.register('logs', {
|
||||
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
|
||||
});
|
||||
|
||||
const panel = doc.getElementById('display-content');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(10);
|
||||
}
|
||||
|
||||
// A classic partial's own registration, keyed by its tab name: the
|
||||
// service's registrations must not replace it, nor it them.
|
||||
let classic = 0;
|
||||
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
|
||||
|
||||
setTab('overview');
|
||||
await registry.start();
|
||||
await swap('<div data-page="display"></div>');
|
||||
|
||||
// ── mounted on another tab ──────────────────────────────────────────────
|
||||
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
|
||||
ok('...and no interval runs', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false', handles[0].isVisible() === false);
|
||||
ok('the page\'s tab is its name', handles[0].tab === 'display');
|
||||
|
||||
// ── its tab comes on screen ─────────────────────────────────────────────
|
||||
setTab('display');
|
||||
ok('switching to the tab runs start()', log.join() === 'start', log);
|
||||
ok('every() calls fn at once', polls === 1, polls);
|
||||
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
|
||||
[...intervals.values()].map(i => i.ms));
|
||||
ok('isVisible() is true', handles[0].isVisible() === true);
|
||||
ok('the classic registration still runs alongside', classic === 1, classic);
|
||||
fireIntervals();
|
||||
fireIntervals();
|
||||
ok('fn runs on each interval', polls === 3, polls);
|
||||
|
||||
// ── the browser tab is hidden, then shown ───────────────────────────────
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
|
||||
ok('...and clears the interval', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
|
||||
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
|
||||
|
||||
// ── another tab ─────────────────────────────────────────────────────────
|
||||
setTab('logs');
|
||||
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
|
||||
[log, intervals.size]);
|
||||
setTab('display');
|
||||
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
|
||||
|
||||
// ── the partial is swapped out while on screen ──────────────────────────
|
||||
await swap('<p>no page here</p>');
|
||||
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
|
||||
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
|
||||
const before = [log.length, polls];
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
|
||||
ok('the classic registration keeps running after the swap', classic === 5, classic);
|
||||
|
||||
// ── five swaps, then one page ───────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
|
||||
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
|
||||
const pollsBefore = polls;
|
||||
fireIntervals();
|
||||
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
|
||||
|
||||
// ── two timers on one page, and an end function ─────────────────────────
|
||||
const h = handles[handles.length - 1];
|
||||
let a = 0, b = 0;
|
||||
const endA = h.every(100, () => { a++; });
|
||||
h.every(200, () => { b++; });
|
||||
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
|
||||
endA();
|
||||
endA();
|
||||
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
|
||||
|
||||
// ── a page that registers after it was destroyed ────────────────────────
|
||||
const gone = handles[handles.length - 1];
|
||||
await swap('<p>gone</p>');
|
||||
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
|
||||
let late = 0;
|
||||
const end = gone.every(1000, () => { late++; });
|
||||
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
|
||||
|
||||
// ── a start() that throws ───────────────────────────────────────────────
|
||||
await swap('<div data-page="display"></div>');
|
||||
const h2 = handles[handles.length - 1];
|
||||
const loggedBefore = logged.length;
|
||||
let afterThrow = 0;
|
||||
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
|
||||
h2.whileVisible(() => { afterThrow++; }, () => {});
|
||||
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
|
||||
logged.slice(loggedBefore));
|
||||
ok('...and the next registration still starts', afterThrow === 1);
|
||||
await swap('<p>gone</p>');
|
||||
|
||||
// ── without LEDVisibility (a page outside base.html) ────────────────────
|
||||
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
|
||||
const seen = [];
|
||||
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
|
||||
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
|
||||
setHidden(true);
|
||||
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
|
||||
ok('...isVisible() follows the document', bare.isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
|
||||
|
||||
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
|
||||
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
|
||||
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
+2
-1
@@ -37,7 +37,8 @@ const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js',
|
||||
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// The Display tab's inline script must only look up elements the partial
|
||||
// The Display tab's page module must only look up elements the partial
|
||||
// renders.
|
||||
//
|
||||
// Its brightness slider handler also wrote to #brightness-display, a "LED
|
||||
// brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// Its brightness slider handler once also wrote to #brightness-display, a
|
||||
// "LED brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// lookup returned null, so every movement of the slider threw a TypeError.
|
||||
// This checks every literal getElementById() in the partial's inline scripts
|
||||
// against the ids its markup renders, and runs the shipped script in a vm
|
||||
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
|
||||
// the slider.
|
||||
// This imports the shipped module (static/v3/js/pages/display.js), starts it
|
||||
// on a fake root that answers only for the ids the partial's markup renders
|
||||
// (null for any other, as in a browser), fires every listener it registered,
|
||||
// and checks that every id it asked for exists. Then it moves the slider.
|
||||
//
|
||||
// No jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
|
||||
const scripts = blocks.map(m => m[1]);
|
||||
// The markup is what lies between the script blocks (sliced around them, not
|
||||
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
|
||||
let markup = '';
|
||||
let from = 0;
|
||||
for (const m of blocks) {
|
||||
markup += html.slice(from, m.index);
|
||||
from = m.index + m[0].length;
|
||||
}
|
||||
markup += html.slice(from);
|
||||
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
|
||||
console.log('\n── Display partial: element lookups ──');
|
||||
|
||||
// 1. Static: every literal lookup names an id the partial renders.
|
||||
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
|
||||
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
|
||||
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
|
||||
function fakeElement(id) {
|
||||
const listeners = {};
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, value: '', textContent: '', min: '', max: '', checked: false,
|
||||
id, value: '1', textContent: '', min: '', max: '', checked: false,
|
||||
style: {}, dataset: {}, className: '',
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
|
||||
dispatchEvent() { return true; },
|
||||
appendChild() {},
|
||||
getAttribute: () => null,
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
const main = scripts.find(s => s.includes("getElementById('brightness')"));
|
||||
ok('found the script that wires the brightness slider', !!main);
|
||||
if (main) {
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
getElementById: id => {
|
||||
if (!rendered.has(id)) return null;
|
||||
if (!elements.has(id)) elements.set(id, fakeElement(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: () => fakeElement(''),
|
||||
createTextNode: () => ({}),
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
LEDEscape: { html: v => String(v), attr: v => String(v) },
|
||||
LEDVisibility: { onActive() {} },
|
||||
};
|
||||
const context = {
|
||||
window, document, console, URLSearchParams,
|
||||
fetch: () => new Promise(() => {}),
|
||||
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
let loadError = null;
|
||||
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
|
||||
ok('the script loads', !loadError, loadError && String(loadError));
|
||||
(async () => {
|
||||
console.log('\n── Display page module: element lookups ──');
|
||||
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
|
||||
|
||||
const slider = elements.get('brightness');
|
||||
const handlers = (slider && slider.listeners.input) || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
const asked = new Set();
|
||||
const elements = new Map();
|
||||
const timers = [];
|
||||
const win = {
|
||||
setTimeout: fn => { timers.push(fn); return timers.length; },
|
||||
clearTimeout() {},
|
||||
URLSearchParams,
|
||||
Event: class { constructor(type) { this.type = type; } },
|
||||
PluginOrderList: { init() {} },
|
||||
};
|
||||
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
|
||||
const rootListeners = {};
|
||||
const root = {
|
||||
ownerDocument: doc,
|
||||
querySelector(sel) {
|
||||
const m = /^#([\w-]+)$/.exec(sel);
|
||||
if (!m) throw new Error('unexpected selector ' + sel);
|
||||
asked.add(m[1]);
|
||||
if (!rendered.has(m[1])) return null;
|
||||
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
|
||||
return elements.get(m[1]);
|
||||
},
|
||||
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
|
||||
contains: () => true,
|
||||
};
|
||||
const never = () => new Promise(() => {});
|
||||
const polls = [];
|
||||
const ctx = {
|
||||
root, name: 'display', state: {}, signal: { aborted: false },
|
||||
api: { get: never },
|
||||
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
|
||||
};
|
||||
|
||||
let loadError = null;
|
||||
try { display.init(root, ctx); } catch (e) { loadError = e; }
|
||||
ok('init() runs', !loadError, loadError && String(loadError));
|
||||
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
|
||||
|
||||
// Fire everything it wired, so every lookup it can make is made.
|
||||
let thrown = null;
|
||||
try {
|
||||
for (const el of elements.values()) {
|
||||
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
|
||||
}
|
||||
while (timers.length) timers.shift()();
|
||||
} catch (e) { thrown = e; }
|
||||
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
|
||||
|
||||
const missing = [...asked].filter(id => !rendered.has(id));
|
||||
ok('it looks elements up', asked.size > 10, asked.size);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
const slider = elements.get('brightness') || fakeElement('brightness');
|
||||
const handlers = slider.listeners.input || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
slider.value = '42';
|
||||
thrown = null;
|
||||
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
|
||||
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
|
||||
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
|
||||
elements.get('brightness-value').textContent);
|
||||
}
|
||||
const label = elements.get('brightness-value');
|
||||
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
display.destroy(root, ctx);
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -103,17 +103,16 @@ const ESCAPERS = [
|
||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['slider.js (escapeAttr)',
|
||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['display.html (escapeAttr)',
|
||||
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['tools.html (escHtml)',
|
||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||
['tools.html (phEscape)',
|
||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||
['logs.html (escapeHtml)',
|
||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
// cache.html, backup_restore.html and operation_history.html have no
|
||||
// script any more: their js/pages/ modules draw server data with
|
||||
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
|
||||
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||
// have no script any more (display.html's two escapers were never called):
|
||||
// their js/pages/ modules draw server data with textContent, and each
|
||||
// page's suite in test/js/dom/ checks a hostile value.
|
||||
];
|
||||
|
||||
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||
|
||||
@@ -242,6 +242,46 @@ function recorder(log) {
|
||||
ok('has()', reg.has('dup') && !reg.has('nope'));
|
||||
}
|
||||
|
||||
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
|
||||
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const seen = [];
|
||||
const made = [];
|
||||
const reg = createRegistry({
|
||||
document: doc, context: { api: 'shared' }, logger: quiet,
|
||||
mountContext(ctx) {
|
||||
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
|
||||
return { bound: { root: ctx.root, signal: ctx.signal } };
|
||||
},
|
||||
});
|
||||
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
|
||||
await reg.start();
|
||||
await tick();
|
||||
ok('mountContext sees the mount\'s name, root, signal and the shared services',
|
||||
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
|
||||
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
await tick();
|
||||
ok('called again for each new mount, with that mount\'s signal',
|
||||
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
|
||||
&& seen[0].signal.aborted, made);
|
||||
|
||||
const errors = [];
|
||||
const doc2 = new Doc();
|
||||
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
|
||||
mountContext() { throw new Error('boom'); } });
|
||||
let started = 0;
|
||||
reg2.register('demo', { init() { started++; } });
|
||||
await reg2.start();
|
||||
await tick();
|
||||
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""src.common.sports_game_over: behaviour, host contract and base order.
|
||||
|
||||
The matrix is ledmatrix-plugins' ``scripts/test_game_over_check.py`` (the
|
||||
table the family 5 reconcile was checked against) folded to the three
|
||||
``FINAL_PERIOD`` values the nine scoreboards declare: None (afl, baseball,
|
||||
nrl, soccer, ufc), 4 (basketball, football, lacrosse) and 3 (hockey).
|
||||
Baseball's postponed/suspended override stays in its plugin and is not here.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_game_over
|
||||
from src.common.sports_game_over import SportsGameOverMixin
|
||||
from src.common.sports_shared import SportsLiveSharedMixin
|
||||
|
||||
LOG = logging.getLogger("test_sports_game_over")
|
||||
|
||||
|
||||
def host(final_period):
|
||||
"""A live manager stand-in declaring ``FINAL_PERIOD`` as a plugin does."""
|
||||
cls = type("Live", (SportsGameOverMixin,), {"FINAL_PERIOD": final_period})
|
||||
h = cls()
|
||||
h.logger = LOG
|
||||
return h
|
||||
|
||||
|
||||
MISSING = object() # the key is absent from the game dict
|
||||
|
||||
|
||||
def game(period_text="", period=MISSING, clock=MISSING, away="1", home="2"):
|
||||
g = {"away_abbr": "AWY", "home_abbr": "HOM", "away_score": away,
|
||||
"home_score": home, "period_text": period_text}
|
||||
if period is not MISSING:
|
||||
g["period"] = period
|
||||
if clock is not MISSING:
|
||||
g["clock"] = clock
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The matrix: clock x period, for each FINAL_PERIOD. Scores 1-2.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINAL_PERIODS = (None, 4, 3)
|
||||
PERIODS = (MISSING, 1, 2, 3, 4, 5, 6)
|
||||
CLOCKS = {"12:00": "12:00", "0:00": "0:00", ":00": ":00", "0.0": "0.0",
|
||||
"-": "-", "''": "", "None": None, "missing": MISSING}
|
||||
|
||||
#: The period text each ESPN status carries. Only "Final" contains "final";
|
||||
#: the method reads no status, so every other text answers the same row.
|
||||
LIVE_TEXTS = {
|
||||
"in progress": lambda p: "" if p is MISSING else f"P{p}",
|
||||
"end of period": lambda p: "" if p is MISSING else f"End P{p}",
|
||||
"halftime": lambda p: "Halftime",
|
||||
"end of round": lambda p: "" if p is MISSING else f"End R{p}",
|
||||
"postponed": lambda p: "Postponed",
|
||||
}
|
||||
|
||||
#: clock -> one cell per period (missing, 1..6) for FINAL_PERIOD None, 4, 3.
|
||||
EXPECTED_LIVE = {
|
||||
"12:00": "....... ....... .......",
|
||||
"0:00": "....... ....YYY ...YYYY",
|
||||
":00": "....... ....YYY ...YYYY",
|
||||
"0.0": "....... ....... .......",
|
||||
"-": "....... ....... .......",
|
||||
"''": "....... ....... .......",
|
||||
"None": "....... ....... .......",
|
||||
"missing": "....... ....... .......",
|
||||
}
|
||||
|
||||
|
||||
def row(text_for, clock):
|
||||
return " ".join(
|
||||
"".join("Y" if host(fp)._is_game_really_over(game(text_for(p), p, clock)) else "."
|
||||
for p in PERIODS)
|
||||
for fp in FINAL_PERIODS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", sorted(LIVE_TEXTS))
|
||||
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
|
||||
def test_a_live_period_text(status, clock_label):
|
||||
assert row(LIVE_TEXTS[status], CLOCKS[clock_label]) == EXPECTED_LIVE[clock_label]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
|
||||
def test_a_final_period_text_is_always_over(clock_label):
|
||||
assert row(lambda p: "Final", CLOCKS[clock_label]) == "YYYYYYY YYYYYYY YYYYYYY"
|
||||
|
||||
|
||||
#: label -> (game, one cell per FINAL_PERIOD None, 4, 3)
|
||||
EDGES = {
|
||||
"period_text None, P4 0:00": (game(None, 4, "0:00"), ".YY"),
|
||||
"period None, 0:00": (game("", None, "0:00"), "..."),
|
||||
"period 'OT', 0:00": (game("OT", "OT", "0:00"), "..."),
|
||||
"period '4' (str), 0:00": (game("P4", "4", "0:00"), ".YY"),
|
||||
"clock int 0, P4": (game("P4", 4, 0), "..."),
|
||||
"clock float 0.0, P4": (game("P4", 4, 0.0), "..."),
|
||||
"clock ' 0:00 ', P4": (game("P4", 4, " 0:00 "), ".YY"),
|
||||
"clock '00:00', P4": (game("P4", 4, "00:00"), "..."),
|
||||
"period_text 'Final/OT', P5 0:00": (game("Final/OT", 5, "0:00"), "YYY"),
|
||||
"period_text 'FINAL', P1 12:00": (game("FINAL", 1, "12:00"), "YYY"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", sorted(EDGES))
|
||||
def test_edge_shapes(label):
|
||||
g, want = EDGES[label]
|
||||
got = "".join("Y" if host(fp)._is_game_really_over(dict(g)) else "." for fp in FINAL_PERIODS)
|
||||
assert got == want
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tie guard: level at 0:00 is overtime, not the end.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTieGuard:
|
||||
@pytest.mark.parametrize("fp,period", [(4, 4), (4, 5), (3, 3), (3, 4), (3, 5)])
|
||||
def test_level_at_zero_is_not_over(self, fp, period):
|
||||
assert host(fp)._is_game_really_over(game("", period, "0:00", "2", "2")) is False
|
||||
|
||||
def test_level_scores_compare_as_numbers(self):
|
||||
assert host(4)._is_game_really_over(game("", 4, "0:00", 2, "2")) is False
|
||||
assert host(4)._is_game_really_over(game("", 4, "0:00", " 2 ", "2")) is False
|
||||
|
||||
def test_a_game_that_ends_level_ends_on_final(self):
|
||||
assert host(4)._is_game_really_over(game("Final/OT", 5, "0:00", "2", "2")) is True
|
||||
|
||||
def test_level_before_the_final_period_was_never_over(self):
|
||||
assert host(4)._is_game_really_over(game("", 3, "0:00", "2", "2")) is False
|
||||
|
||||
@pytest.mark.parametrize("away,home", [
|
||||
(MISSING, MISSING), (None, None), ("", ""), ("2", None),
|
||||
("2.0", "2.0"), ({"value": 2}, {"value": 2}), ("inf", "inf"),
|
||||
])
|
||||
def test_an_unreadable_score_leaves_it_to_the_clock(self, away, home):
|
||||
g = game("", 4, "0:00")
|
||||
for key, value in (("away_score", away), ("home_score", home)):
|
||||
if value is MISSING:
|
||||
del g[key]
|
||||
else:
|
||||
g[key] = value
|
||||
assert host(4)._is_game_really_over(g) is True
|
||||
|
||||
def test_float_infinity_does_not_raise(self):
|
||||
assert host(4)._is_game_really_over(
|
||||
game("", 4, "0:00", float("inf"), float("inf"))) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ufc: ESPN MMA payloads, as ufc's _extract_game_details stores them
|
||||
# (ledmatrix-plugins plugins/ufc-scoreboard/test/fixtures/espn_mma_round_states.json).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UFC_RECORDED = {
|
||||
"in_round_3_of_3": ("R3", 3, "1:21"),
|
||||
"break_after_round_1": ("R1", 1, "-"),
|
||||
"end_of_round_after_stoppage": ("R2", 2, "0:51"),
|
||||
"walkouts_five_rounder": ("", 0, "-"),
|
||||
"final_five_round_decision": ("R5", 5, "5:00"),
|
||||
"final_five_round_stoppage": ("R5", 5, "1:38"),
|
||||
"final_three_round_decision": ("R3", 3, "5:00"),
|
||||
"final_three_round_stoppage": ("R2", 2, "4:07"),
|
||||
"break_after_round_4_of_5": ("R4", 4, "-"),
|
||||
"end_of_round_5_awaiting_decision": ("R5", 5, "-"),
|
||||
}
|
||||
|
||||
|
||||
class TestUfc:
|
||||
@pytest.mark.parametrize("name", sorted(UFC_RECORDED))
|
||||
def test_no_recorded_state_is_over_here(self, name):
|
||||
"""A finished bout leaves the live list on is_final, before this is asked."""
|
||||
text, period, clock = UFC_RECORDED[name]
|
||||
assert host(None)._is_game_really_over(game(text, period, clock, "0", "0")) is False
|
||||
|
||||
@pytest.mark.parametrize("fp", FINAL_PERIODS)
|
||||
def test_a_round_break_dash_is_never_a_zero_clock(self, fp):
|
||||
assert host(fp)._is_game_really_over(game("R4", 4, "-", "1", "2")) is False
|
||||
|
||||
@pytest.mark.parametrize("clock", ["0:00", None, MISSING])
|
||||
def test_the_horn_does_not_end_a_bout(self, clock):
|
||||
assert host(None)._is_game_really_over(game("R5", 5, clock, "1", "2")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring: the default, overrides, and the live mixin's caller.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_default_is_no_clock_rule():
|
||||
assert SportsGameOverMixin.FINAL_PERIOD is None
|
||||
|
||||
|
||||
def test_an_override_defers_through_super():
|
||||
"""baseball's BaseballLive: its own check first, then the shared one."""
|
||||
|
||||
class Baseballish(SportsGameOverMixin):
|
||||
logger = LOG
|
||||
|
||||
def _is_game_really_over(self, game):
|
||||
if game.get("status") == "status_postponed":
|
||||
return True
|
||||
return super()._is_game_really_over(game)
|
||||
|
||||
b = Baseballish()
|
||||
assert b._is_game_really_over(dict(game("", 6, "0:00"), status="status_postponed")) is True
|
||||
assert b._is_game_really_over(game("", 6, "0:00")) is False
|
||||
assert b._is_game_really_over(game("Final", 9, None)) is True
|
||||
|
||||
|
||||
class _Live(SportsGameOverMixin, SportsLiveSharedMixin):
|
||||
"""A SportsLive stand-in in the documented base order."""
|
||||
|
||||
FINAL_PERIOD = 4
|
||||
|
||||
def __init__(self):
|
||||
self.logger = LOG
|
||||
self.stale_game_timeout = 600
|
||||
self.game_update_timestamps = {}
|
||||
|
||||
|
||||
class TestBaseOrder:
|
||||
def test_the_documented_order_resolves_this_method(self):
|
||||
assert _Live._is_game_really_over is SportsGameOverMixin._is_game_really_over
|
||||
mro = _Live.__mro__
|
||||
assert mro.index(SportsGameOverMixin) < mro.index(SportsLiveSharedMixin)
|
||||
|
||||
def test_neither_shared_mixin_defines_it(self):
|
||||
"""So the base order cannot change which body runs."""
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
for mixin in (SportsLiveSharedMixin, SportsCoreSharedMixin):
|
||||
assert "_is_game_really_over" not in vars(mixin)
|
||||
|
||||
def test_detect_stale_games_drops_an_over_game_through_it(self):
|
||||
live = _Live()
|
||||
live.game_update_timestamps = {"over": {"last_seen": time.time()},
|
||||
"on": {"last_seen": time.time()}}
|
||||
games = [dict(game("", 4, "0:00"), id="over"),
|
||||
dict(game("", 4, "0:00", "2", "2"), id="on")]
|
||||
live._detect_stale_games(games)
|
||||
assert [g["id"] for g in games] == ["on"]
|
||||
assert "over" not in live.game_update_timestamps
|
||||
|
||||
def test_the_class_value_wins_over_the_default(self):
|
||||
assert _Live().FINAL_PERIOD == 4
|
||||
assert _Live()._is_game_really_over(game("", 4, "0:00")) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _self_reads():
|
||||
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
|
||||
cls = next(n for n in tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "SportsGameOverMixin")
|
||||
return {node.attr for node in ast.walk(cls)
|
||||
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
|
||||
and isinstance(node.value, ast.Name) and node.value.id == "self"}
|
||||
|
||||
|
||||
class TestHostContract:
|
||||
def test_every_host_read_is_documented(self):
|
||||
undocumented = sorted(n for n in _self_reads()
|
||||
if f"``{n}``" not in sports_game_over.__doc__)
|
||||
assert undocumented == [], f"read but not in the host contract: {undocumented}"
|
||||
|
||||
def test_the_mixin_creates_no_state(self):
|
||||
assert "__init__" not in vars(SportsGameOverMixin)
|
||||
assert not hasattr(SportsGameOverMixin, "logger")
|
||||
assert sorted(n for n in vars(SportsGameOverMixin) if not n.startswith("__")) == [
|
||||
"FINAL_PERIOD", "_is_game_really_over"]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
|
||||
|
||||
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
|
||||
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
|
||||
body. The plugins delete their copies once they floor on the release that
|
||||
ships this module. Until each has, a copy that changes on its own is a fix one
|
||||
side has and the other lacks.
|
||||
|
||||
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
|
||||
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
|
||||
normalisation (the AST with docstrings and annotations dropped), plus the
|
||||
decorators. A copy that is gone counts as adopted when the plugin's
|
||||
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
|
||||
compared with the value the owner decided for its sport, which stays in the
|
||||
plugin after adoption. Without the variable this skips: core CI has no plugins
|
||||
checkout.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_game_over
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
|
||||
#: from which a 0:00 clock ends a game, None where the clock never does.
|
||||
FINAL_PERIOD = {
|
||||
"afl": None, "baseball": None, "basketball": 4, "football": 4,
|
||||
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
|
||||
}
|
||||
NAME = "_is_game_really_over"
|
||||
|
||||
|
||||
def _drift_report():
|
||||
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
DRIFT = _drift_report()
|
||||
|
||||
|
||||
def _plugins_root():
|
||||
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
|
||||
if root is None:
|
||||
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
|
||||
"compare this module against the plugin copies")
|
||||
return root
|
||||
|
||||
|
||||
def _class(tree, name):
|
||||
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
|
||||
|
||||
|
||||
def _method(cls):
|
||||
return next((n for n in cls.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
|
||||
|
||||
|
||||
def _fingerprint(node):
|
||||
return (DRIFT._digest(node, DRIFT._Canonical()),
|
||||
tuple(ast.unparse(d) for d in node.decorator_list))
|
||||
|
||||
|
||||
def _final_period(cls):
|
||||
for node in cls.body:
|
||||
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
|
||||
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
|
||||
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
|
||||
return ast.literal_eval(node.value)
|
||||
raise AssertionError("SportsLive declares no FINAL_PERIOD")
|
||||
|
||||
|
||||
def _ours():
|
||||
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
|
||||
return _class(tree, "SportsGameOverMixin")
|
||||
|
||||
|
||||
def test_the_mixin_holds_one_method_and_the_default():
|
||||
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
|
||||
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
|
||||
and (isinstance(n, ast.FunctionDef) or n.value is not None))
|
||||
assert names == ["FINAL_PERIOD", NAME]
|
||||
assert _final_period(_ours()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_remaining_plugin_copy_matches(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
live = _class(ast.parse(source), "SportsLive")
|
||||
copy = _method(live)
|
||||
if copy is None:
|
||||
assert sports_game_over.__name__ in source, (
|
||||
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
|
||||
else:
|
||||
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
|
||||
f"{NAME} in {sport} differs from sports_game_over. "
|
||||
f"Port the change to both, or stop treating it as shared.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
|
||||
def test_every_plugin_declares_its_final_period(sport):
|
||||
root = _plugins_root()
|
||||
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
|
||||
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
|
||||
|
||||
|
||||
def test_the_drift_report_still_calls_it_identical():
|
||||
root = _plugins_root()
|
||||
families = DRIFT.build(root, ("sports.py",))
|
||||
rows = {(r["file"], r["family"]): r
|
||||
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
|
||||
row = rows.get(("sports.py", NAME))
|
||||
assert row is None or row["worst_class_variants"] == 1
|
||||
@@ -465,6 +465,136 @@ class TestLiveMixin:
|
||||
# The safety property that makes it correct: 30s beats 600s.
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
|
||||
# ---- every pending kickoff is honoured, not just the first ------------
|
||||
#
|
||||
# One stored kickoff held the slot through its grace and refused every
|
||||
# later one; a later one that had passed by the time the grace ended was
|
||||
# refused again as "already past". So of two favourites kicking off ten
|
||||
# minutes apart, the second lost its grace: if ESPN had not flipped it live
|
||||
# by the end of the FIRST game's grace, the back-off returned to its
|
||||
# ceiling and the game was noticed up to that late. That bites whenever the
|
||||
# first game is not live by then -- a rain delay, a postponement, ESPN slow
|
||||
# to flip it -- since a live first game keeps the live cadence anyway.
|
||||
|
||||
@staticmethod
|
||||
def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900):
|
||||
"""Drive a live manager's idle loop over a schedule on a fake clock.
|
||||
|
||||
``kickoffs`` are start offsets in seconds from t=0 (the first look),
|
||||
``flips`` how long after its start ESPN reports each game live (None:
|
||||
postponed, never live). Every
|
||||
look offers each not-yet-live game, as the live loop does, then sleeps
|
||||
for whatever the back-off returns. Returns, per game, how long after it
|
||||
went live it was first seen live -- None if never.
|
||||
"""
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = _LiveHost(no_data_interval=300)
|
||||
h.live_idle_max_interval = ceiling
|
||||
h.update_interval = poll
|
||||
h._empty_live_streak = 30 # idle all morning: at the ceiling
|
||||
t0 = clock[0]
|
||||
seen = [None] * len(kickoffs)
|
||||
while clock[0] - t0 < until:
|
||||
now = clock[0] - t0
|
||||
live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)]
|
||||
for i, is_live in enumerate(live):
|
||||
if is_live and seen[i] is None:
|
||||
seen[i] = now - (kickoffs[i] + flips[i])
|
||||
start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc)
|
||||
h._note_scheduled_start_candidate(
|
||||
{"is_live": is_live, "is_halftime": False,
|
||||
"start_time_utc": start})
|
||||
# A real board keeps polling at the live cadence while anything is
|
||||
# live; a game here stays live for an hour after it flips.
|
||||
on = any(f is not None and k + f <= now < k + f + 3600
|
||||
for k, f in zip(kickoffs, flips))
|
||||
h._note_live_fetch(on)
|
||||
clock[0] += poll if on else h._idle_live_interval()
|
||||
assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX
|
||||
return seen
|
||||
|
||||
@pytest.mark.parametrize("kickoffs,flips", [
|
||||
# (start offsets, ESPN's flip delay per game), both in seconds.
|
||||
pytest.param([1800], [120], id="one kickoff, flipped 2 min late"),
|
||||
pytest.param([1800, 2400], [0, 840],
|
||||
id="first live on time, the second flipped 14 min late"),
|
||||
pytest.param([1800, 2400], [None, 840],
|
||||
id="first postponed, the second 10 min later flipped 14 min late"),
|
||||
pytest.param([1800, 2400], [1200, 840],
|
||||
id="first in a 20 min delay, the second flipped 14 min late"),
|
||||
pytest.param([1800, 2100, 2520], [None, None, 600],
|
||||
id="three inside one grace, the last flipped 10 min late"),
|
||||
pytest.param([1800, 1800, 2400], [None, None, 700],
|
||||
id="two at the same time, then one 10 min later"),
|
||||
pytest.param([1800, 2700], [None, 840],
|
||||
id="second kickoff 15 min later, flipped 14 min late"),
|
||||
pytest.param([1800 + 60 * i for i in range(20)],
|
||||
[None] * 19 + [840],
|
||||
id="twenty kickoffs a minute apart overflow the queue"),
|
||||
])
|
||||
def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips):
|
||||
seen = self._replay(monkeypatch, kickoffs, flips,
|
||||
until=max(kickoffs) + 3 * 3600)
|
||||
late = [s for s, f in zip(seen, flips)
|
||||
if f is not None and (s is None or s > 30)]
|
||||
assert not late, "games noticed late (s after going live): %r" % (seen,)
|
||||
|
||||
def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch):
|
||||
# A postponed game keeps the live cadence for its grace and no longer:
|
||||
# remembering more kickoffs must not pin the poll to dead ones.
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for offset in (600, 900):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1
|
||||
assert h._idle_live_interval() == 900
|
||||
assert not getattr(h, "_later_scheduled_starts", None)
|
||||
|
||||
def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch):
|
||||
# After a long sleep (or a run of looks that never woke the manager)
|
||||
# several queued kickoffs may have gone stale at once. The one still
|
||||
# inside its grace must win, not the first stale one in the queue.
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for offset in (600, 700, 1500):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace
|
||||
assert h._idle_live_interval() == h.update_interval
|
||||
assert h._next_scheduled_start_ts == t0 + 1500
|
||||
|
||||
def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch):
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
cap = sports_shared._KICKOFF_QUEUE_MAX
|
||||
for offset in reversed(range(1, cap + 6)): # latest first
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)})
|
||||
assert h._next_scheduled_start_ts == t0 + 600
|
||||
assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)]
|
||||
|
||||
def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch):
|
||||
clock = [1_800_000_000.0]
|
||||
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
|
||||
h = self._idle_host()
|
||||
t0 = clock[0]
|
||||
for _ in range(3):
|
||||
for offset in (600, 1200):
|
||||
h._note_scheduled_start_candidate(
|
||||
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
|
||||
assert h._next_scheduled_start_ts == t0 + 600
|
||||
assert h._later_scheduled_starts == [t0 + 1200]
|
||||
|
||||
def test_finding_a_live_game_resets_the_streak(self):
|
||||
h = _LiveHost()
|
||||
h._note_live_fetch(False)
|
||||
|
||||
@@ -43,7 +43,8 @@ def _url(path):
|
||||
def test_the_module_directories_hold_modules():
|
||||
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
|
||||
"durations.js", "operation-history.js", "raw-json.js",
|
||||
"backup-restore.js", "schedule.js", "general.js"}
|
||||
"backup-restore.js", "schedule.js", "general.js",
|
||||
"visibility.js", "display.js"}
|
||||
for directory in MODULE_DIRS:
|
||||
# node needs this to import them in the JS tests; browsers ignore it.
|
||||
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
|
||||
@@ -106,6 +107,7 @@ CONVERTED = {
|
||||
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
|
||||
"schedule": ("schedule.html", "/partials/schedule"),
|
||||
"general": ("general.html", "/partials/general"),
|
||||
"display": ("display.html", "/partials/display"),
|
||||
}
|
||||
|
||||
# Old window.* names that moved into a page module. Each stays as a
|
||||
@@ -118,6 +120,7 @@ ALIASES = {
|
||||
"clearRestore", "runRestore"],
|
||||
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
|
||||
"general": ["webLogin"],
|
||||
"display": ["updateSyncUI"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user