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.
This commit is contained in:
+35
-5
@@ -1877,7 +1877,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.18.1';
|
const SIMPLE_VERSION = '3.18.2';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -6878,6 +6878,10 @@ let _testCurrentIdx = -1;
|
|||||||
let _testRunState = null;
|
let _testRunState = null;
|
||||||
let _testRunTimer = null;
|
let _testRunTimer = null;
|
||||||
let _testSaveTimer = null;
|
let _testSaveTimer = null;
|
||||||
|
// One write chain per script object: saves of the same script run in order, so
|
||||||
|
// two quick saves of an unsaved draft cannot both create it (the first assigns
|
||||||
|
// the id before the second starts).
|
||||||
|
let _testWriteChains = new WeakMap();
|
||||||
let _testEditStep = null;
|
let _testEditStep = null;
|
||||||
let _testEditChecks = [];
|
let _testEditChecks = [];
|
||||||
let _testLastPresetIdx = -1;
|
let _testLastPresetIdx = -1;
|
||||||
@@ -6937,11 +6941,23 @@ function testScriptsSave(immediate) {
|
|||||||
const cur = testCurrent();
|
const cur = testCurrent();
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
|
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
|
||||||
if (immediate) { testScriptsWriteback(cur); return; }
|
if (immediate) { testScriptsWritebackQueued(cur); return; }
|
||||||
_testSaveTimer = setTimeout(() => { _testSaveTimer = null; testScriptsWriteback(cur); }, 600);
|
_testSaveTimer = setTimeout(() => {
|
||||||
|
_testSaveTimer = null;
|
||||||
|
testScriptsWritebackQueued(cur);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the writebacks of one script: the queued save reads `script.id`
|
||||||
|
// only when it runs, so a create's id is adopted before the next update.
|
||||||
|
function testScriptsWritebackQueued(script) {
|
||||||
|
const prev = _testWriteChains.get(script) || Promise.resolve();
|
||||||
|
const next = prev.then(() => testScriptsWriteback(script)).catch(() => {});
|
||||||
|
_testWriteChains.set(script, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testScriptsWriteback(script) {
|
async function testScriptsWriteback(script) {
|
||||||
|
if (script._deleted) return; // queued before the delete: never resurrect
|
||||||
const problem = testScriptProblem(script);
|
const problem = testScriptProblem(script);
|
||||||
if (problem) return; // an incomplete draft stays local until it is valid
|
if (problem) return; // an incomplete draft stays local until it is valid
|
||||||
const payload = {name: script.name || '', steps: script.steps};
|
const payload = {name: script.name || '', steps: script.steps};
|
||||||
@@ -6952,7 +6968,13 @@ async function testScriptsWriteback(script) {
|
|||||||
} else {
|
} else {
|
||||||
const resp = await pysimFetch('/api/test/scripts', payload);
|
const resp = await pysimFetch('/api/test/scripts', payload);
|
||||||
if (resp.error) throw new Error(resp.error);
|
if (resp.error) throw new Error(resp.error);
|
||||||
if (resp.script && resp.script.id) script.id = resp.script.id;
|
if (resp.script && resp.script.id) {
|
||||||
|
script.id = resp.script.id;
|
||||||
|
if (script._deleted) {
|
||||||
|
// deleted while the create was in flight: undo it
|
||||||
|
try { await pysimFetch('/api/test/scripts/delete', {id: script.id}); } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ioStatus('test-io-status', t('Could not save the test script') + ': ' + e.message);
|
ioStatus('test-io-status', t('Could not save the test script') + ': ' + e.message);
|
||||||
@@ -7150,6 +7172,8 @@ function testClone() {
|
|||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
const copy = JSON.parse(JSON.stringify(cur));
|
const copy = JSON.parse(JSON.stringify(cur));
|
||||||
delete copy.id;
|
delete copy.id;
|
||||||
|
delete copy._deleted;
|
||||||
|
delete copy._eventFieldsError;
|
||||||
copy.name = (copy.name || t('Script')) + ' (2)';
|
copy.name = (copy.name || t('Script')) + ' (2)';
|
||||||
_testScripts.push(copy);
|
_testScripts.push(copy);
|
||||||
_testCurrentIdx = _testScripts.length - 1;
|
_testCurrentIdx = _testScripts.length - 1;
|
||||||
@@ -7161,11 +7185,16 @@ async function testDelete() {
|
|||||||
const cur = testCurrent();
|
const cur = testCurrent();
|
||||||
if (!cur) return;
|
if (!cur) return;
|
||||||
if (!confirm(t('Delete this test script?'))) return;
|
if (!confirm(t('Delete this test script?'))) return;
|
||||||
|
// cancel a pending debounced write and mark the script: a queued or
|
||||||
|
// in-flight save must not resurrect it
|
||||||
|
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
|
||||||
|
cur._deleted = true;
|
||||||
if (cur.id) {
|
if (cur.id) {
|
||||||
try {
|
try {
|
||||||
const resp = await pysimFetch('/api/test/scripts/delete', {id: cur.id});
|
const resp = await pysimFetch('/api/test/scripts/delete', {id: cur.id});
|
||||||
if (resp.error) throw new Error(resp.error);
|
if (resp.error) throw new Error(resp.error);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
cur._deleted = false; // the server still has it: keep it deletable
|
||||||
ioStatus('test-io-status', t('Could not delete the test script') + ': ' + e.message);
|
ioStatus('test-io-status', t('Could not delete the test script') + ': ' + e.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7558,6 +7587,8 @@ function testStepCollect() {
|
|||||||
p.event = val('test-f-evtype') || '03';
|
p.event = val('test-f-evtype') || '03';
|
||||||
const raw = val('test-f-evfields');
|
const raw = val('test-f-evfields');
|
||||||
p.fields = {};
|
p.fields = {};
|
||||||
|
// the flag reflects THIS collect, so fixing the JSON saves again
|
||||||
|
delete step._eventFieldsError;
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(raw);
|
const obj = JSON.parse(raw);
|
||||||
@@ -7567,7 +7598,6 @@ function testStepCollect() {
|
|||||||
step._eventFieldsError = true;
|
step._eventFieldsError = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!step._eventFieldsError && !raw) delete step._eventFieldsError;
|
|
||||||
}
|
}
|
||||||
else if (kind === 'file-write' || kind === 'file-read') {
|
else if (kind === 'file-write' || kind === 'file-read') {
|
||||||
p.path = val('test-f-path');
|
p.path = val('test-f-path');
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v382';
|
const CACHE = 'simple-v383';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -321,3 +321,26 @@ test('the event form previews the bytes the send would deliver', () => {
|
|||||||
pysimEventPreview();
|
pysimEventPreview();
|
||||||
assert.strictEqual(els['event-send-preview'].value, '');
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"_note": "Shared event-data vectors for the semantic test-script `event` action. Generated from the PWA's EVENT_FORMS builders (frontend/index.html) and asserted by BOTH suites - frontend/tests/event_forms.test.js (against the JS builders) and tests/test_events.py (against pysim_simple_server/events.py) - so the two implementations cannot drift apart. A builder change must update this file, which then fails the other suite until it matches.",
|
||||||
|
"events": [
|
||||||
|
{"event": "03", "fields": {"status": "0", "mcc": "250", "mnc": "01", "lac": "00FF", "cell": "0001"}, "hex": "9B0100930752F01000FF0001"},
|
||||||
|
{"event": "03", "fields": {"status": "1", "mcc": "250"}, "hex": "9B0101"},
|
||||||
|
{"event": "03", "fields": {"status": "0"}, "hex": "9B0100"},
|
||||||
|
{"event": "03", "fields": {"status": "0", "mcc": "310", "mnc": "410", "lac": "ABCD", "cell": "1234"}, "hex": "9B01009307130014ABCD1234"},
|
||||||
|
{"event": "0B", "fields": {"tech": "0"}, "hex": "BF0100"},
|
||||||
|
{"event": "0B", "fields": {"tech": "8"}, "hex": "BF0108"},
|
||||||
|
{"event": "0B", "fields": {"tech": "12"}, "hex": "BF010C"},
|
||||||
|
{"event": "12", "fields": {"reg_type": "0"}, "hex": "F40100BF0100F50102930552F0100000"},
|
||||||
|
{"event": "12", "fields": {"reg_type": "3", "mcc": "262", "mnc": "02", "lac": "00FF", "rac": "05", "access_tech": "3", "cause": "15"}, "hex": "F40103BF0103F5010FF30662F22000FF05"},
|
||||||
|
{"event": "12", "fields": {"reg_type": "9", "mcc": "310", "mnc": "260", "tac": "A1B2C3", "access_tech": "8", "cause": "7", "ext_cause": "22"}, "hex": "F40109BF0108F501077D06130062A1B2C3570116"},
|
||||||
|
{"event": "12", "fields": {"reg_type": "17", "tac": "0001", "access_tech": "10", "cause": "2"}, "hex": "F40111BF010AF501027D0552F0100001"},
|
||||||
|
{"event": "12", "fields": {"reg_type": "9", "access_tech": "8", "cause": "2", "ext_info_type": "1", "ext_info": "00000001"}, "hex": "F40109BF0108F501027D0552F0100001F2050100000001"},
|
||||||
|
{"event": "1D", "fields": {"status": "0", "type": "0", "ti": "00", "loc_status": "0"}, "hex": "9D0100AA01001C01009B0100"},
|
||||||
|
{"event": "1D", "fields": {"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"}, "hex": "9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101C708696E7465726E65740B0103"},
|
||||||
|
{"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"}
|
||||||
|
],
|
||||||
|
"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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -49,12 +49,14 @@ eval(extractFunc(html, 'testStepFormError'));
|
|||||||
eval(extractFunc(html, 'testWriteBackCounter'));
|
eval(extractFunc(html, 'testWriteBackCounter'));
|
||||||
eval(extractFunc(html, 'testRunPreset'));
|
eval(extractFunc(html, 'testRunPreset'));
|
||||||
eval(extractFunc(html, 'testScriptsWriteback', true));
|
eval(extractFunc(html, 'testScriptsWriteback', true));
|
||||||
|
eval(extractFunc(html, 'testScriptsWritebackQueued'));
|
||||||
eval(extractFunc(html, 'testScriptsSave'));
|
eval(extractFunc(html, 'testScriptsSave'));
|
||||||
eval(extractFunc(html, 'testRunStart', true));
|
eval(extractFunc(html, 'testRunStart', true));
|
||||||
|
eval(extractFunc(html, 'testDelete', true));
|
||||||
eval(extractFunc(html, 'testChecksCollect'));
|
eval(extractFunc(html, 'testChecksCollect'));
|
||||||
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
||||||
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'
|
+ ' var _testScripts = null; var _testCurrentIdx = -1; var _testRunState = null;'
|
||||||
+ ' var _testSaveTimer = null; var _testLastPresetIdx = -1;');
|
+ ' var _testSaveTimer = null; var _testWriteChains = new WeakMap(); var _testLastPresetIdx = -1;');
|
||||||
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
|
||||||
|
|
||||||
function fakeForm(values) {
|
function fakeForm(values) {
|
||||||
@@ -235,11 +237,52 @@ test('the event step collects the type and the JSON fields', () => {
|
|||||||
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
|
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
|
||||||
'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||||
assert.match(testStepFormError(), /JSON object/);
|
assert.match(testStepFormError(), /JSON object/);
|
||||||
|
// fixing the JSON saves again - the error flag is not sticky
|
||||||
|
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
|
||||||
|
'test-f-evfields': '{"status":0}', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||||
|
assert.strictEqual(testStepFormError(), '');
|
||||||
// the summary names the event
|
// the summary names the event
|
||||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||||
params: { event: '03', fields: {} } }), 'EVENT Location status');
|
params: { event: '03', fields: {} } }), 'EVENT Location status');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('two quick saves of a draft create one script, then update it', async () => {
|
||||||
|
// the per-script write chain serializes saves: without it both would see
|
||||||
|
// the draft's missing id and create two server-side scripts
|
||||||
|
const calls = [];
|
||||||
|
globalThis.pysimFetch = async (path) => {
|
||||||
|
calls.push(path);
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
return { ok: true, script: { id: 'd'.repeat(32) } };
|
||||||
|
};
|
||||||
|
globalThis.ioStatus = () => {};
|
||||||
|
_testScripts = [{ name: 'one', steps: [{ type: 'action', kind: 'status', params: { attempts: 1 } }] }];
|
||||||
|
_testCurrentIdx = 0;
|
||||||
|
globalThis.testCurrent = () => _testScripts[_testCurrentIdx];
|
||||||
|
testScriptsSave(true);
|
||||||
|
testScriptsSave(true);
|
||||||
|
await new Promise(r => setTimeout(r, 40));
|
||||||
|
assert.deepStrictEqual(calls, ['/api/test/scripts', '/api/test/scripts/update']);
|
||||||
|
assert.strictEqual(_testScripts[0].id, 'd'.repeat(32));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a script cancels a pending debounced save', async () => {
|
||||||
|
const calls = [];
|
||||||
|
globalThis.pysimFetch = async (path) => { calls.push(path); return {}; };
|
||||||
|
globalThis.ioStatus = () => {};
|
||||||
|
globalThis.confirm = () => true;
|
||||||
|
globalThis.testRender = () => {};
|
||||||
|
_testScripts = [{ name: 'draft', steps: [{ type: 'action', kind: 'status', params: { attempts: 1 } }] }];
|
||||||
|
_testCurrentIdx = 0;
|
||||||
|
globalThis.testCurrent = () => _testScripts[_testCurrentIdx];
|
||||||
|
testScriptsSave(); // schedules the 600 ms write
|
||||||
|
await testDelete(); // a draft: local removal only
|
||||||
|
await new Promise(r => setTimeout(r, 700));
|
||||||
|
assert.deepStrictEqual(calls, []); // the queued save never fired
|
||||||
|
assert.strictEqual(_testScripts.length, 1);
|
||||||
|
assert.strictEqual(_testScripts[0].name, 'New test script');
|
||||||
|
});
|
||||||
|
|
||||||
test('the STATUS step leaves the SW check to the server default', () => {
|
test('the STATUS step leaves the SW check to the server default', () => {
|
||||||
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||||
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.18.1"
|
version = "3.18.2"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.18.1'
|
VERSION = '3.18.2'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ Test scripts used to live in the browser's localStorage, although the runner
|
|||||||
that executes them is server-side - the store makes the bench state one unit
|
that executes them is server-side - the store makes the bench state one unit
|
||||||
(next to the card presets), lets every browser (or a plain API client) manage
|
(next to the card presets), lets every browser (or a plain API client) manage
|
||||||
the same set, and validates a script before it is saved, so what is stored is
|
the same set, and validates a script before it is saved, so what is stored is
|
||||||
what the runner accepts.
|
what the runner accepts. A load keeps an entry that no longer validates - a
|
||||||
|
tightened rule (or a hand edit) must never destroy a stored script; it is
|
||||||
|
served, fails at run start with the validator's message, and can be fixed in
|
||||||
|
the editor, while the next save rewrites the file with it intact.
|
||||||
|
|
||||||
A stored script is the script JSON (``name`` + ``steps``, the same shape the
|
A stored script is the script JSON (``name`` + ``steps``, the same shape the
|
||||||
PWA exports) plus a stable uuid and timestamps::
|
PWA exports) plus a stable uuid and timestamps::
|
||||||
@@ -97,9 +100,23 @@ class TestScriptStore:
|
|||||||
loaded = []
|
loaded = []
|
||||||
for entry in items:
|
for entry in items:
|
||||||
try:
|
try:
|
||||||
loaded.append(self._normalise(entry, keep_meta=True))
|
s = self._normalise(entry, keep_meta=True, validate=False)
|
||||||
except TestScriptError as e:
|
except TestScriptError as e:
|
||||||
sys.stderr.write('TESTSCRIPTS: dropping an invalid entry: %s\n' % e)
|
sys.stderr.write('TESTSCRIPTS: dropping an unreadable entry: %s\n' % e)
|
||||||
|
continue
|
||||||
|
if self._validator is not None:
|
||||||
|
try:
|
||||||
|
self._validator(copy.deepcopy({'name': s['name'],
|
||||||
|
'steps': s['steps']}))
|
||||||
|
except Exception as e:
|
||||||
|
# Keep it: a tightened rule (or a hand edit) must never
|
||||||
|
# destroy a stored script - it is served, fails at run start
|
||||||
|
# with the validator's message, and can be fixed in the
|
||||||
|
# editor. A later save rewrites the file WITH this entry.
|
||||||
|
sys.stderr.write('TESTSCRIPTS: keeping %r - it does not '
|
||||||
|
'validate now: %s\n'
|
||||||
|
% (s['name'] or '(unnamed)', e))
|
||||||
|
loaded.append(s)
|
||||||
self._scripts = loaded
|
self._scripts = loaded
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
@@ -132,13 +149,14 @@ class TestScriptStore:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# ------------------------------------------------------------- shaping
|
# ------------------------------------------------------------- shaping
|
||||||
def _check(self, raw):
|
def _check(self, raw, validate=True):
|
||||||
"""The script dict, validated: the basic shape always, the full engine
|
"""The script dict, validated: the basic shape always, the full engine
|
||||||
pass when a validator was provided. The *input* shape is kept - the
|
pass when a validator was provided (and ``validate`` is set - a load
|
||||||
engine's normalised output is not meant to be re-normalised (it fills
|
keeps an entry that no longer validates, the mutation paths refuse it).
|
||||||
defaults like an 'any' PoR check), and the runner normalises again at
|
The *input* shape is kept - the engine's normalised output is not meant
|
||||||
start, so storing what was submitted keeps the store re-validatable and
|
to be re-normalised (it fills defaults like an 'any' PoR check), and the
|
||||||
hand-editable."""
|
runner normalises again at start, so storing what was submitted keeps
|
||||||
|
the store re-validatable and hand-editable."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise TestScriptError('script must be an object')
|
raise TestScriptError('script must be an object')
|
||||||
name = raw.get('name')
|
name = raw.get('name')
|
||||||
@@ -154,7 +172,7 @@ class TestScriptStore:
|
|||||||
raise TestScriptError('step %d must be an action or an expectation'
|
raise TestScriptError('step %d must be an action or an expectation'
|
||||||
% (i + 1))
|
% (i + 1))
|
||||||
script = {'name': name.strip(), 'steps': copy.deepcopy(steps)}
|
script = {'name': name.strip(), 'steps': copy.deepcopy(steps)}
|
||||||
if self._validator is not None:
|
if validate and self._validator is not None:
|
||||||
try:
|
try:
|
||||||
self._validator(copy.deepcopy(script))
|
self._validator(copy.deepcopy(script))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -162,13 +180,13 @@ class TestScriptStore:
|
|||||||
raise TestScriptError(str(e))
|
raise TestScriptError(str(e))
|
||||||
return script
|
return script
|
||||||
|
|
||||||
def _normalise(self, entry, keep_meta=False):
|
def _normalise(self, entry, keep_meta=False, validate=True):
|
||||||
"""A stored entry: the validated script plus id and timestamps. With
|
"""A stored entry: the validated script plus id and timestamps. With
|
||||||
``keep_meta`` the stored id/timestamps are preserved (a load), else
|
``keep_meta`` the stored id/timestamps are preserved (a load), else
|
||||||
fresh ones are made (an add)."""
|
fresh ones are made (an add)."""
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
raise TestScriptError('script must be an object')
|
raise TestScriptError('script must be an object')
|
||||||
script = self._check(entry)
|
script = self._check(entry, validate=validate)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
rid = str(entry.get('id') or '').strip().lower()
|
rid = str(entry.get('id') or '').strip().lower()
|
||||||
if not re.fullmatch(r'[0-9a-f]{32}', rid):
|
if not re.fullmatch(r'[0-9a-f]{32}', rid):
|
||||||
|
|||||||
+26
-67
@@ -1,89 +1,48 @@
|
|||||||
"""Server-side event-data builders (the semantic test-script `event` action).
|
"""Server-side event-data builders (the semantic test-script `event` action).
|
||||||
|
|
||||||
The byte vectors are generated from the PWA's own `EVENT_FORMS` builders (the
|
The byte vectors live in the SHARED fixture `frontend/tests/event_vectors.json`:
|
||||||
Node generator run over `frontend/index.html`) and pinned here, so the two
|
the Node suite asserts the same file against the PWA's own `EVENT_FORMS`
|
||||||
implementations cannot drift apart: every vector below is asserted by the
|
builders, this suite asserts it against `events.py`, so neither implementation
|
||||||
frontend suite too (or was produced by the very code it asserts).
|
can drift alone (a change on one side fails the other until the fixture and the
|
||||||
|
code agree).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from pysim_simple_server import events
|
from pysim_simple_server import events
|
||||||
|
|
||||||
|
VECTORS = json.loads((pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
/ 'frontend' / 'tests' / 'event_vectors.json')
|
||||||
|
.read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
|
||||||
def st(year=2026, mon=9, day=30, hour=21, minute=55, sec=0):
|
def st(year=2026, mon=9, day=30, hour=21, minute=55, sec=0):
|
||||||
return time.struct_time((year, mon, day, hour, minute, sec, 0, 0, -1))
|
return time.struct_time((year, mon, day, hour, minute, sec, 0, 0, -1))
|
||||||
|
|
||||||
|
|
||||||
class EventVectorTests(unittest.TestCase):
|
class EventVectorTests(unittest.TestCase):
|
||||||
def test_location_status(self):
|
"""Byte parity with the PWA builders from the shared fixture."""
|
||||||
cases = [
|
|
||||||
({'status': '0', 'mcc': '250', 'mnc': '01', 'lac': '00FF', 'cell': '0001'},
|
|
||||||
'9B0100930752F01000FF0001'),
|
|
||||||
({'status': '1', 'mcc': '250'}, '9B0101'),
|
|
||||||
({'status': '0'}, '9B0100'),
|
|
||||||
({'status': '0', 'mcc': '310', 'mnc': '410', 'lac': 'ABCD', 'cell': '1234'},
|
|
||||||
'9B01009307130014ABCD1234'),
|
|
||||||
]
|
|
||||||
for fields, want in cases:
|
|
||||||
self.assertEqual(events.build(0x03, fields).upper(), want)
|
|
||||||
|
|
||||||
def test_access_tech(self):
|
def test_the_shared_event_vectors(self):
|
||||||
for fields, want in [({'tech': '0'}, 'BF0100'), ({'tech': '8'}, 'BF0108'),
|
self.assertTrue(VECTORS['events'], 'the fixture must not be empty')
|
||||||
({'tech': '12'}, 'BF010C')]:
|
for v in VECTORS['events']:
|
||||||
self.assertEqual(events.build(0x0B, fields).upper(), want)
|
code = int(v['event'], 16)
|
||||||
|
self.assertEqual(events.build(code, v['fields']).upper(), v['hex'],
|
||||||
|
'event 0x%s %s' % (v['event'], v['fields']))
|
||||||
|
|
||||||
def test_network_rejection(self):
|
def test_the_shared_scts_vectors(self):
|
||||||
cases = [
|
self.assertTrue(VECTORS['scts'], 'the fixture must not be empty')
|
||||||
# location updating -> LAI, defaults filled
|
for v in VECTORS['scts']:
|
||||||
({'reg_type': '0'}, 'F40100BF0100F50102930552F0100000'),
|
stamp = time.struct_time((v['year'], v['month'], v['day'], v['hour'],
|
||||||
# GPRS -> RAI
|
v['min'], v['sec'], 0, 0, -1))
|
||||||
({'reg_type': '3', 'mcc': '262', 'mnc': '02', 'lac': '00FF',
|
self.assertEqual(events.scts_bytes(stamp, v['tz_east_min']).hex().upper(),
|
||||||
'rac': '05', 'access_tech': '3', 'cause': '15'},
|
v['hex'])
|
||||||
'F40103BF0103F5010FF30662F22000FF05'),
|
|
||||||
# EPS -> TAI (6-hex TAC) + the extended EMM cause
|
|
||||||
({'reg_type': '9', 'mcc': '310', 'mnc': '260', 'tac': 'A1B2C3',
|
|
||||||
'access_tech': '8', 'cause': '7', 'ext_cause': '22'},
|
|
||||||
'F40109BF0108F501077D06130062A1B2C3570116'),
|
|
||||||
# 5GS -> TAI (4-hex TAC)
|
|
||||||
({'reg_type': '17', 'tac': '0001', 'access_tech': '10', 'cause': '2'},
|
|
||||||
'F40111BF010AF501027D0552F0100001'),
|
|
||||||
# the extended information object (CAG ID)
|
|
||||||
({'reg_type': '9', 'access_tech': '8', 'cause': '2',
|
|
||||||
'ext_info_type': '1', 'ext_info': '00000001'},
|
|
||||||
'F40109BF0108F501027D0552F0100001F2050100000001'),
|
|
||||||
]
|
|
||||||
for fields, want in cases:
|
|
||||||
self.assertEqual(events.build(0x12, fields).upper(), want)
|
|
||||||
|
|
||||||
def test_data_connection(self):
|
|
||||||
cases = [
|
|
||||||
({'status': '0', 'type': '0', 'ti': '00', 'loc_status': '0'},
|
|
||||||
'9D0100AA01001C01009B0100'),
|
|
||||||
({'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'},
|
|
||||||
'9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101'
|
|
||||||
'C708696E7465726E65740B0103'),
|
|
||||||
({'status': '2', 'type': '1', 'ti': '', 'mcc': '250', 'loc_status': '2'},
|
|
||||||
'9D0102AA01011C0100130752F010000000019B0102'),
|
|
||||||
({'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'},
|
|
||||||
'9D0100AA01021C01AA130762F21000010002BF01079B0101'
|
|
||||||
'C7147765622E6F70657261746F722E6578616D706C650B0104'),
|
|
||||||
]
|
|
||||||
for fields, want in cases:
|
|
||||||
self.assertEqual(events.build(0x1D, fields).upper(), want)
|
|
||||||
|
|
||||||
def test_the_host_clock_date_time_object(self):
|
def test_the_host_clock_date_time_object(self):
|
||||||
# the PWA encoder's vector: UTC+3, 2026-09-30 21:55:00
|
# the data-connection builder embeds the 8.39 object 26 07 ...
|
||||||
self.assertEqual(events.scts_bytes(st(), 180).hex().upper(), '62900312550021')
|
|
||||||
# UTC-2:30: 10 quarters with the sign bit
|
|
||||||
self.assertEqual(events.scts_bytes(st(2026, 1, 5, 8, 7, 9), -150).hex().upper(),
|
|
||||||
'62105080709009')
|
|
||||||
# the data-connection builder embeds it as the 8.39 object 26 07 ...
|
|
||||||
out = events.build(0x1D, {'status': '0', 'type': '0', 'ti': '00',
|
out = events.build(0x1D, {'status': '0', 'type': '0', 'ti': '00',
|
||||||
'loc_status': '0', 'datetime': 'now'},
|
'loc_status': '0', 'datetime': 'now'},
|
||||||
now=st()).upper()
|
now=st()).upper()
|
||||||
|
|||||||
@@ -113,6 +113,32 @@ class StorePersistenceTests(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_an_entry_that_fails_the_validator_is_kept_and_survives_a_save(self):
|
||||||
|
# a tightened engine rule (or a hand edit) must not silently destroy a
|
||||||
|
# stored script on load - it stays, fails at run/update time, and the
|
||||||
|
# next save keeps it on disk
|
||||||
|
store = make_store(self.tmp.name, validator=lambda raw: raw)
|
||||||
|
store.add(script('one'))
|
||||||
|
|
||||||
|
def strict(raw):
|
||||||
|
if raw['name'] == 'one':
|
||||||
|
raise ValueError('rule tightened in a later version')
|
||||||
|
return raw
|
||||||
|
|
||||||
|
reopened = make_store(self.tmp.name, validator=strict)
|
||||||
|
self.assertEqual([s['name'] for s in reopened.list()], ['one'])
|
||||||
|
reopened.add(script('two'))
|
||||||
|
again = make_store(self.tmp.name, validator=strict)
|
||||||
|
self.assertEqual(sorted(s['name'] for s in again.list()), ['one', 'two'])
|
||||||
|
# the kept entry is refused by the mutation paths while it stays invalid
|
||||||
|
# (here the validator rejects the name 'one'), and renaming it - the
|
||||||
|
# editor's fix - succeeds
|
||||||
|
one = next(s for s in reopened.list() if s['name'] == 'one')
|
||||||
|
with self.assertRaises(test_scripts.TestScriptError):
|
||||||
|
reopened.update(one['id'], {'name': 'one'})
|
||||||
|
fixed = reopened.update(one['id'], {'name': 'renamed'})
|
||||||
|
self.assertEqual(fixed['name'], 'renamed')
|
||||||
|
|
||||||
def test_round_trip_keeps_ids_and_timestamps(self):
|
def test_round_trip_keeps_ids_and_timestamps(self):
|
||||||
store = make_store(self.tmp.name)
|
store = make_store(self.tmp.name)
|
||||||
s = store.add(script('one'))
|
s = store.add(script('one'))
|
||||||
|
|||||||
Reference in New Issue
Block a user