Files
simple/frontend/tests/ram_counter_flow.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

158 lines
7.3 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) {
const re = new RegExp('(?:async\\s+)?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 src.slice(m.index, i + 1);
}
// The real functions behind the Explore Delete flow: the counter the card
// consumed must be persisted into the preset, a successful delete drops the
// record locally (no re-explore), and a delete with no PoR (the SPI may
// request none) is not a failure - live 2026-09-28 showed "Failed: 9000"
// although the delete executed.
let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
'spPresetIdx', 'spPresetCounterValue', 'spCntrPlaceholder', 'spCntrAhead',
'ramHideProgress', 'ramDeleteFromExplorer',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramCntrLowView', 'ramShowCntrLow',
'ramCntrLowBox', 'ramCntrLowClear',
'escHtml', 'esc',
'spKeysetKvnOf', 'spKeysetList', 'spKeysetFor', 'spKeysetCheck',
'spTarOptionsHtml', 'spTarRender', 'spSpi1Seed', 'spTarKeysetGuard', 'cardsTarKeysetCell', 'cardsTarKeysetFromForm', 'cardsTarKeysetRefresh', 'cardsTarRowsRender', 'cardsTarRowHtml', 'cardsTarsFromTable', 'cardsFreeTarsFromForm', 'cardsRoleTar', 'cardsTarEntry', 'cardsTarList', 'cardsRoleDefault', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'ramPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n';
}
code = 'var _cardsKeysetCount = 0;\n' + code;
// the role defaults the TAR helpers read (v3.26.0)
for (const c of ['CARDS_TAR_DEFAULTS', 'CARDS_TAR_ROLES', 'CARDS_MSL_DEFAULT']) {
const m = new RegExp('const\\s+' + c + '\\s*=\\s*([^;]+);').exec(html);
if (!m) throw new Error('const ' + c + ' not found');
code = 'var ' + c + ' = ' + m[1] + ';\n' + code;
}
eval(code);
// The dialog sweep replaced the native alert/confirm with the helpers: route
// them to the native stubs fakeEnv() installs on globalThis.
globalThis.pysimAlert = m => globalThis.alert(m);
globalThis.pysimConfirm = async m => globalThis.confirm(m);
globalThis.pysimPrompt = async (m, v) => globalThis.prompt(m, v);
function fakeEnv(por, success) {
const els = {};
const mk = () => ({ value: '', textContent: '',
classList: { add() {}, remove() {}, toggle() {} } });
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
'ram-progress', 'ram-progress-text']) els[id] = mk();
els['ram-card-sel'] = { value: '0' };
// the preset keys the real spRefreshFromPreset/cardsApply would copy into
// the form (the delete flow aborts without them)
els['sp-kic-key'].value = 'AA';
els['sp-kid-key'].value = 'BB';
els['sp-spi2-hex'].value = '01';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ id: 'preset-1', name: 'C', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000005' }] }];
const calls = { saved: 0, explored: null, removed: null, sent: null };
globalThis.cardsSave = () => { calls.saved++; };
globalThis.cardsRender = () => {};
globalThis.ramRender = () => {};
globalThis.t = s => s;
globalThis.alert = () => {};
globalThis.confirm = () => true;
globalThis.spRefreshFromPreset = () => {
const cntr = cards[0].keysets[0].cntr;
els['sp-cntr'].value = cntr;
return cntr;
};
globalThis.ramSendOta = async (apdu, sp) => {
calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2, preset_id: sp.preset_id };
return { success: success !== false, por: por };
};
globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
globalThis.ramRemoveFromExplorer = (aid, cascade) => {
calls.removed = { aid: aid, cascade: cascade };
return true;
};
return { els, calls };
}
test('accepted delete persists the consumed counter and drops the record', async () => {
const { els, calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '9000' } });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(calls.sent.cntr, '0000000005');
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C46416101');
assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006',
'the preset must carry the counter the card consumed');
// the form field is an override (empty = auto, the server seeds it); the
// operation only syncs the local preset copy, never the field (v3.20.0)
assert.strictEqual(els['sp-cntr'].value, '0000000005');
assert.strictEqual(calls.sent.preset_id, 'preset-1',
'the operation must name the server-side preset that persists the counter');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally');
assert.strictEqual(els['ram-result'].textContent, 'OK');
});
test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
const { els, calls } = fakeEnv(undefined);
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
assert.strictEqual(els['ram-result'].textContent, 'OK');
assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
});
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
const { calls } = fakeEnv({ response_status: 'cntr_low' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.saved, 0);
assert.strictEqual(calls.removed, null);
});
test('a refused DELETE still advances the counter but drops nothing', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '6A88' } });
await ramDeleteFromExplorer('F0414C46416101', true);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.strictEqual(calls.removed, null);
});
test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000006');
assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
});
test('a send failure leaves the preset untouched', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].keysets[0].cntr, '0000000005');
assert.strictEqual(calls.removed, null);
});