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'))