Files
simple/frontend/tests/event_forms.test.js
T
catarrh 152878062f v3.18.2: review fixes - sticky event-JSON error, store keep-on-load, write queue
The four findings from the review of the 3.15-3.18 batch:

- The event step's `_eventFieldsError` flag is cleared at the start of each
  collect: fixing malformed JSON used to leave the save refused until the
  editor was closed and reopened.  Regression test covers invalid -> fixed.
- The script store keeps an entry that no longer validates on load (a
  tightened engine rule or a hand edit): it is served, fails at run start with
  the validator's message and can be fixed in the editor, instead of being
  dropped from memory and erased by the next save.  Mutation paths still
  refuse it.  (The presets store already had this behaviour.)
- Writebacks are serialized per script (`_testWriteChains`): two quick saves
  of an unsaved draft no longer create two server-side scripts - the window
  could be minutes while a RAM operation holds the card lock.  Regression
  test asserts one create + one update.
- Deleting a script cancels its pending debounced save and marks it deleted,
  so a queued writeback never resurrects it; a create already in flight is
  undone with a delete.  Regression test covers the pending-save case.
- The event vectors moved to a shared fixture
  (frontend/tests/event_vectors.json) asserted by BOTH suites - a one-sided
  builder change now fails the other suite instead of drifting silently.

Tests: 816 frontend + 716 Python; sw cache simple-v383.
2026-10-04 18:27:04 +03:00

347 lines
17 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 extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found');
return html.slice(start, end);
}
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);
}
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'cmdQualifierShort'));
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_INNER_MAX = ', 'function pysimEventSendForm').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'eventFieldsForRelease'));
eval(extractFunc(html, 'encPlmn'));
eval(extractFunc(html, 'bytesToHex'));
test('CMD_NAMES decodes timer management and the BIP commands', () => {
assert.strictEqual(CMD_NAMES['01'], 'REFRESH');
assert.strictEqual(CMD_NAMES['10'], 'SET UP CALL');
assert.strictEqual(CMD_NAMES['27'], 'TIMER MANAGEMENT');
assert.strictEqual(CMD_NAMES['28'], 'SET UP IDLE MODE TEXT');
assert.strictEqual(CMD_NAMES['40'], 'OPEN CHANNEL');
assert.strictEqual(CMD_NAMES['41'], 'CLOSE CHANNEL');
assert.strictEqual(CMD_NAMES['42'], 'RECEIVE DATA');
assert.strictEqual(CMD_NAMES['43'], 'SEND DATA');
assert.strictEqual(CMD_NAMES['44'], 'GET CHANNEL STATUS');
});
test('cmdQualifierShort decodes TIMER MANAGEMENT actions', () => {
assert.strictEqual(cmdQualifierShort('27', 0x00), 'Start');
assert.strictEqual(cmdQualifierShort('27', 0x01), 'Deactivate');
assert.strictEqual(cmdQualifierShort('27', 0x02), 'Get');
});
test('cmdQualifierShort decodes OPEN CHANNEL qualifier flags', () => {
assert.strictEqual(cmdQualifierShort('40', 0x00), 'OnDemand');
assert.strictEqual(cmdQualifierShort('40', 0x01), 'Immediate');
assert.strictEqual(cmdQualifierShort('40', 0x03), 'Immediate+AutoReconn');
assert.strictEqual(cmdQualifierShort('40', 0x05), 'Background');
assert.strictEqual(cmdQualifierShort('40', 0x0C), 'Background+DNS');
});
test('cmdQualifierShort returns empty for unknown types', () => {
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
});
test('EVENT_NAMES follows the pinned CAT spec and names the 3GPP events', () => {
assert.strictEqual(EVENT_NAMES[0x0B], 'Access technology change (single access technology)');
assert.strictEqual(EVENT_NAMES[0x14], 'Access technology change (multiple access technologies)');
assert.strictEqual(EVENT_NAMES[0x19], 'Profile container');
assert.strictEqual(EVENT_NAMES[0x1C], 'Poll interval negotiation');
for (const [v, name] of [[0x11, '(I-)WLAN access status'], [0x12, 'Network rejection'],
[0x15, 'CSG cell selection'], [0x17, 'IMS registration'], [0x18, 'Incoming IMS data'],
[0x1D, 'Data connection status change'], [0x1E, 'CAG cell selection'],
[0x1F, 'Slices status change'], [0x20, 'Reserved (future usage)']]) {
assert.strictEqual(EVENT_NAMES[v], name, '0x' + v.toString(16));
}
// the "Reserved for 3GPP" spec cross-reference is never user-facing
for (const [v, name] of Object.entries(EVENT_NAMES)) {
assert.ok(!name.includes('Reserved for 3GPP'), v + ': ' + name);
}
});
test('the poll interval negotiation event builds a Duration TLV', () => {
const build = EVENT_FORMS[0x1C].build;
assert.strictEqual(build({ unit: '1', interval: '45' }), '0402012D');
assert.strictEqual(build({ unit: '0', interval: '2' }), '04020002');
assert.strictEqual(build({ unit: '2', interval: '5' }), '04020205');
assert.strictEqual(build({ unit: '1', interval: '' }), '0402011E'); // default 30 s
});
test('channel status event builds the B8 channel status TLV', () => {
const build = EVENT_FORMS[0x0A].build;
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
assert.strictEqual(build({ channel: '2', state: '128', info: '5' }), 'B8028205');
assert.strictEqual(build({ channel: '1', state: '0', info: '0' }), 'B8020100');
assert.strictEqual(build({ channel: '0', state: '64', info: '0' }), 'B8024000');
});
test('the data connection status change event builds the 7.5.25 object set', () => {
const build = EVENT_FORMS[0x1D].build;
assert.strictEqual(EVENT_FORMS[0x1D].note, undefined);
// the four mandatory objects; every conditional object omitted
assert.strictEqual(build({ status: '0', type: '0', ti: '00', loc_status: '0' }),
'9D0100AA01001C01009B0100');
// full form: cause, location information, access technology, APN, type
assert.strictEqual(
build({ status: '1', type: '2', cause: '26', ti: '85', mcc: '250', mnc: '01',
lac: '00FF', cell: '0001', tech: '8', loc_status: '1', apn: 'internet', pdp_type: '3' }),
'9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101C708696E7465726E65740B0103');
// an empty TI falls back to 00; the location defaults fill missing fields
assert.strictEqual(build({ status: '2', type: '1', ti: '', mcc: '250', loc_status: '2' }),
'9D0102AA01011C0100130752F010000000019B0102');
});
test('the access technology options follow the pinned 8.61 coding', () => {
const opts = EVENT_FORMS[0x0B].fields[0].opts;
assert.deepStrictEqual(opts.map(o => o.v), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
assert.strictEqual(opts[1].l, 'TIA/EIA-553-A');
assert.strictEqual(opts[2].l, 'TIA/EIA-136-270');
assert.strictEqual(opts[10].l, 'NG-RAN');
assert.strictEqual(opts[12].l, 'Satellite E-UTRAN');
});
test('eventDateTimeTlv builds the 8.39 date-time object', () => {
const d = o => ({
getFullYear: () => o.year, getMonth: () => o.month - 1, getDate: () => o.day,
getHours: () => o.hour, getMinutes: () => o.min, getSeconds: () => o.sec,
getTimezoneOffset: () => o.tzMin,
});
// UTC+3, 2026-09-30 21:55:00 -> swapped BCD + 12 quarters
assert.strictEqual(
eventDateTimeTlv(d({ year: 2026, month: 9, day: 30, hour: 21, min: 55, sec: 0, tzMin: -180 })),
'260762900312550021');
// UTC-2:30 -> 10 quarters with the sign bit (bit 3 of the first semi-octet)
assert.strictEqual(
eventDateTimeTlv(d({ year: 2026, month: 1, day: 5, hour: 8, min: 7, sec: 9, tzMin: 150 })),
'260762105080709009');
});
test('the data connection event can carry the current date-time', () => {
const build = EVENT_FORMS[0x1D].build;
assert.match(build({ status: '0', type: '0', ti: '00', loc_status: '0', datetime: 'now' }),
/^9D0100AA01001C01002607[0-9A-F]{14}9B0100$/);
// left out by default
assert.strictEqual(build({ status: '0', type: '0', ti: '00', loc_status: '0' }),
'9D0100AA01001C01009B0100');
});
test('encBcdDigits and eventAddressTlv build the EFADN number coding', () => {
assert.strictEqual(encBcdDigits('12345'), '2143F5');
assert.strictEqual(encBcdDigits('79001234567'), '9700214365F7');
// international number -> TON/NPI 0x91, length = 1 + 6 bytes
assert.strictEqual(eventAddressTlv('79001234567', '1'), '8607919700214365F7');
assert.strictEqual(eventAddressTlv('', '1'), '');
});
test('the call events build their 31.111 object sets', () => {
// MT call: source network, TI + Address + Subaddress + IMS URI + Media Type
assert.strictEqual(EVENT_FORMS[0x00].src, '83');
assert.strictEqual(EVENT_FORMS[0x00].build({ ti: '85', number: '79001234567', ton: '1',
sub: '12', ims_uri: 'sip:a@b', media: '1' }),
'1C01858607919700214365F7880112B1077369703A614062FE0101');
// call connected: source is the form's field, TI + optional Media Type
assert.strictEqual(EVENT_FORMS[0x01].srcField, 'src');
assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '0' }), '1C0100FE0100');
assert.strictEqual(EVENT_FORMS[0x01].build({ ti: '00', media: '' }), '1C0100');
// call disconnected: radio link timeout = zero-length cause, custom = bytes
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'rlt', media: '' }), '1C01009A00');
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: 'custom', cause: '10', media: '' }), '1C01009A0110');
// a URI longer than the 100-byte limit is cut and tagged (8.135)
const long = EVENT_FORMS[0x00].build({ ti: '00', ims_uri: 'x'.repeat(120) });
assert.ok(long.includes('B164' + '78'.repeat(100)), 'the URI is cut to 100 bytes');
assert.ok(long.endsWith('F300'), long.slice(-8));
assert.ok(!EVENT_FORMS[0x00].build({ ti: '00', ims_uri: 'sip:a@b' }).includes('F300'));
});
test('the browser, WLAN and CSG events build their object sets', () => {
assert.strictEqual(EVENT_FORMS[0x08].build({ cause: '1' }), 'B40101');
assert.strictEqual(EVENT_FORMS[0x11].build({ status: '2' }), 'CB0102');
// CSG: source network, access tech + status/mechanism + ID + name + PLMN
assert.strictEqual(EVENT_FORMS[0x15].src, '83');
assert.strictEqual(EVENT_FORMS[0x15].build({ status: '1', mech: 'manual', tech: '8',
csg: '00000001', hnb: 'MyCSG', mcc: '250', mnc: '01' }),
'BF0108D5020141D60400000001D7054D79435347890352F010');
});
test('every event type has a parameter form now', () => {
assert.ok(!html.includes("note: 'not_yet'"), 'no event may stay unimplemented');
});
test('the data/display/search/frames events build their object sets', () => {
// Data available: Channel status (B8) + Channel data length (B7)
assert.strictEqual(EVENT_FORMS[0x09].build({ channel: '2', state: '128', info: '0', len: 'FF' }),
'B8028200B701FF');
// Display parameters: rows/cols + sizing/fonts/effects bytes
assert.strictEqual(EVENT_FORMS[0x0C].build({ rows: '4', cols: '16', sizing: '1', fonts: '0', effects: '00' }),
'C003841000');
assert.strictEqual(EVENT_FORMS[0x0E].build({ mode: '1' }), 'E50101');
assert.strictEqual(EVENT_FORMS[0x0F].build({ status: '12' }), 'E40112');
assert.strictEqual(EVENT_FORMS[0x0F].build({ status: '' }), 'E40100');
assert.strictEqual(EVENT_FORMS[0x10].build({ frame: '01', list: '0410' }), 'E703010410');
// Card reader status (8.33) and Language (8.45) carry their object tags
assert.strictEqual(EVENT_FORMS[0x06].build({ status: '1' }), 'A00101');
assert.strictEqual(EVENT_FORMS[0x07].build({ lang: 'en' }), 'AD02656E');
});
test('the IMS, CAG and slices events build their object sets', () => {
// IMS registration: source network, IMPU list (80-tagged URIs) + status code
assert.strictEqual(EVENT_FORMS[0x17].src, '83');
assert.strictEqual(EVENT_FORMS[0x17].build({ impu: 'sip:a@b', code: '200' }),
'F70980077369703A614062F803323030');
assert.strictEqual(EVENT_FORMS[0x18].src, '83');
assert.strictEqual(EVENT_FORMS[0x18].build({ iari: 'a' }), 'F60161');
// CAG cell selection: tech + status/mechanism + information list
assert.strictEqual(EVENT_FORMS[0x1E].src, '83');
assert.strictEqual(EVENT_FORMS[0x1E].build({ status: '1', mech: 'manual', tech: '8', list: '00000001' }),
'BF0108D5020141D60400000001');
// Slices status change: tech + status + served S-NSSAIs (count + 4 bytes each)
assert.strictEqual(EVENT_FORMS[0x1F].src, '83');
assert.strictEqual(EVENT_FORMS[0x1F].build({ status: '1', tech: '8', served: '01020304' }),
'BF0108D50101D6050101020304');
// partial NSSAI: the F9 object + the automatic Last Envelope marker
assert.strictEqual(EVENT_FORMS[0x1F].build({ status: '1', partial: '0102' }), 'D50101F9020102F000');
assert.ok(!EVENT_FORMS[0x1F].build({ status: '1', served: '01020304' }).includes('F000'));
});
test('the location-status event can carry the location information', () => {
const build = EVENT_FORMS[0x03].build;
// Normal service: the 8.19 object follows the status
assert.strictEqual(build({ status: '0', mcc: '250', mnc: '01', lac: '00FF', cell: '0001' }),
'9B0100930752F01000FF0001');
// Limited / No service: no location information
assert.strictEqual(build({ status: '1', mcc: '250' }), '9B0101');
assert.strictEqual(build({ status: '0' }), '9B0100');
});
test('the channel-status event can carry the bearer and the local address', () => {
const build = EVENT_FORMS[0x0A].build;
assert.strictEqual(build({ channel: '1', state: '128', info: '0' }), 'B8028100');
assert.strictEqual(build({ channel: '1', state: '128', info: '0', bearer: '5', addr_type: '21', addr: '192.168.1.2' }),
'B8028100B50105BE0521C0A80102');
assert.strictEqual(build({ channel: '1', state: '128', info: '0', addr_type: '00' }), 'B8028100BE00');
});
test('the disconnected/rejection/CAG/slices events carry their extra objects', () => {
// IMS call disconnection cause: protocol + 2-byte cause (603 -> 025B)
assert.strictEqual(EVENT_FORMS[0x02].build({ ti: '00', cause_mode: '', media: '', ims_proto: '1', ims_cause: '603' }),
'1C0100D50301025B');
// Extended information (CAG ID, 4 bytes)
assert.ok(EVENT_FORMS[0x12].build({ reg_type: '9', access_tech: '8', cause: '2', ext_info_type: '1', ext_info: '00000001' })
.endsWith('F2050100000001'));
// CAG HRNN list (80-tagged names)
assert.ok(EVENT_FORMS[0x1E].build({ status: '1', list: '', hrnn: 'A,B' }).endsWith('D706800141800142'));
// allowed/served/rejected slice lists, with and without mapping
assert.ok(EVENT_FORMS[0x1F].build({ status: '1', served: '', allowed_map: '01020304', allowed: '01020304',
rejected_map: '01020304', rejected: '01020304' })
.endsWith('F70401020304F80401020304D70401020304B10401020304'));
});
test('event fields introduced after the effective release are hidden', () => {
// MT call: the IMS URI came with the Rel-12 URI support (12.4.0)
let r = eventFieldsForRelease(EVENT_FORMS[0x00], 11);
assert.deepStrictEqual(r.hidden.map(f => f.id), ['ims_uri']);
assert.ok(r.fields.some(f => f.id === 'ti'));
assert.strictEqual(eventFieldsForRelease(EVENT_FORMS[0x00], 12).hidden.length, 0);
// CSG cell selection: the PLMN ID came in 12.1.0
r = eventFieldsForRelease(EVENT_FORMS[0x15], 11);
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['mcc', 'mnc']);
// IMS registration: the IMPU list and the status code came in Rel-11
r = eventFieldsForRelease(EVENT_FORMS[0x17], 10);
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['code', 'impu']);
// slices: the mapping/rejected objects came in Rel-18
r = eventFieldsForRelease(EVENT_FORMS[0x1F], 16);
assert.deepStrictEqual(r.hidden.map(f => f.id).sort(), ['allowed_map', 'partial', 'rejected', 'rejected_map']);
assert.strictEqual(eventFieldsForRelease(EVENT_FORMS[0x1F], 18).hidden.length, 0);
// a field without a known release is never hidden
r = eventFieldsForRelease(EVENT_FORMS[0x12], 4);
assert.strictEqual(r.hidden.length, 0);
assert.strictEqual(r.fields.length, EVENT_FORMS[0x12].fields.length);
});
test('the single-envelope budget is enforced by the size helper', () => {
// inner = 8 header bytes + the event data; D6 81 <len> + inner must stay
// within the 1-byte APDU Lc, so the inner data is capped at 252 bytes
assert.strictEqual(EVENT_INNER_MAX, 252);
assert.strictEqual(eventEnvelopeSize(''), 8);
assert.strictEqual(eventEnvelopeSize('AABB'), 10);
assert.strictEqual(eventEnvelopeSize('AB'.repeat(244)), 252);
assert.ok(eventFitsOneEnvelope('AB'.repeat(244)));
assert.ok(!eventFitsOneEnvelope('AB'.repeat(245)));
assert.ok(eventFitsOneEnvelope(null));
});
test('the event form previews the bytes the send would deliver', () => {
// the preview builds from the visible fields - the same bytes the send
// uses, so a raw-envelope test step can be authored by copying it
const els = {
'ev-f-status': { value: '0' },
'ev-f-mcc': { value: '250' },
'ev-f-mnc': { value: '01' },
'ev-f-lac': { value: '00FF' },
'ev-f-cell': { value: '0001' },
'event-send-preview': { value: '' },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis._eventFormType = 0x03;
pysimEventPreview();
assert.strictEqual(els['event-send-preview'].value, '9B0100930752F01000FF0001');
// a hidden field is left out of the build
els['ev-f-mcc'].closest = () => ({ style: { display: 'none' } });
pysimEventPreview();
assert.strictEqual(els['event-send-preview'].value, '9B0100');
// an event without a form leaves the preview empty
globalThis._eventFormType = 0x99;
pysimEventPreview();
assert.strictEqual(els['event-send-preview'].value, '');
});
test('the shared event vectors match the PWA builders', () => {
// the same fixture is asserted against the Python builders by
// tests/test_events.py - a change on either side fails the other suite
// until the fixture and both implementations agree
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, 'event_vectors.json'), 'utf8'));
assert.ok(vectors.events.length && vectors.scts.length, 'the fixture must not be empty');
for (const v of vectors.events) {
const build = EVENT_FORMS[parseInt(v.event, 16)].build;
assert.strictEqual(build(v.fields).toUpperCase(), v.hex,
'event 0x' + v.event + ' ' + JSON.stringify(v.fields));
}
for (const v of vectors.scts) {
const d = {
getFullYear: () => v.year, getMonth: () => v.month - 1,
getDate: () => v.day, getHours: () => v.hour,
getMinutes: () => v.min, getSeconds: () => v.sec,
getTimezoneOffset: () => -v.tz_east_min,
};
assert.strictEqual(eventDateTimeTlv(d).toUpperCase(), '2607' + v.hex,
'scts ' + JSON.stringify(v));
}
});