Files
simple/frontend/tests/ram_batch.test.js
T
catarrh ab373aff30 v3.28.0: the batch CAP install
The bulk workflow: one .cap and one set of install parameters applied to
card after card - swap cards and each one installs as soon as it is
equipped.  The server owns the loop, so closing the tab or reloading the
page does not interrupt it.

The install chain is now shared: _ram_install_plan(body) builds the
card-independent part (the parsed .cap, the INSTALL/LOAD sequence, the
block size, the transport knobs) and _ram_install_run(server, body, plan,
scc) runs one chain (the per-card preset/keyset/counter resolution
included); the /api/ram-install handler is the plan + run + response, and
the batch worker calls the same pair.  The plan is built once at Start,
so a bad .cap or parameter combination is a 400 there (a malformed
archive included - a zip parse error is the caller's, never a dropped
connection), not a per-card failure.  _op_keyset_resolve() is the module
form of the handler's _op_keyset() (the dead _keyset_guard method is
gone).

The worker (_ram_batch_worker, state _RAM_BATCH) polls the card state
every 0.5 s and acts on a new card session while connected; a card
already equipped at Start installs immediately.  Per card the preset is
matched by ICCID and its ISD role TAR supplies the keyset (v3.26.0) and
the counter seed (v3.20.0); a card with no readable ICCID, no matching
preset or no usable ISD keyset is recorded as skipped with its reason.
A card is attempted once per run (any outcome): re-equipping it records
"already processed in this batch (<outcome>)" and never installs again -
restarting the batch is the retry.  Failures carry the failing step and
the remote SW, are never retried and never stop the run; an unexpected
card-handling error is recorded and the loop keeps waiting.

Endpoints: POST /api/ram-batch/start (no card needed; 400/409), GET
/api/ram-batch/status (card-free - the panel polls it during an install),
POST /api/ram-batch/stop (the first _CARD_FREE_POST: it only sets a flag,
so the button works while the worker holds the card lock; a running
install always finishes first) and POST /api/ram-batch/clear (409 while
active).  /api/status carries the ram_batch summary (the detailed state
minus the attempts list), so the panel's headline - the live phase, the
current card, the counts - rides the 2 s poll; the attempts report (the
last 500 kept, the total counted) is fetched only when it changed.  The
batch and a test run exclude each other (409 both ways); the state is in
memory - a server restart ends a run.

PWA: the Batch install (.cap file, per card) RAM operation shares the
install form (ramInstallCapFields(); the single install uses it too and
now sends ram_format, and the block-size validation moved before the
modal), hides the preset selector + Execute and the preset-mismatch
banner, and renders Start/Stop/Clear, the phase headline (equip the next
card / installing card + live step / stopping after the current card),
the counts and the attempts table (newest first).  The panel only works
inside the visible SCP80 RAM pill.

Docs: help EN/RU, api.md (a section + the four table rows), the repo
AGENTS.  Tests: the state/report helpers, the per-card decisions, the
worker loop (a real thread + a fake install), the lifecycle and guards,
the HTTP endpoints (the malformed .cap, the stop under a held card lock,
the test-run refusal) and the PWA panel helpers/wiring.  914 frontend +
919 Python; sw cache simple-v438.
2026-10-14 15:01:56 +03:00

252 lines
11 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
const ASYNC_FNS = ['ramBatchFetch', 'ramBatchStart', 'ramBatchStop', 'ramBatchClear'];
let code = '';
for (const fn of ['ramBatchPanelVisible', 'ramBatchAttemptsHtml', 'ramBatchRender',
'ramBatchApplyStatus', 'ramBatchFetch', 'ramBatchStart', 'ramBatchStop',
'ramBatchClear', 'esc']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
const els = {};
function setEl(id, value) {
els[id] = {
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
disabled: false,
classes: {},
classList: {
add(c) { els[id].classes[c] = true; },
remove(c) { delete els[id].classes[c]; },
toggle(c, on) { if (on) els[id].classes[c] = true; else delete els[id].classes[c]; },
contains(c) { return !!els[id].classes[c]; },
},
};
return els[id];
}
function clearEls() {
for (const k of Object.keys(els)) delete els[k];
}
const app = new Function('els', `
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var isViewVisible = () => sandbox.visibility;
var alert = m => sandbox.alerts.push(String(m));
var pysimFetch = async (p) => { sandbox.fetched.push(p); return sandbox.replies[p] || null; };
var pysimFetchOk = async (p, body) => {
sandbox.fetched.push(p);
sandbox.bodies = sandbox.bodies || {};
sandbox.bodies[p] = body;
if (sandbox.fetchError) throw new Error(sandbox.fetchError);
return sandbox.replies[p] || { ok: true };
};
var capGateOk = () => sandbox.capGate !== false;
var ramReadFileHex = async () => 'AABB';
var ramInstallCapFields = () => ({ sd_aid: '', install_params: '', ram_format: 'auto' });
var pysimApplyAvailability = () => {};
var _ramBatchLast = null;
var _ramBatchFetching = false;
var _ramBatchSummary = null;
var _ramBatchProgress = null;
${code}
sandbox.getLast = () => _ramBatchLast;
sandbox.setLast = v => { _ramBatchLast = v; };
sandbox.getSummary = () => _ramBatchSummary;
sandbox.getProgress = () => _ramBatchProgress;
sandbox.ramBatchPanelVisible = ramBatchPanelVisible;
sandbox.ramBatchAttemptsHtml = ramBatchAttemptsHtml;
sandbox.ramBatchRender = ramBatchRender;
sandbox.ramBatchApplyStatus = ramBatchApplyStatus;
sandbox.ramBatchFetch = ramBatchFetch;
sandbox.ramBatchStart = ramBatchStart;
sandbox.ramBatchStop = ramBatchStop;
sandbox.ramBatchClear = ramBatchClear;
return sandbox;
`)(els);
test('the RAM view wires the batch operation and the panel', () => {
assert.match(html, /<option value="batch" data-l10n="Batch install \(\.cap file, per card\)">/);
assert.match(html, /<div id="ram-batch-panel"/);
for (const id of ['ram-batch-start', 'ram-batch-stop', 'ram-batch-clear',
'ram-batch-state', 'ram-batch-notice', 'ram-batch-counts', 'ram-batch-attempts']) {
assert.match(html, new RegExp('id="' + id + '"'));
}
// the batch start needs the server (not a card) and the CAP analysis
assert.match(html, /id="ram-batch-start" data-needs="server" data-cap-gate="ram"/);
assert.match(html, /'Batch install \(\.cap file, per card\)': /);
});
test('ramOpChanged hides the preset cell and Execute in batch mode', () => {
const src = extractFunc(html, 'ramOpChanged');
assert.match(src, /const isBatch = op === 'batch';/);
assert.match(src, /const isCap = op === 'install-cap' \|\| isBatch;/);
assert.match(src, /setHidden\('ram-preset-cell', isBatch\)/);
assert.match(src, /setHidden\('ram-exec-btn', isBatch\)/);
assert.match(src, /setHidden\('ram-batch-panel', !isBatch\)/);
assert.match(src, /if \(isBatch\) ramBatchFetch\(\);/);
// the preset warning does not apply to the per-card resolution
const warn = extractFunc(html, 'spPresetWarningRender');
assert.match(warn, /batchMode \? '' : spPresetWarning/);
});
test('the status poll drives the batch panel', () => {
const poll = extractFunc(html, 'pysimStartBackendPoll');
assert.match(poll, /const status = await pysimFetch\('\/api\/status'\);/);
assert.match(poll, /ramBatchApplyStatus\(status\);/);
// the single install and the batch share the field builder
const inst = extractFunc(html, 'ramInstallCap', true);
assert.match(inst, /const fields = ramInstallCapFields\(\);/);
assert.match(inst, /Object\.assign\(\{/);
const start = extractFunc(html, 'ramBatchStart', true);
assert.match(start, /ramInstallCapFields\(\)/);
assert.match(start, /pysimFetchOk\('\/api\/ram-batch\/start', fields\)/);
});
test('ramBatchAttemptsHtml renders every verdict with its detail', () => {
const out = app.ramBatchAttemptsHtml([
{ ts: 1700000000, iccid: '8970119000004600098', preset: 'Alfa', result: 'installed', duration_ms: 12000 },
{ ts: 1700000001, iccid: '8970119000004600106', preset: 'Alfa', result: 'failed',
failed_step: 'LOAD (3/7)', por_sw: '6A80', duration_ms: 9000 },
{ ts: 1700000002, iccid: '8970119000004600114', result: 'skipped',
reason: 'no preset matches this ICCID' },
]);
assert.match(out, /8970119000004600098/);
assert.match(out, /OK/);
assert.match(out, /LOAD \(3\/7\) — 6A80/);
assert.match(out, /no preset matches this ICCID/);
assert.match(out, /12\.0 s/);
// newest first
assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098'));
assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./);
});
test('ramBatchRender shows the phase, the counts and the stop button', () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
// waiting for a card
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
app.ramBatchRender();
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined);
assert.ok(els['ram-batch-start'].classes['hidden']);
// installing card X with the live step
app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 },
attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } });
app.ramBatchRender();
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
assert.match(els['ram-batch-counts'].textContent, /Installed: 2 · Failed: 1 · Skipped: 0 · 3 attempts/);
assert.strictEqual(els['ram-batch-notice'].textContent, 'Card 897… installed.');
// stopping
app.setLast({ active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchRender();
assert.match(els['ram-batch-state'].textContent, /Stopping after the current card\.\.\./);
assert.strictEqual(els['ram-batch-stop'].textContent, 'Stopping...');
assert.ok(els['ram-batch-stop'].disabled);
// stopped
app.setLast({ active: false, phase: 'stopped', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchRender();
assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped');
assert.ok(els['ram-batch-start'].classes['hidden'] === undefined);
assert.ok(els['ram-batch-stop'].classes['hidden']);
});
test('ramBatchApplyStatus fetches only when the summary changed', async () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
app.fetched = [];
app.replies['/api/ram-batch/status'] = { active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 1 };
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1, installed: 0, failed: 0, skipped: 0 } });
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/status']);
// an unchanged summary does not refetch
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 1 } });
await new Promise(r => setImmediate(r));
assert.strictEqual(app.fetched.length, 1);
// a new attempt refetches
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 2 } });
await new Promise(r => setImmediate(r));
assert.strictEqual(app.fetched.length, 2);
// the panel is not visible -> no fetch, no render
clearEls();
app.fetched = [];
app.ramBatchApplyStatus({ ram_batch: { active: true, attempts_total: 5 } });
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, []);
});
test('the panel headline follows the live summary (phase and step)', () => {
clearEls();
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
setEl('ram-batch-start', '');
setEl('ram-batch-stop', '');
setEl('ram-batch-state', '');
setEl('ram-batch-notice', '');
setEl('ram-batch-counts', '');
setEl('ram-batch-attempts', '');
// the detailed status is stale (waiting), the 2 s summary says installing
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0 });
app.ramBatchApplyStatus({
ram_batch: { active: true, phase: 'installing', attempts_total: 0,
current: { iccid: '8970119000004600098', preset: 'Alfa' },
counts: { installed: 0, failed: 0, skipped: 0 } },
ram_progress: { active: true, step: 3, total: 8, name: 'LOAD (3/7)' },
});
assert.match(els['ram-batch-state'].textContent, /Installing\.\.\. 8970119000004600098 — Alfa/);
assert.match(els['ram-batch-counts'].textContent, /LOAD \(3\/7\)/);
// ...and back to waiting when the card is gone
app.ramBatchApplyStatus({ ram_batch: { active: true, phase: 'waiting_card', attempts_total: 0,
counts: { installed: 0, failed: 0, skipped: 0 } } });
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
});
test('the stop and clear calls hit their endpoints', async () => {
clearEls();
app.fetched = [];
app.replies['/api/ram-batch/stop'] = { active: true, phase: 'stopping', attempts: [], counts: {}, attempts_total: 0 };
app.replies['/api/ram-batch/clear'] = { ok: true };
app.replies['/api/ram-batch/status'] = { active: false, phase: 'idle', attempts: [], counts: {}, attempts_total: 0 };
setEl('ram-op', 'batch');
setEl('ram-batch-panel', '');
await app.ramBatchStop();
await app.ramBatchClear();
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
});