diff --git a/frontend/index.html b/frontend/index.html
index 7661645..4819531 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1877,7 +1877,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.18.1';
+const SIMPLE_VERSION = '3.18.2';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -6878,6 +6878,10 @@ let _testCurrentIdx = -1;
let _testRunState = null;
let _testRunTimer = 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 _testEditChecks = [];
let _testLastPresetIdx = -1;
@@ -6937,11 +6941,23 @@ function testScriptsSave(immediate) {
const cur = testCurrent();
if (!cur) return;
if (_testSaveTimer) { clearTimeout(_testSaveTimer); _testSaveTimer = null; }
- if (immediate) { testScriptsWriteback(cur); return; }
- _testSaveTimer = setTimeout(() => { _testSaveTimer = null; testScriptsWriteback(cur); }, 600);
+ if (immediate) { testScriptsWritebackQueued(cur); return; }
+ _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) {
+ if (script._deleted) return; // queued before the delete: never resurrect
const problem = testScriptProblem(script);
if (problem) return; // an incomplete draft stays local until it is valid
const payload = {name: script.name || '', steps: script.steps};
@@ -6952,7 +6968,13 @@ async function testScriptsWriteback(script) {
} else {
const resp = await pysimFetch('/api/test/scripts', payload);
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) {
ioStatus('test-io-status', t('Could not save the test script') + ': ' + e.message);
@@ -7150,6 +7172,8 @@ function testClone() {
if (!cur) return;
const copy = JSON.parse(JSON.stringify(cur));
delete copy.id;
+ delete copy._deleted;
+ delete copy._eventFieldsError;
copy.name = (copy.name || t('Script')) + ' (2)';
_testScripts.push(copy);
_testCurrentIdx = _testScripts.length - 1;
@@ -7161,11 +7185,16 @@ async function testDelete() {
const cur = testCurrent();
if (!cur) 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) {
try {
const resp = await pysimFetch('/api/test/scripts/delete', {id: cur.id});
if (resp.error) throw new Error(resp.error);
} 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);
return;
}
@@ -7558,6 +7587,8 @@ function testStepCollect() {
p.event = val('test-f-evtype') || '03';
const raw = val('test-f-evfields');
p.fields = {};
+ // the flag reflects THIS collect, so fixing the JSON saves again
+ delete step._eventFieldsError;
if (raw) {
try {
const obj = JSON.parse(raw);
@@ -7567,7 +7598,6 @@ function testStepCollect() {
step._eventFieldsError = true;
}
}
- if (!step._eventFieldsError && !raw) delete step._eventFieldsError;
}
else if (kind === 'file-write' || kind === 'file-read') {
p.path = val('test-f-path');
diff --git a/frontend/sw.js b/frontend/sw.js
index f5dcb38..b788c07 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v382';
+const CACHE = 'simple-v383';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/event_forms.test.js b/frontend/tests/event_forms.test.js
index c834eb2..dc40a91 100644
--- a/frontend/tests/event_forms.test.js
+++ b/frontend/tests/event_forms.test.js
@@ -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));
+ }
+});
diff --git a/frontend/tests/event_vectors.json b/frontend/tests/event_vectors.json
new file mode 100644
index 0000000..b522798
--- /dev/null
+++ b/frontend/tests/event_vectors.json
@@ -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"}
+ ]
+}
diff --git a/frontend/tests/testscripts.test.js b/frontend/tests/testscripts.test.js
index 5931e21..2414a7f 100644
--- a/frontend/tests/testscripts.test.js
+++ b/frontend/tests/testscripts.test.js
@@ -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',
diff --git a/pyproject.toml b/pyproject.toml
index 57d595b..7994af3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.18.1"
+version = "3.18.2"
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 8b55bdb..5d9a294 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -34,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
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
diff --git a/pysim_simple_server/test_scripts.py b/pysim_simple_server/test_scripts.py
index ba03ced..d597ae3 100644
--- a/pysim_simple_server/test_scripts.py
+++ b/pysim_simple_server/test_scripts.py
@@ -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
(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
-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
PWA exports) plus a stable uuid and timestamps::
@@ -97,9 +100,23 @@ class TestScriptStore:
loaded = []
for entry in items:
try:
- loaded.append(self._normalise(entry, keep_meta=True))
+ s = self._normalise(entry, keep_meta=True, validate=False)
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
def _save(self):
@@ -132,13 +149,14 @@ class TestScriptStore:
pass
# ------------------------------------------------------------- shaping
- def _check(self, raw):
+ def _check(self, raw, validate=True):
"""The script dict, validated: the basic shape always, the full engine
- pass when a validator was provided. The *input* shape is kept - the
- engine's normalised output is not meant to be re-normalised (it fills
- defaults like an 'any' PoR check), and the runner normalises again at
- start, so storing what was submitted keeps the store re-validatable and
- hand-editable."""
+ pass when a validator was provided (and ``validate`` is set - a load
+ keeps an entry that no longer validates, the mutation paths refuse it).
+ The *input* shape is kept - the engine's normalised output is not meant
+ to be re-normalised (it fills defaults like an 'any' PoR check), and the
+ runner normalises again at start, so storing what was submitted keeps
+ the store re-validatable and hand-editable."""
if not isinstance(raw, dict):
raise TestScriptError('script must be an object')
name = raw.get('name')
@@ -154,7 +172,7 @@ class TestScriptStore:
raise TestScriptError('step %d must be an action or an expectation'
% (i + 1))
script = {'name': name.strip(), 'steps': copy.deepcopy(steps)}
- if self._validator is not None:
+ if validate and self._validator is not None:
try:
self._validator(copy.deepcopy(script))
except Exception as e:
@@ -162,13 +180,13 @@ class TestScriptStore:
raise TestScriptError(str(e))
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
``keep_meta`` the stored id/timestamps are preserved (a load), else
fresh ones are made (an add)."""
if not isinstance(entry, dict):
raise TestScriptError('script must be an object')
- script = self._check(entry)
+ script = self._check(entry, validate=validate)
now = time.time()
rid = str(entry.get('id') or '').strip().lower()
if not re.fullmatch(r'[0-9a-f]{32}', rid):
diff --git a/tests/test_events.py b/tests/test_events.py
index d4f40f3..1676b3e 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -1,89 +1,48 @@
"""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
-Node generator run over `frontend/index.html`) and pinned here, so the two
-implementations cannot drift apart: every vector below is asserted by the
-frontend suite too (or was produced by the very code it asserts).
+The byte vectors live in the SHARED fixture `frontend/tests/event_vectors.json`:
+the Node suite asserts the same file against the PWA's own `EVENT_FORMS`
+builders, this suite asserts it against `events.py`, so neither implementation
+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 unittest
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):
return time.struct_time((year, mon, day, hour, minute, sec, 0, 0, -1))
class EventVectorTests(unittest.TestCase):
- def test_location_status(self):
- 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)
+ """Byte parity with the PWA builders from the shared fixture."""
- def test_access_tech(self):
- for fields, want in [({'tech': '0'}, 'BF0100'), ({'tech': '8'}, 'BF0108'),
- ({'tech': '12'}, 'BF010C')]:
- self.assertEqual(events.build(0x0B, fields).upper(), want)
+ def test_the_shared_event_vectors(self):
+ self.assertTrue(VECTORS['events'], 'the fixture must not be empty')
+ for v in VECTORS['events']:
+ 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):
- cases = [
- # location updating -> LAI, defaults filled
- ({'reg_type': '0'}, 'F40100BF0100F50102930552F0100000'),
- # GPRS -> RAI
- ({'reg_type': '3', 'mcc': '262', 'mnc': '02', 'lac': '00FF',
- 'rac': '05', 'access_tech': '3', 'cause': '15'},
- '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_shared_scts_vectors(self):
+ self.assertTrue(VECTORS['scts'], 'the fixture must not be empty')
+ for v in VECTORS['scts']:
+ stamp = time.struct_time((v['year'], v['month'], v['day'], v['hour'],
+ v['min'], v['sec'], 0, 0, -1))
+ self.assertEqual(events.scts_bytes(stamp, v['tz_east_min']).hex().upper(),
+ v['hex'])
def test_the_host_clock_date_time_object(self):
- # the PWA encoder's vector: UTC+3, 2026-09-30 21:55:00
- 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 ...
+ # the data-connection builder embeds the 8.39 object 26 07 ...
out = events.build(0x1D, {'status': '0', 'type': '0', 'ti': '00',
'loc_status': '0', 'datetime': 'now'},
now=st()).upper()
diff --git a/tests/test_test_scripts.py b/tests/test_test_scripts.py
index adf0cd8..769409e 100644
--- a/tests/test_test_scripts.py
+++ b/tests/test_test_scripts.py
@@ -113,6 +113,32 @@ class StorePersistenceTests(unittest.TestCase):
def tearDown(self):
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):
store = make_store(self.tmp.name)
s = store.add(script('one'))