Files
simple/frontend/tests/cards_outfile.test.js
T
catarrh 9a688207ba v3.30.4: the in-app dialogs (native alert/confirm/prompt replaced)
The native browser dialogs block the page and look foreign inside the
app.  One shared modal (#pysim-dialog-modal) with the promise-based
helpers pysimAlert/pysimConfirm/pysimPrompt replaces all 71 call sites
(60 alert, 10 confirm, 1 prompt in profilerNew); deferredPrompt.prompt()
is the PWA install prompt and is untouched.

The helpers serialize through ONE promise queue (overlapping dialogs
appear in order, no message is overwritten).  Escape cancels; Enter
accepts except with the Cancel button focused (native behaviour); key
repeats/IME are ignored; a POINTER answer within 500 ms of a dialog
opening is dropped (the buttons keep the same position, so a
double-click's second click would otherwise answer the next queued
dialog - e.g. an alert queued before a destructive confirm).  Backdrop
clicks are ignored.  The dialog is role="dialog" aria-modal="true" and
traps Tab (the handler cycles the controls the dialog shows: alert OK;
confirm Cancel/OK; prompt Cancel/input/OK), so the focus can neither
leave the modal nor reach a background control (Space would activate
it).  When the modal is absent the helpers fall back to the native
dialogs - the Node tests extract single functions without the markup.

Alerts are fire-and-forget (a notification, not a decision); the 10
confirms and the prompt are awaited, which made five functions async
(scriptsDelete, pysimCustomRemove, profilerNew, profilerDelete,
snapshotDelete).  Because the modal no longer freezes the page, the
delete flows that mutated by index after the confirmation mutate by
identity now (profilerDelete/snapshotDelete/pysimCustomRemove/
testSuiteDeleteScript/testSuiteDelete): a list reorder during the open
dialog would otherwise delete the wrong entry.

The nine dialog messages that were still raw English literals are
t()-wrapped - three of them (No secured packet to verify/send, Card
reader server is not connected) activated RU entries that had existed
unused because they were never wrapped; Import error and Snapshot save
failed are new keys.

Drive-by: the batch modal's #ram-op-batch-info used whitespace-pre-line,
which has no rule in the prebuilt style.css (the v3.30.0 stale-style
trap), so its notice never line-broke - now whitespace-pre-wrap.

Tests: a new frontend/tests/dialogs.test.js (15 tests: the modal path,
the queue, the double-answer guard, the keyboard, the grace, the trap,
the fallback, the sweep pin, the stylesheet pin) plus the async/helper
updates in 8 existing test files.  945 frontend + 952 Python;
sw cache simple-v454.
2026-10-18 00:30:06 +03:00

351 lines
16 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);
}
function extractConst(src, name) {
const m = new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + ' = ' + m[1] + ';\n';
}
const ASYNC_FNS = ['cardsOutfileFile', 'cardsOutfilePreview', 'cardsOutfileImport'];
let code = '';
for (const fn of ['cardsOutfileTarsFromDom', 'cardsOutfileTarsFromForm',
'cardsOutfileTarRowHtml', 'cardsOutfileTarRowsHtml', 'cardsOutfileTarAdd',
'cardsOutfileTarRemove', 'cardsOutfileTarRender', 'cardsOutfileKeysetOptions',
'cardsOutfileResultHtml', 'cardsOutfileControls', 'cardsOutfileClose',
'cardsOutfileRender', 'cardsOutfileFile', 'cardsOutfilePreview',
'cardsOutfileImport', 'esc', 'escHtml', 'cardsRoleDefault', 'spKeysetKvnOf']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
code = extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
+ extractConst(html, 'CARDS_MSL_DEFAULT') + extractConst(html, 'CARDS_TAR_ROLE_DESC')
+ 'var _cardsOutfile = null;\nvar _cardsOutfileTars = 0;\n' + code;
// A minimal DOM: the tests set the elements they need, everything else is
// absent (the code guards every getElementById result).
const els = {};
const app = new Function('els', `
var sandbox = { calls: [], io: [], alerts: [], fetched: 0, reply: null, replyError: null, confirmResult: true };
var document = { getElementById: id => els[id] || null };
var t = k => k;
var alert = m => { sandbox.alerts.push(String(m)); };
var confirm = m => { sandbox.confirmCalls = (sandbox.confirmCalls || []); sandbox.confirmCalls.push(String(m)); return sandbox.confirmResult; };
var pysimAlert = m => alert(m);
var pysimConfirm = async m => confirm(m);
var pysimPrompt = async () => ''; // not used by the outfile flow
var ioStatus = (id, msg) => { sandbox.io.push([id, msg]); };
var pysimFetchOk = async (path, body) => {
sandbox.calls.push({ path: path, body: body });
if (sandbox.replyError) throw new Error(sandbox.replyError);
return sandbox.reply;
};
var cardsFetch = async () => { sandbox.fetched++; };
${code}
sandbox.getState = () => _cardsOutfile;
sandbox.setState = v => { _cardsOutfile = v; };
sandbox.getTars = () => _cardsOutfileTars;
sandbox.setTars = v => { _cardsOutfileTars = v; };
Object.defineProperty(sandbox, 'state', { get: () => _cardsOutfile, set: v => { _cardsOutfile = v; } });
Object.defineProperty(sandbox, 'tars', { get: () => _cardsOutfileTars, set: v => { _cardsOutfileTars = v; } });
sandbox.cardsOutfileTarsFromDom = cardsOutfileTarsFromDom;
sandbox.cardsOutfileTarsFromForm = cardsOutfileTarsFromForm;
sandbox.cardsOutfileTarRowHtml = cardsOutfileTarRowHtml;
sandbox.cardsOutfileTarRowsHtml = cardsOutfileTarRowsHtml;
sandbox.cardsOutfileTarAdd = cardsOutfileTarAdd;
sandbox.cardsOutfileTarRemove = cardsOutfileTarRemove;
sandbox.cardsOutfileTarRender = cardsOutfileTarRender;
sandbox.cardsOutfileKeysetOptions = cardsOutfileKeysetOptions;
sandbox.cardsOutfileResultHtml = cardsOutfileResultHtml;
sandbox.cardsOutfileControls = cardsOutfileControls;
sandbox.cardsOutfileClose = cardsOutfileClose;
sandbox.cardsOutfileRender = cardsOutfileRender;
sandbox.cardsOutfileFile = cardsOutfileFile;
sandbox.cardsOutfilePreview = cardsOutfilePreview;
sandbox.cardsOutfileImport = cardsOutfileImport;
return sandbox;
`)(els);
app.state = null;
app.tars = 0;
function setEl(id, value) {
els[id] = { value: value, innerHTML: '', classList: { add() {}, remove() {} } };
}
function clearEls() {
for (const k of Object.keys(els)) delete els[k];
}
const TEXT = 'var_out:IMSI ICCID KIC1 KID1\n250197700000000 8970119000004600098F ' +
'00112233445566778899AABBCCDDEEFF FFEEDDCCBBAA99887766554433221100\n';
test('the Cards view wires the outfile button, the input and the panel', () => {
assert.match(html, /onclick="cardsOutfilePick\(\)"[^>]*data-l10n="Import outfile"/);
assert.match(html, /<input type="file" id="cards-outfile"[^>]*onchange="cardsOutfileFile\(this\)"/);
assert.match(html, /<div id="cards-outfile-panel"/);
assert.match(html, /'Import outfile': 'Импорт outfile'/); // the term stays untranslated
});
test('the preview and the import both post the outfile endpoint', () => {
const preview = extractFunc(html, 'cardsOutfilePreview', true);
const imp = extractFunc(html, 'cardsOutfileImport', true);
assert.match(preview, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /'\/api\/presets\/import-outfile'/);
assert.match(imp, /mode: o\.mode/);
// the keysets travel per TAR row (v3.29.1): no global tar_keyset
assert.ok(!preview.includes('tar_keyset'), 'the preview must not send a global keyset');
assert.ok(!imp.includes('tar_keyset'), 'the import must not send a global keyset');
assert.match(preview, /tars: cardsOutfileTarsFromForm\(\)/);
assert.match(imp, /tars: cardsOutfileTarsFromForm\(\), mode: o\.mode/);
});
test('the 16-byte algorithm values match the server map', () => {
const py = fs.readFileSync(
path.join(__dirname, '..', '..', 'pysim_simple_server', 'outfile.py'), 'utf8');
const m = /_KEY_ALGO_16\s*=\s*\{([^}]*)\}/.exec(py);
assert.ok(m, '_KEY_ALGO_16 not found in outfile.py');
const server = [...m[1].matchAll(/'([a-z0-9]+)'\s*:/g)].map(x => x[1]).sort();
const ui = [...html.matchAll(/<option value="(3des2|aes)"/g)].map(x => x[1]).sort();
assert.deepStrictEqual(ui, server);
});
test('cardsOutfileTarsFromForm fills the role defaults and keeps the free rows', () => {
clearEls();
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
app.tars = 0;
const tars = app.cardsOutfileTarsFromForm();
assert.deepStrictEqual(tars.map(e => e.role), ['isd', 'uiccRfm', 'usimRfm']);
assert.deepStrictEqual(tars.map(e => e.tar), ['000000', 'B00000', 'B00001']);
assert.deepStrictEqual(tars.map(e => e.msl), ['16', '16', '16']);
// a typed role value wins, a typed free row is kept, an empty one dropped
setEl('cards-of-tar-0-tar', 'af4d01');
setEl('cards-of-tar-0-msl', '0a');
app.tars = 2;
setEl('cards-of-tar-3-tar', 'b00011');
setEl('cards-of-tar-3-msl', '16');
setEl('cards-of-tar-3-desc', 'push');
const tars2 = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars2.length, 4);
assert.deepStrictEqual([tars2[0].tar, tars2[0].msl], ['AF4D01', '0A']);
assert.deepStrictEqual([tars2[3].tar, tars2[3].msl, tars2[3].desc], ['B00011', '16', 'push']);
assert.strictEqual(tars2[3].role, undefined);
});
test('the TAR rows render the role ids and the free rows with a remove button', () => {
clearEls();
app.state = { text: TEXT, file: 'a.out', name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge', preview: null };
app.tars = 0;
const rows = app.cardsOutfileTarRowsHtml();
assert.match(rows, /id="cards-of-tar-0-tar"[^>]*value="000000"/);
assert.match(rows, /id="cards-of-tar-2-msl"[^>]*value="16"/);
assert.match(rows, /ISD compact format/);
assert.ok(!rows.includes('cards-of-tar-3-tar'));
// a free row carries the description input and the remove action
app.tars = 1;
const rows2 = app.cardsOutfileTarRowsHtml();
assert.match(rows2, /id="cards-of-tar-3-tar"/);
assert.match(rows2, /id="cards-of-tar-3-desc"/);
assert.match(rows2, /onclick="cardsOutfileTarRemove\(3\)"/);
// every row has a keyset select, fed by the parsed keysets (v3.29.1)
assert.match(rows2, /id="cards-of-tar-0-keyset"/);
assert.match(rows2, /id="cards-of-tar-3-keyset"/);
app.state.preview = { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] };
const rows3 = app.cardsOutfileTarRowsHtml();
assert.match(rows3, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(rows3, /<option value="2">Keyset 2 — 25\/25<\/option>/);
// a non-zero MSL without a keyset gets the amber marker
app.state.preview = { presets: [] };
const rows4 = app.cardsOutfileTarRowsHtml();
assert.match(rows4, /This TAR needs a keyset/);
});
test('cardsOutfileKeysetOptions collects the parsed keysets with their bytes', () => {
clearEls();
app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [
{ keysets: [{ kic: '25', kid: '25' }, { kic: '15', kid: '15' }] },
{ keysets: [{ kic: '15', kid: '15' }, { kic: '31', kid: '31' }] },
] },
};
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(),
[{ v: 1, kic: '15', kid: '15' }, { v: 2, kic: '25', kid: '25' },
{ v: 3, kic: '31', kid: '31' }]);
app.state.preview = { presets: [] };
assert.deepStrictEqual(app.cardsOutfileKeysetOptions(), []);
});
test('cardsOutfileResultHtml renders the report and the parsed cards', () => {
clearEls();
app.state = {
text: TEXT, name: 'x', algorithm16: '3des2', tarKeyset: null, mode: 'merge',
preview: {
presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }], adm: '62329F37' }],
report: { columns: ['IMSI', 'ICCID', 'KIC1', 'KID1'], rows: 2, cards: 1,
keysets: ['KIC1/KID1'], skipped: ['line 3: no ICCID'], skipped_more: 1 },
},
};
const out = app.cardsOutfileResultHtml();
assert.match(out, /Columns: <span class="font-mono">IMSI ICCID KIC1 KID1<\/span>/);
assert.match(out, /Cards: 1 \/ rows read: 2/);
assert.match(out, /Keysets: <span class="font-mono">KIC1\/KID1<\/span>/);
assert.match(out, /line 3: no ICCID/);
assert.match(out, /\(\+1\)/);
assert.match(out, /8970119000004600098/);
assert.match(out, /15\/15/);
assert.match(out, /ADM/);
// an empty preview says so, an error is shown
app.state.preview = null;
assert.match(app.cardsOutfileResultHtml(), /Parsing\.\.\./);
app.state.preview = { report: { error: 'no column-name line found' } };
assert.match(app.cardsOutfileResultHtml(), /no column-name line found/);
});
test('cardsOutfileControls reads the panel controls back into the state', () => {
clearEls();
app.state = { text: TEXT, name: 'old', algorithm16: '3des2', mode: 'merge', preview: null };
setEl('cards-of-name', ' Batch 7 ');
setEl('cards-of-algo', 'aes');
setEl('cards-of-mode', 'replace');
app.cardsOutfileControls();
assert.strictEqual(app.state.name, 'Batch 7');
assert.strictEqual(app.state.algorithm16, 'aes');
assert.strictEqual(app.state.mode, 'replace');
});
test('picking a file previews it with the file name and the TAR defaults', async () => {
clearEls();
app.calls = [];
app.reply = { preview: true, presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
report: { columns: ['ICCID'], rows: 1, cards: 1 } };
setEl('cards-outfile-panel', '');
const input = { value: 'C:\\fakepath\\batch.out', files: [{ name: 'batch.out', text: async () => TEXT }] };
await app.cardsOutfileFile(input);
assert.strictEqual(input.value, '');
assert.strictEqual(app.calls.length, 1);
assert.strictEqual(app.calls[0].path, '/api/presets/import-outfile');
const body = app.calls[0].body;
assert.strictEqual(body.text, TEXT);
assert.strictEqual(body.name, 'batch'); // the file base name
assert.strictEqual(body.algorithm16, '3des2');
assert.strictEqual(body.mode, undefined); // the preview is a dry run
assert.strictEqual(body.tar_keyset, undefined); // the keysets are per row now
assert.strictEqual(body.tars.length, 3);
// the rows' keyset selects are filled from the parsed keysets (the first one
// is the default)
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-tar-0-keyset"/);
assert.match(els['cards-outfile-panel'].innerHTML, /<option value="1" selected>Keyset 1 — 15\/15<\/option>/);
assert.match(els['cards-outfile-panel'].innerHTML, /8970119000004600098/);
});
test('a failing preview shows the server message in the panel', async () => {
clearEls();
app.calls = [];
app.replyError = 'TAR must be three hex bytes: ZZ';
setEl('cards-outfile-panel', '');
const input = { value: '', files: [{ name: 'bad.out', text: async () => TEXT }] };
await app.cardsOutfileFile(input);
app.replyError = null;
assert.match(app.cardsOutfileResultHtml(), /TAR must be three hex bytes/);
});
test('the import posts the chosen mode and refreshes the list', async () => {
clearEls();
app.calls = [];
app.fetched = 0;
app.io = [];
app.confirmResult = true;
app.reply = { ok: true, preview: false, added: 2, skipped: 1, errors: ['dup'] };
setEl('cards-outfile-panel', '');
setEl('cards-of-mode', 'replace');
setEl('cards-of-name', 'Batch 7');
app.state = { text: TEXT, file: 'batch.out', name: 'Batch 7', algorithm16: '3des2',
mode: 'merge', preview: { presets: [], report: {} } };
setEl('cards-of-tar-0-keyset', '2'); // the ISD row's chosen keyset
await app.cardsOutfileImport();
assert.strictEqual(app.calls.length, 1);
assert.strictEqual(app.calls[0].body.mode, 'replace');
assert.strictEqual(app.calls[0].body.tar_keyset, undefined); // per row, not global
assert.strictEqual(app.calls[0].body.tars[0].keyset, 2);
assert.strictEqual(app.calls[0].body.name, 'Batch 7');
assert.strictEqual(app.fetched, 1);
assert.deepStrictEqual(app.io, [['cards-io', 'Imported 2 cards (1 skipped).']]);
assert.strictEqual(app.state, null); // the panel closed
// the replace mode asks first, and a declined answer imports nothing
assert.deepStrictEqual(app.confirmCalls, ['Replace mode wipes the whole preset store. Continue?']);
});
test('a declined replace confirmation aborts the import', async () => {
clearEls();
app.calls = [];
app.confirmResult = false;
setEl('cards-of-mode', 'replace');
app.state = { text: TEXT, file: 'batch.out', name: 'x', algorithm16: '3des2',
tarKeyset: null, mode: 'merge', preview: { presets: [], report: {} } };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 0);
assert.ok(app.state); // the panel stays open
// merge never asks
app.confirmResult = false;
setEl('cards-of-mode', 'merge');
app.reply = { ok: true, preview: false, added: 1, skipped: 0, errors: [] };
await app.cardsOutfileImport();
app.confirmResult = true;
assert.strictEqual(app.calls.length, 1);
});
test('the Import button is disabled while the report has a file error (v3.29.2)', () => {
clearEls();
setEl('cards-outfile-panel', '');
app.state = { text: TEXT, file: 'bad.out', name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [], report: { error: 'line 2 has 2 values, the header declares 3 columns - the file is invalid' } } };
app.tars = 0;
app.cardsOutfileRender();
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" disabled/);
assert.match(els['cards-outfile-panel'].innerHTML, /the header declares 3 columns/);
// a valid report keeps it enabled
app.state.preview = { presets: [{ iccid: '8970119000004600098', keysets: [{ kic: '15', kid: '15' }] }],
report: { columns: ['ICCID', 'KIC1', 'KID1'], rows: 1, cards: 1 } };
app.cardsOutfileRender();
assert.match(els['cards-outfile-panel'].innerHTML, /id="cards-of-import" onclick/);
});
test('a row keeps its chosen keyset through the DOM round-trip', () => {
clearEls();
app.state = { text: TEXT, name: 'x', algorithm16: '3des2', mode: 'merge',
preview: { presets: [{ keysets: [{ kic: '15', kid: '15' }, { kic: '25', kid: '25' }] }] } };
app.tars = 0;
setEl('cards-of-tar-0-keyset', '2'); // the user picked keyset 2 for the ISD row
setEl('cards-of-tar-1-keyset', '');
const tars = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars[0].keyset, 2);
assert.strictEqual(tars[1].keyset, null);
// a free row carries its own keyset too
app.tars = 1;
setEl('cards-of-tar-3-tar', 'b00011');
setEl('cards-of-tar-3-msl', '1a');
setEl('cards-of-tar-3-keyset', '1');
const tars2 = app.cardsOutfileTarsFromForm();
assert.strictEqual(tars2.length, 4);
assert.strictEqual(tars2[3].keyset, 1);
});