v3.18.0: the semantic event action for test scripts

The other-applet batch: a test script can send the common event downloads
semantically instead of raw hex.

- events.py: server-side mirrors of the PWA's event forms for Location status
  (0x03), Access technology change (0x0B), Network rejection (0x12 - the
  LA/RA/TA object selected by the registration group, the extended EMM cause
  and the extended information object) and Data connection status change
  (0x1D - status/type/TI plus the optional cause, host-clock 8.39 date-time,
  location information, access technology, APN and PDP/PDN/PDU type).  Fields
  are validated before the run (a bad value is a script error, not a step
  failure); `src` overrides the device-identities source (default terminal,
  matching the Phone forms).
- The engine gains the `event` action kind; the runner builds and sends it.
  The PWA's action-kind list stays in sync (drift-guarded - the guard itself
  caught its own regex bug on the first run).
- Byte parity: the vectors are generated from the PWA's own builders and
  pinned in tests/test_events.py; the frontend suites assert the same values.
  PLMN and SCTS helpers are mirrored too.
- The Phone tab's event form shows the built "Event data (hex)" (updated as
  the fields change), so every event without a builder can be used through
  the raw `envelope` action.
- The test-step editor offers the event select + a JSON fields textarea;
  malformed JSON refuses the save with a clear message.
- docs/test-scripts.md (the semantic-event table, an event example, the
  format stamp), docs/api.md and help EN/RU updated.

Tests: 812 frontend + 717 Python; sw cache simple-v381.
This commit is contained in:
2026-10-04 18:00:53 +03:00
parent 0b9e9e4c5e
commit fb4e4bd7b2
15 changed files with 715 additions and 19 deletions
+25
View File
@@ -296,3 +296,28 @@ test('the single-envelope budget is enforced by the size helper', () => {
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, '');
});
+48
View File
@@ -29,6 +29,7 @@ function extractFunc(src, name, asyncFn) {
}
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'testTemplate'));
eval(extractFunc(html, 'testMenuSelectProblem'));
@@ -44,6 +45,7 @@ eval(extractFunc(html, 'testFormSelect'));
eval(extractFunc(html, 'testStepRender'));
eval(extractFunc(html, 'testRenderChecks'));
eval(extractFunc(html, 'testStepCollect'));
eval(extractFunc(html, 'testStepFormError'));
eval(extractFunc(html, 'testWriteBackCounter'));
eval(extractFunc(html, 'testRunPreset'));
eval(extractFunc(html, 'testScriptsWriteback', true));
@@ -93,6 +95,12 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
params: {} }] }), /item id or text/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: {} }] }), /event is required/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '03', fields: [] } }] }), /object/);
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '03', fields: { status: 0 } } }] }), '');
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
@@ -201,6 +209,27 @@ test('the menu-select editor collects the item id or the text', () => {
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
});
test('the event step collects the type and the JSON fields', () => {
_testEditStep = { type: 'action', kind: 'event', params: {} };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '12',
'test-f-evfields': '{"reg_type":"9","cause":"2"}',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params,
{ event: '12', fields: { reg_type: '9', cause: '2' } });
assert.strictEqual(_testEditStep._eventFieldsError, undefined);
// invalid JSON marks the form and the save refuses with a clear message
_testEditStep = { type: 'action', kind: 'event', params: {} };
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
assert.match(testStepFormError(), /JSON object/);
// the summary names the event
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '03', fields: {} } }), 'EVENT Location status');
});
test('the STATUS step leaves the SW check to the server default', () => {
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
@@ -227,6 +256,25 @@ test('the step form renders the chosen source and no pre-filled SW', () => {
assert.match(body, /id="test-f-sw" value=""/);
});
test('the event step form renders the event select and the JSON fields', () => {
const els = {
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
'test-step-title': {}, 'test-step-body': {},
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
};
globalThis.document = { getElementById: id => els[id] || null };
_testEditStep = { type: 'action', kind: 'event',
params: { event: '12', fields: { reg_type: '9' } } };
_testEditChecks = [];
_testEditStepIndex = 0;
testStepRender();
const body = els['test-step-body'].innerHTML;
assert.match(body, /id="test-f-evtype"/);
assert.match(body, /<option value="12" selected>/);
assert.match(body, /id="test-f-evfields"/);
assert.ok(body.includes('{"reg_type":"9"}'), body);
});
test('hex fields strip mask wildcards, check values keep them', () => {
_testEditStep = { type: 'action', kind: 'apdu', params: {} };
fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2??',