6360dc5bae
Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar fields: a preset now carries `tars` - the three mandatory role entries (isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an optional description, each with its MSL (Minimum SPI1, TS 102 226 8.2.1.3.2.4). v3.9.1 presets convert on load/import (the spi1 seeds all three MSLs); duplicate TARs and empty TAR/MSL values are refused; the store schema version is 3. Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the RAM ops, the test runner (per-step overrides still win), the counter probe and each TAR-probe row; the plain send keeps an explicit SPI1 with a `warning` when it is below the MSL, and a TAR outside the preset without an explicit SPI1 is refused with a hint. The TAR probe resolves per checked TAR before sending and reports the effective spi1 per row. The secured packet form's SPI2 default is 00 (the RAM/probe operations pin their own). PWA: the Cards form edits the TAR table (role rows with MSL inputs plus Add/Remove free rows with a description), the list shows a TARs (MSL) column, the SCP80 TAR field suggests the preset's entries (datalist) and a preset apply seeds the SPI1 from the operation TAR's MSL with an amber warning below it; the preset-wide SPI1/SPI2 fields and the header marker's SPI requirement are gone (completeness = a keyset + the three role entries with their MSLs). Help EN/RU, docs/api.md and AGENTS updated; sw cache simple-v358. Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter advanced); a send without spi1 derived 16 (por_ok, counter advanced); a send with spi1 0A returned the warning and the card refused it (6200, no counter consumed); an unknown TAR without spi1 was refused with the hint.
208 lines
8.4 KiB
JavaScript
208 lines
8.4 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 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';
|
||
}
|
||
|
||
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', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
|
||
'cardsIccidFromCard', 'cardsAutoSelectByIccid', 'cardsRender',
|
||
'esc', 'spKeysetList', 'spKeysetKvnOf',
|
||
'cardsTarList', 'cardsRoleDefault', 'cardsRoleTar', 'cardsRoleMsl',
|
||
'cardsTarEntry', 'cardsTarMsl', 'cardsTarText', 'cardsScp81Complete']) {
|
||
code += extractFunc(html, fn) + '\n';
|
||
}
|
||
code += extractConst(html, 'CARDS_TAR_DEFAULTS') + extractConst(html, 'CARDS_TAR_ROLES')
|
||
+ extractConst(html, 'CARDS_MSL_DEFAULT');
|
||
eval(code);
|
||
|
||
// live-card style raw EF.ICCID content (nibble-swapped digits + 'F' pad)
|
||
const RAW_HEX = '980711090000640090F8';
|
||
const DIGITS = '8970119000004600098';
|
||
|
||
function fakeDoc() {
|
||
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' },
|
||
'tar-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
|
||
globalThis.document = { getElementById: id => els[id] || null };
|
||
return els;
|
||
}
|
||
|
||
function fakeAlert() {
|
||
const seen = [];
|
||
globalThis.alert = msg => seen.push(msg);
|
||
globalThis.t = s => s;
|
||
return seen;
|
||
}
|
||
|
||
test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
|
||
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(RAW_HEX), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(RAW_HEX.toLowerCase()), DIGITS);
|
||
assert.strictEqual(cardsNormIccid(''), '');
|
||
assert.strictEqual(cardsNormIccid(null), '');
|
||
});
|
||
|
||
test('cardsFindByIccid matches regardless of the stored form', () => {
|
||
globalThis.cards = [
|
||
{ name: 'A', iccid: '1111111111111111111' },
|
||
{ name: 'B', iccid: RAW_HEX },
|
||
{ name: 'C', iccid: '89 70 1190 0000 4600 098' },
|
||
];
|
||
assert.strictEqual(cardsFindByIccid(DIGITS), 1);
|
||
assert.strictEqual(cardsFindByIccid('1111111111111111111'), 0);
|
||
assert.strictEqual(cardsFindByIccid('2222222222222222222'), -1);
|
||
assert.strictEqual(cardsFindByIccid(''), -1);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid selects the preset in both SCP80 views', () => {
|
||
const els = fakeDoc();
|
||
const applied = [];
|
||
globalThis.cardsApply = idx => applied.push(idx);
|
||
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }, { name: 'B', iccid: '' }];
|
||
globalThis._ramCardIdx = null;
|
||
globalThis._cardsAutoIccid = null;
|
||
const idx = cardsAutoSelectByIccid(DIGITS);
|
||
assert.strictEqual(idx, 0);
|
||
assert.strictEqual(els['sp-card-sel'].value, '0');
|
||
assert.strictEqual(els['ram-card-sel'].value, '0');
|
||
assert.strictEqual(els['tar-card-sel'].value, '0',
|
||
'the TAR probe view gets the equipped card\'s preset too');
|
||
assert.deepStrictEqual(applied, ['0']);
|
||
assert.strictEqual(globalThis._ramCardIdx, 0);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid does not override a manual choice for the same card', () => {
|
||
const els = fakeDoc();
|
||
const applied = [];
|
||
globalThis.cardsApply = idx => applied.push(idx);
|
||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
|
||
globalThis._ramCardIdx = null;
|
||
globalThis._cardsAutoIccid = null;
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
// user picks the empty preset B for this card, then status polls repeat
|
||
els['sp-card-sel'].value = '1';
|
||
els['ram-card-sel'].value = '1';
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
|
||
assert.strictEqual(els['sp-card-sel'].value, '1');
|
||
assert.deepStrictEqual(applied, ['0']);
|
||
});
|
||
|
||
test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', () => {
|
||
fakeDoc();
|
||
globalThis.cardsApply = () => {};
|
||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '89390100000129506903' }];
|
||
globalThis._cardsAutoIccid = null;
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
assert.strictEqual(cardsAutoSelectByIccid('89390100000129506903'), 1);
|
||
// ICCID not readable: reset the guard, keep the current selection
|
||
assert.strictEqual(cardsAutoSelectByIccid(null), -1);
|
||
assert.strictEqual(globalThis._cardsAutoIccid, null);
|
||
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
|
||
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
|
||
});
|
||
|
||
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
|
||
globalThis.cards = [
|
||
{ name: 'A', iccid: '1111111111111111111' },
|
||
{ name: 'B', iccid: RAW_HEX },
|
||
{ name: 'C', iccid: '' },
|
||
];
|
||
assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1);
|
||
assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1);
|
||
assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0);
|
||
assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1);
|
||
// empty ICCID is never a duplicate (the field is optional)
|
||
assert.strictEqual(cardsFindDuplicateIccid(''), -1);
|
||
assert.strictEqual(cardsFindDuplicateIccid(' '), -1);
|
||
});
|
||
|
||
test('cardsFindDuplicateIccid skips the row being edited', () => {
|
||
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
|
||
assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1);
|
||
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1);
|
||
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0);
|
||
});
|
||
|
||
test('cardsIccidFromCard fills the field from the equipped card', () => {
|
||
const els = fakeDoc();
|
||
const seen = fakeAlert();
|
||
globalThis._pysimCardIccid = DIGITS;
|
||
cardsIccidFromCard();
|
||
assert.strictEqual(els['cards-iccid'].value, DIGITS);
|
||
assert.deepStrictEqual(seen, []);
|
||
// no readable ICCID (state raced the click): alert, leave the field alone
|
||
els['cards-iccid'].value = 'keep-me';
|
||
globalThis._pysimCardIccid = null;
|
||
cardsIccidFromCard();
|
||
assert.strictEqual(els['cards-iccid'].value, 'keep-me');
|
||
assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']);
|
||
});
|
||
|
||
test('the From card button and the duplicate refusal are wired into the form', () => {
|
||
assert.match(html, /<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard\(\)"/);
|
||
assert.ok(html.includes('data-l10n="From card"'));
|
||
// normalized duplicate check with the conflicting preset named
|
||
assert.ok(html.includes('cardsFindDuplicateIccid(v.iccid, _cardsEditIdx)'));
|
||
assert.ok(html.includes("t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name"));
|
||
assert.ok(html.includes("'From card': 'С карты'"));
|
||
assert.ok(html.includes("'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке'"));
|
||
});
|
||
|
||
test('the card-state update wires the ICCID into the preset selection', () => {
|
||
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
|
||
assert.ok(html.includes('_cardsAutoIccid = null;'));
|
||
assert.ok(html.includes("getElementById('tar-card-sel')"),
|
||
'the auto-selection must cover the TAR probe view');
|
||
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
|
||
});
|
||
|
||
test('cardsRender highlights the equipped card preset', () => {
|
||
globalThis.t = s => s;
|
||
const els = { 'cards-tbody': { innerHTML: '' } };
|
||
globalThis.document = { getElementById: id => els[id] || null };
|
||
globalThis.cards = [
|
||
{ name: 'other', iccid: '1111111111111111111', keysets: [] },
|
||
{ name: 'live', iccid: DIGITS, keysets: [] },
|
||
];
|
||
globalThis._pysimCardIccid = DIGITS;
|
||
cardsRender();
|
||
let rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||
assert.ok(!/bg-emerald-50/.test(rows[0]), 'the other preset must stay plain');
|
||
assert.match(rows[1], /bg-emerald-50/);
|
||
assert.match(rows[1], /The equipped card's preset/);
|
||
// the same card stored as raw EF hex matches too
|
||
globalThis.cards[1].iccid = RAW_HEX;
|
||
cardsRender();
|
||
rows = els['cards-tbody'].innerHTML.split('</tr>');
|
||
assert.match(rows[1], /bg-emerald-50/);
|
||
// no readable ICCID -> no highlight
|
||
globalThis._pysimCardIccid = null;
|
||
cardsRender();
|
||
assert.ok(!/bg-emerald-50/.test(els['cards-tbody'].innerHTML));
|
||
});
|