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:
@@ -321,3 +321,26 @@ test('the event form previews the bytes the send would deliver', () => {
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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, 'testRunPreset'));
|
||||
eval(extractFunc(html, 'testScriptsWriteback', true));
|
||||
eval(extractFunc(html, 'testScriptsWritebackQueued'));
|
||||
eval(extractFunc(html, 'testScriptsSave'));
|
||||
eval(extractFunc(html, 'testRunStart', true));
|
||||
eval(extractFunc(html, 'testDelete', true));
|
||||
eval(extractFunc(html, 'testChecksCollect'));
|
||||
eval('var _testEditStep = null; var _testEditChecks = []; var _testEditStepIndex = -1;'
|
||||
+ ' 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: () => {} };
|
||||
|
||||
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',
|
||||
'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
|
||||
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
|
||||
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||
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', () => {
|
||||
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||
|
||||
Reference in New Issue
Block a user