From 9fb46b786fb8c2599cd252bc48276cedfed8c438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 7 Oct 2026 21:56:20 +0300 Subject: [PATCH] v3.23.6: the event name keywords in the test-script editor A script's `event` value may be a code (a hex byte or a number) or one of the server's name keywords (location_status, access_tech, network_rejection, data_connection, ... - events.py's EVENT_NAMES); the API accepts both. The PWA parsed every value as a hex string: the step list showed 'EVENT 0xaccess_tech' (the unparsed fallback), the editor fell back to event 03 (Location status) with the location field set, and saving then rewrote the step to that event - silently corrupting name-authored scripts (the two Alfa2G trigger scripts use access_tech/data_connection). A mirrored EVENT_ALIASES map plus an eventCode() resolver (number / hex byte / name -> code) now feed the step summary, the editor's event block and the event check; the summary's fallback no longer prefixes a non-numeric value with 0x, and the editor keeps its canonical hex form on save (the same code). Drift guard: the aliases live in frontend/tests/event_vectors.json, asserted by the Node test (the PWA's map + resolver) and the Python test (events.EVENT_NAMES + resolve_event). Tests: 856 frontend + 839 Python; sw cache simple-v419. --- docs/test-scripts.md | 4 ++++ frontend/index.html | 38 +++++++++++++++++++++++------- frontend/sw.js | 2 +- frontend/tests/event_forms.test.js | 28 ++++++++++++++++++++++ frontend/tests/event_vectors.json | 14 +++++++++++ frontend/tests/testscripts.test.js | 21 +++++++++++++++++ pyproject.toml | 2 +- pysim_simple_server/server.py | 2 +- tests/test_events.py | 10 ++++++++ 9 files changed, 110 insertions(+), 11 deletions(-) diff --git a/docs/test-scripts.md b/docs/test-scripts.md index ad45943..49d6383 100644 --- a/docs/test-scripts.md +++ b/docs/test-scripts.md @@ -321,6 +321,10 @@ from the form's source field; an explicit `src` overrides it. Both the "lac": "00FF", "cell": "0001"}}} ``` +The `event` value may be the code (a hex byte or a number) or one of the name +keywords above; the editor resolves either form to the event - showing the +right form and fields - and saves the hex byte form. + | `event` | fields | |---|---| | `location_status` / `0x03` | `status` 0 normal / 1 limited / 2 no service; for normal service `mcc`, `mnc`, `lac`, `cell` | diff --git a/frontend/index.html b/frontend/index.html index df5a1d9..cdfcbcf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1905,7 +1905,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.23.5'; +const SIMPLE_VERSION = '3.23.6'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -7372,9 +7372,11 @@ function testStepSummary(step) { : 'item=' + p.item_id); } else if (step.kind === 'event') { - // a legacy numeric event is the code itself; the editor stores hex - const code = (typeof p.event === 'number') ? p.event : parseInt(p.event, 16); - s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?'))); + // the value may be a number, the editor's hex byte or a name keyword + const code = eventCode(p.event); + s = 'EVENT ' + (EVENT_NAMES[code] || (Number.isInteger(code) + ? '0x' + code.toString(16).toUpperCase().padStart(2, '0') + : String(p.event || '?'))); if (p.src) s += ' src=' + p.src; } else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?'); @@ -8156,7 +8158,7 @@ function testStepRender() { const codes = Object.keys(EVENT_FORMS).map(k => parseInt(k, 10)) .sort((a, b) => a - b); const rawEvent = p.event === undefined ? '03' : p.event; - let cur = (typeof rawEvent === 'number') ? rawEvent : parseInt(String(rawEvent), 16); + let cur = eventCode(rawEvent); if (!(Number.isInteger(cur) && cur >= 0 && cur <= 0xFF)) cur = 3; const opts = codes.map(c => ({ v: c.toString(16).toUpperCase().padStart(2, '0'), @@ -8640,9 +8642,7 @@ function testStepFormError() { if (problem) return problem; } if (step.kind === 'event') { - const rawEvent = (step.params || {}).event; - const code = (typeof rawEvent === 'number') ? rawEvent - : parseInt(String(rawEvent || ''), 16); + const code = eventCode((step.params || {}).event); const cfg = EVENT_FORMS[code]; if (!cfg) return t('No form for this event - use the raw Envelope action.'); const bad = eventFormPatternError(cfg, (step.params || {}).fields || {}); @@ -15222,6 +15222,28 @@ function pysimFsResetEdit() { // points at 3GPP for carry their TS 31.111 7.5 event name; "Reserved for // 3GPP" is a spec cross-reference, not a usage restriction, so it never // appears in the user-facing names. + +// The test-script `event` value may be the editor's hex byte, a number or one +// of the server's name keywords (pysim_simple_server/events.py's +// EVENT_NAMES); this map mirrors it, drift-guarded through +// frontend/tests/event_vectors.json ("aliases"). +const EVENT_ALIASES = { + 'location status': 0x03, location_status: 0x03, 'location-status': 0x03, + 'access technology change': 0x0B, access_tech: 0x0B, 'access-technology-change': 0x0B, + 'network rejection': 0x12, network_rejection: 0x12, 'network-rejection': 0x12, + 'data connection status change': 0x1D, data_connection: 0x1D, 'data-connection-status-change': 0x1D, +}; + +// The event code of a script's `event` value (a number, a hex byte or a name +// keyword), or NaN when it is none of them. +function eventCode(value) { + if (typeof value === 'number') return value; + const s = String(value === undefined || value === null ? '' : value).trim(); + if (/^(0x)?[0-9a-fA-F]{1,2}$/.test(s)) return parseInt(s.replace(/^0x/i, ''), 16); + const alias = EVENT_ALIASES[s.toLowerCase()]; + return alias === undefined ? NaN : alias; +} + const EVENT_NAMES = { 0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected', 0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available', diff --git a/frontend/sw.js b/frontend/sw.js index 337d6f3..851169d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v418'; +const CACHE = 'simple-v419'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/event_forms.test.js b/frontend/tests/event_forms.test.js index 2b56e4a..9129d5c 100644 --- a/frontend/tests/event_forms.test.js +++ b/frontend/tests/event_forms.test.js @@ -31,6 +31,8 @@ function extractFunc(src, name) { // 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_ALIASES = {', 'function eventCode').replace(/^const /gm, 'var ')); +eval(extractFunc(html, 'eventCode')); 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 ')); @@ -324,12 +326,38 @@ test('the event form previews the bytes the send would deliver', () => { assert.strictEqual(els['event-send-preview'].value, ''); }); +test('a name-keyword event points at the right form', () => { + // the server's name keywords (used by the applet test scripts) resolve to + // their code, so the editor shows the right event and prefills its fields + // instead of falling back to event 03 + const cfg = EVENT_FORMS[eventCode('access_tech')]; + assert.ok(cfg, 'the access-tech form'); + assert.deepStrictEqual(cfg.fields.map(f => f.id), ['tech']); + assert.strictEqual(cfg.build({ tech: 8 }), 'BF0108'); + const dc = EVENT_FORMS[eventCode('data_connection')]; + assert.ok(dc, 'the data-connection form'); + assert.ok(dc.fields.some(f => f.id === 'status') && dc.fields.some(f => f.id === 'type')); +}); + 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'); + // the event name aliases (the server's keywords) resolve to the codes + assert.ok(vectors.aliases, 'the fixture must carry the aliases'); + const wantAliases = {}; + for (const [name, hex] of Object.entries(vectors.aliases)) { + wantAliases[name] = parseInt(hex, 16); + assert.strictEqual(eventCode(name), parseInt(hex, 16), name); + } + assert.deepStrictEqual(EVENT_ALIASES, wantAliases); + assert.deepStrictEqual(eventCode(0x0B), 0x0B); + assert.deepStrictEqual(eventCode('0B'), 0x0B); + assert.deepStrictEqual(eventCode('0x1d'), 0x1D); + assert.ok(Number.isNaN(eventCode('nonsense'))); + assert.ok(Number.isNaN(eventCode(''))); for (const v of vectors.events) { const build = EVENT_FORMS[parseInt(v.event, 16)].build; assert.strictEqual(build(v.fields).toUpperCase(), v.hex, diff --git a/frontend/tests/event_vectors.json b/frontend/tests/event_vectors.json index b522798..1df1317 100644 --- a/frontend/tests/event_vectors.json +++ b/frontend/tests/event_vectors.json @@ -18,6 +18,20 @@ {"event": "1D", "fields": {"status": "2", "type": "1", "ti": "", "mcc": "250", "loc_status": "2"}, "hex": "9D0102AA01011C0100130752F010000000019B0102"}, {"event": "1D", "fields": {"status": "0", "type": "2", "ti": "AA", "mcc": "262", "mnc": "01", "lac": "0001", "cell": "0002", "tech": "7", "loc_status": "1", "apn": "web.operator.example", "pdp_type": "4"}, "hex": "9D0100AA01021C01AA130762F21000010002BF01079B0101C7147765622E6F70657261746F722E6578616D706C650B0104"} ], + "aliases": { + "location status": "03", + "location_status": "03", + "location-status": "03", + "access technology change": "0B", + "access_tech": "0B", + "access-technology-change": "0B", + "network rejection": "12", + "network_rejection": "12", + "network-rejection": "12", + "data connection status change": "1D", + "data_connection": "1D", + "data-connection-status-change": "1D" + }, "scts": [ {"year": 2026, "month": 9, "day": 30, "hour": 21, "min": 55, "sec": 0, "tz_east_min": 180, "hex": "62900312550021"}, {"year": 2026, "month": 1, "day": 5, "hour": 8, "min": 7, "sec": 9, "tz_east_min": -150, "hex": "62105080709009"} diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js index 3bbbd89..1ab1aad 100644 --- a/frontend/tests/testscripts.test.js +++ b/frontend/tests/testscripts.test.js @@ -31,6 +31,9 @@ function extractFunc(src, name, asyncFn) { eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var ')); eval(extractBlock('const EVENT_NAMES = {', 'const EVENT_FORMS = {').replace(/^const /gm, 'var ')); eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var ')); +// the event name aliases (the server's EVENT_NAMES keywords) + the resolver +eval(extractBlock('const EVENT_ALIASES = {', 'function eventCode').replace(/^const /gm, 'var ')); +eval(extractFunc(html, 'eventCode')); // the builders' pure helpers (eventFormFieldsHtml/Values are the shared form) eval(extractFunc(html, 'bytesToHex')); eval(extractFunc(html, 'eventAddressTlv')); @@ -195,6 +198,24 @@ test('testStepSummary renders actions', () => { assert.strictEqual( testStepSummary({ type: 'action', kind: 'status', params: { attempts: 5 } }), 'STATUS x5 (poll)'); + // the event action's value may be a name keyword (the server's alias + // form), the editor's hex byte or a number - all show the event name + for (const ev of ['access_tech', '0B', '0b', 11, 0x0B]) { + assert.strictEqual( + testStepSummary({ type: 'action', kind: 'event', params: { event: ev } }), + 'EVENT Access technology change (single access technology)', String(ev)); + } + assert.strictEqual( + testStepSummary({ type: 'action', kind: 'event', params: { event: 'data_connection' }, + check: { sw: { mode: 'mask', value: '91??' } } }), + 'EVENT Data connection status change ยท SW ~91??'); + // a value that is neither a code nor a known name is shown as-is + assert.strictEqual( + testStepSummary({ type: 'action', kind: 'event', params: { event: 'nonsense' } }), + 'EVENT nonsense'); + assert.strictEqual( + testStepSummary({ type: 'action', kind: 'event', params: { event: '1F' } }), + 'EVENT Slices status change'); assert.strictEqual( testStepSummary({ type: 'action', kind: 'proactive-drain', params: { attempts: 5, respond: { result: 'ok' }, diff --git a/pyproject.toml b/pyproject.toml index 53c4880..5c6741c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.23.5" +version = "3.23.6" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index f998563..0e1ce29 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.23.5' +VERSION = '3.23.6' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE diff --git a/tests/test_events.py b/tests/test_events.py index 1676b3e..85f9930 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -41,6 +41,16 @@ class EventVectorTests(unittest.TestCase): self.assertEqual(events.scts_bytes(stamp, v['tz_east_min']).hex().upper(), v['hex']) + def test_the_shared_event_name_aliases(self): + # the script's `event` value may be one of these name keywords; the + # PWA mirrors the map (frontend/tests/event_forms.test.js) + want = {name: int(hexv, 16) for name, hexv in VECTORS['aliases'].items()} + self.assertEqual(events.EVENT_NAMES, want) + for name, code in want.items(): + self.assertEqual(events.resolve_event(name), code, name) + self.assertEqual(events.resolve_event(hex(code)[2:].upper().zfill(2)), code) + self.assertIsNone(events.resolve_event('nonsense')) + def test_the_host_clock_date_time_object(self): # the data-connection builder embeds the 8.39 object 26 07 ... out = events.build(0x1D, {'status': '0', 'type': '0', 'ti': '00',