Files
simple/frontend/tests/stk_menu.test.js
T
catarrh 904eca1758 fix: accept comprehension-required tag variants everywhere (v3.6.40)
The TERMINAL PROFILE chain parsed the SET UP MENU title with 'tag == 0x05'
only, so the live Alfa card's title (CR-set 0x85, 'Alfa Mobile') was
dropped: /api/menu returned items without a title and the PWA showed
'No menu set by the card' - card-specific only because cards pick the tag
variant (this one sends plain 0x8F items and a CR-set title in the same
command).

- _parse_setup_menu_command (pure, tested with the live FETCH bytes)
  replaces the inline parse; the PWA shows a title-less menu as 'STK: Menu'
  instead of 'No menu set by the card' (stkMenuLabel, pure).
- _cmd_tlv is now the single lookup helper: exact tag, then tag ^ 0x80
  (bit 8 is the CR flag, TS 101 220 7.1.1); the old '| 0x80'-only helper and
  the duplicate _cmd_tlv_any are gone.
- Remaining single-style checks fixed: _parse_setup_menu_items (0x8F/0x0F),
  _parse_select_item title (0x85/0x05), _find_sms_tpdu (0x8B/0x0B - the
  plain variant lost the SEND SHORT MESSAGE PoR TPDU), the response
  scripting parsers (R-APDU 0x23/0xA3, executed count 0x80/0x00) and the
  FF21 resource tags (0x81-0x83/0x01-0x03).
- tests/test_tag_variants.py flips the CR flag on every top-level tag of
  real command fixtures and asserts identical parse results for all
  parsers (verified to fail when a single-style lookup is reintroduced).

sw cache simple-v313; tests: 524 Python / 675 Node.
2026-09-29 01:54:39 +03:00

197 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 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);
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
code += extractFunc(html, 'stkNoPendingError') + '\n';
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
code += extractFunc(html, 'stkMenuLabel') + '\n';
code += extractFunc(html, 'stkInputHtml') + '\n';
code += extractFunc(html, 'stkInputHint') + '\n';
code += extractFunc(html, 'stkInputValidate') + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
function setup(response) {
const calls = { handled: null, rendered: 0 };
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
globalThis.stkCheckMenu = () => {};
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back
: { innerHTML: '', classList: { add: () => {} } }),
};
return calls;
}
test('the STK menu item suffix shows the item next action (8.24)', () => {
assert.strictEqual(stkMenuNaiSuffix({ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' }),
'\u25b8 SET UP MENU');
// no NAI (or a reserved one, which the server drops) -> no suffix
assert.strictEqual(stkMenuNaiSuffix({ id: 2, text: 'Info' }), '');
assert.strictEqual(stkMenuNaiSuffix(null), '');
});
test('the overlay item list shows the next action and the row handler', () => {
const list = stkMenuItemsHtml([
{ id: 1, text: 'Menu', nai: 0x25, nai_name: 'SET UP MENU' },
{ id: 2, text: 'Info' },
], 'stkSubItemClick');
assert.match(list, /onclick="stkSubItemClick\(1\)"/);
assert.match(list, /onclick="stkSubItemClick\(2\)"/);
assert.ok(list.includes('Menu'));
assert.ok(list.includes('\u25b8 SET UP MENU'));
// the second item has no indicator -> exactly one suffix in the list
assert.strictEqual((list.match(/\u25b8/g) || []).length, 1);
assert.strictEqual(stkMenuItemsHtml(null, 'stkMenuItemClick'), '<div class="space-y-1"></div>');
});
test('both overlay lists render through the shared row helper', () => {
// the cached top menu and the pending SELECT ITEM items must use the same
// renderer, otherwise the latter silently loses the NAI suffix
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkMenuItemClick'\)/);
assert.match(html, /stkMenuItemsHtml\(data\.items, 'stkSubItemClick'\)/);
});
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back with a fetched DISPLAY TEXT shows it', async () => {
const data = { type: 'display_text', text: 'hello' };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [] };
const calls = setup(data);
await stkMenuRespond('timeout');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back answered with SW 9000 falls back to the cached top menu', async () => {
const calls = setup({ type: 'done', sw: '9000' });
await stkMenuRespond('back');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('cancel falls back to the cached top menu', async () => {
const calls = setup({ sw: '9000' });
await stkMenuRespond('cancel');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('ok navigates with the server response', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
const calls = setup(data);
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('stkNoPendingError detects the already-answered reply', () => {
assert.strictEqual(stkNoPendingError({ error: 'no pending command' }), true);
assert.strictEqual(stkNoPendingError({ sw: '9000', type: 'done' }), false);
assert.strictEqual(stkNoPendingError({ error: 'other' }), false);
assert.strictEqual(stkNoPendingError(null), false);
});
test('an already-answered respond does not reach the dialog handler', async () => {
const calls = setup({ error: 'no pending command' });
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 0);
});
test('stkInputHtml builds the card-driven input field', () => {
globalThis.t = s => s;
const out = stkInputHtml({ type: 'get_input', text: 'PIN', min: 4, max: 16,
digits_only: true, hidden: true });
assert.ok(out.includes('PIN'), out);
assert.ok(out.includes('type="password"'), out); // hidden entry
assert.ok(out.includes('inputmode="numeric"'), out); // digits only
assert.ok(out.includes('maxlength="16"'), out);
assert.ok(out.includes('4\u201316 characters'), out);
assert.ok(out.includes('hidden entry'), out);
// UCS2 alphabet entry: a plain text field without the numeric keyboard
const ucs2 = stkInputHtml({ type: 'get_input', text: 'Name', min: 0, max: 10, ucs2: true });
assert.ok(ucs2.includes('type="text"'), ucs2);
assert.ok(!ucs2.includes('inputmode'), ucs2);
assert.ok(ucs2.includes('up to 10 characters'), ucs2);
// 0xFF = no maximum (TS 102 223 8.11): no maxlength and no range hint
const noMax = stkInputHtml({ type: 'get_input', text: 'x', min: 0, max: 255 });
assert.ok(!noMax.includes('maxlength'), noMax);
assert.ok(!noMax.includes('characters'), noMax);
// Yes/No: buttons instead of a field
const yesNo = stkInputHtml({ type: 'get_inkey', text: 'Continue?', yes_no: true });
assert.ok(yesNo.includes("stkInputSubmit('01')"), yesNo);
assert.ok(yesNo.includes("stkInputSubmit('00')"), yesNo);
assert.ok(!yesNo.includes('stk-input-field'), yesNo);
// the card's default text is prefilled; help adds the help action
const def = stkInputHtml({ type: 'get_input', text: 'x', default: '1234', help: true });
assert.ok(def.includes('value="1234"'), def);
assert.ok(def.includes("stkMenuRespond('help')"), def);
});
test('stkInputValidate mirrors the server input rules', () => {
globalThis.t = s => s;
const pd = { type: 'get_input', min: 4, max: 16, digits_only: true, hidden: true };
assert.strictEqual(stkInputValidate(pd, '1234').ok, true);
assert.strictEqual(stkInputValidate(pd, '12').ok, false);
assert.strictEqual(stkInputValidate(pd, '12345678901234567').ok, false);
assert.strictEqual(stkInputValidate(pd, 'abcd').ok, false);
// hidden entry allows only the digits set even without digits_only
assert.strictEqual(stkInputValidate({ type: 'get_input', hidden: true }, 'ab').ok, false);
// GET INKEY: exactly one character, digits-only honours * # +
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'a').ok, true);
assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'ab').ok, false);
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
});
test('stkMenuLabel shows the card title or a generic label', () => {
globalThis.t = s => s;
// the card's title when it sent one
assert.strictEqual(stkMenuLabel({ title: 'Alfa Mobile', items: [{ id: 1 }] }), 'Alfa Mobile');
// a title-less SET UP MENU still shows the menu (TS 102 223 6.6.7)
assert.strictEqual(stkMenuLabel({ items: [{ id: 1 }] }), 'Menu');
// no items = no menu, whatever the title
assert.strictEqual(stkMenuLabel({ title: 'x', items: [] }), '');
assert.strictEqual(stkMenuLabel({ items: [] }), '');
assert.strictEqual(stkMenuLabel(null), '');
});