feat: warn when the equipped card and the selected preset disagree (v3.8.0)

Autoselection normally keeps the SCP80/RAM preset in step with the card, so
the mismatch is easy to miss.  Both views now carry an amber banner above the
preset selector (the existing warning style - no new Tailwind classes):

- the equipped card matches no preset: "The equipped card <ICCID> does not
  match any card preset - the selected preset may belong to another card.
  Add it in the Cards tab (From card)"; with no presets at all the banner says
  so instead;
- a preset is selected but it is not the equipped card's: a softer line naming
  the matching preset ("check that its keys and counters belong to this card").

`spPresetWarning(iccid, presets, selectedIdx)` is pure (unit-tested with the
ICCID forms the store accepts: digits, spaced, raw EF hex), `spPresetWarningRender`
fills both banners and runs on every card-state poll, preset load, preset
selection and page init.  No card session or an unreadable ICCID shows nothing
(the card-iccid gating already explains that state); the banner warns only -
it never blocks a send (preparing a packet for another card stays possible).

Tests: frontend/tests/preset_warning.test.js (5), the card-state/preset
harnesses stub the DOM-bound renderer.  sw cache simple-v336.
This commit is contained in:
2026-09-30 15:37:51 +03:00
parent cc2edb3d8a
commit 386958b261
9 changed files with 144 additions and 5 deletions
+83
View File
@@ -0,0 +1,83 @@
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('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);
}
let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid', 'cardsNormIccid',
'spPresetWarning']) {
code += extractFunc(html, fn) + '\n';
}
code += 'function t(s){return s;}\n';
eval(code);
const DIGITS = '8970119000004600098';
const RAW_HEX = '980711090000640090F8';
const OTHER = '89390100000129506903';
const CARDS = [
{ name: 'Card A', iccid: DIGITS },
{ name: 'Card B', iccid: OTHER },
];
test('no warning without a card session or a readable ICCID', () => {
assert.strictEqual(spPresetWarning(null, CARDS, 0), '');
assert.strictEqual(spPresetWarning('', CARDS, 0), '');
assert.strictEqual(spPresetWarning(undefined, CARDS, 0), '');
});
test('no warning when the equipped card matches a preset', () => {
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 0), '');
// the preset's ICCID may be stored in any form
assert.strictEqual(spPresetWarning(DIGITS, [{ name: 'A', iccid: RAW_HEX }], 0), '');
// the match does not depend on the selection
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 1) === '', false);
});
test('a card with no matching preset gets the strong warning', () => {
const text = spPresetWarning('8945001234567890123', CARDS, 0);
assert.match(text, /8945001234567890123/);
assert.match(text, /does not match any card preset/);
assert.match(text, /From card/);
// no presets at all: the other wording
const none = spPresetWarning(DIGITS, [], 0);
assert.match(none, /No card presets defined/);
assert.match(spPresetWarning(DIGITS, null, -1), /No card presets defined/);
});
test('a manually selected preset of another card gets the softer hint', () => {
const text = spPresetWarning(DIGITS, CARDS, 1);
assert.match(text, /selected preset is not the equipped card/);
assert.match(text, /Card A/);
// an empty / out-of-range selection is not the softer case (the strong
// warning above covers a card with no preset)
assert.strictEqual(spPresetWarning(DIGITS, CARDS, -1), '');
assert.strictEqual(spPresetWarning(DIGITS, CARDS, 5), '');
});
test('both SCP80 views carry the warning banner', () => {
assert.match(html, /id="sp-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/);
assert.match(html, /id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/);
// ... and the renderer is wired into the card state, the preset load and
// the preset selection
assert.ok(html.includes('spPresetWarningRender();'));
assert.ok(html.includes("show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));"));
assert.ok(html.includes("show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx()));"));
});