v3.22.3: the mismatch on_fail, the self-healing move, the pruning reconciliation

Three test-management issues:

- an expectation whose command type did not match the pending command
  hard-coded level 'error', so on_fail "warning" tolerated "no command
  pending" but not "a different command is pending".  The check now honours
  the step's on_fail - the tolerant form for "the applet either asks X or
  does Y" (the mismatched command is still fetched and answered).
- moving a script between suites could answer 500 and leave two owners:
  _test_script_move detached only from the suite named by the script's
  suite_id and appended to the target unconditionally, so a duplicate
  listing or a stale suite_id (a hand edit, an interrupted move) collided
  on the attach.  It now detaches from every suite that lists the script,
  takes the on_fail from the explicit owner (suite_id if that suite lists
  it, else the first lister), updates the script first (a refused
  validation changes nothing) and re-attaches with a prune; the same-suite
  no-op only applies when the target really is the only lister.
- reconcile_scripts_to_suites() now prunes duplicate listings to the
  explicit owner (it only adopted before), so those states self-heal at
  startup and after a bundle import; the "Imported scripts" suite is only
  created when there are orphans.

Docs: the bundle-import note (importing into the source store duplicates
the suite and its scripts - the round-trip is not idempotent) and the
expectation on_fail clarification.
Tests: 839 frontend + 818 Python; sw cache simple-v407.
This commit is contained in:
2026-10-06 21:14:07 +03:00
parent a76e0154b6
commit 7d997272ef
9 changed files with 258 additions and 55 deletions
+101 -44
View File
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.22.2'
VERSION = '3.22.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -5987,30 +5987,52 @@ def _test_script_detach(suites, cur):
def _test_script_move(store, suites, cur, target_id):
"""Move a script to another suite: the source detaches, the target appends
it as a member (keeping the on_fail policy it had). Both stores are
updated in the one request. Returns ``(entry, error, status)``."""
"""Move a script to another suite: the target appends it as a member
(keeping the on_fail policy it had) and **every** suite that lists it
detaches. The script's own ``suite_id`` names the owner when that suite
lists it, otherwise the first lister does. A duplicate listing or a
stale suite_id (a hand edit, an interrupted move) is repaired by the
move instead of answering 500 and leaving two owners (v3.22.3 review).
Both stores are updated in the one request; returns
``(entry, error, status)``."""
target = suites.get(target_id)
if target is None:
return None, 'unknown target suite', 400
if target['id'] == (cur.get('suite_id') or ''):
listings = [s for s in suites.list()
if any(e['script_id'] == cur['id'] for e in s['scripts'])]
if target['id'] == (cur.get('suite_id') or '') and [s['id'] for s in listings] == [target['id']]:
# the exact no-op: the script already belongs to the target and only
# the target lists it
return cur, None, 200
source = suites.get(cur.get('suite_id')) if cur.get('suite_id') else None
owner = next((s for s in listings if s['id'] == (cur.get('suite_id') or '')), None)
if owner is None and listings:
owner = listings[0]
on_fail = 'stop'
if source is not None:
entry = next((e for e in source['scripts'] if e['script_id'] == cur['id']), None)
if owner is not None:
entry = next((e for e in owner['scripts'] if e['script_id'] == cur['id']), None)
if entry is not None:
on_fail = entry['on_fail']
_test_script_detach(suites, cur)
# The script first: its validation refuses before any listing changes, so
# a failed move never leaves the script unlisted.
try:
new = store.update(cur['id'], {'suite_id': target['id']})
except (test_scripts.TestScriptError, OSError) as e:
return None, 'cannot move the script: %s' % e, 500
if new is None:
return None, 'unknown test script id', 404
for s in listings:
entries = [e for e in s['scripts'] if e['script_id'] != cur['id']]
if entries == s['scripts']:
continue
try:
suites.update(s['id'], {'scripts': entries})
except (test_suites.TestSuiteError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot detach %s from "%s": %s\n'
% (cur['id'], s['name'], e))
try:
entries = list(target['scripts']) + [
{'script_id': cur['id'], 'role': 'member', 'on_fail': on_fail}]
fresh = suites.get(target['id']) or target
entries = [e for e in fresh['scripts'] if e['script_id'] != cur['id']]
entries.append({'script_id': cur['id'], 'role': 'member', 'on_fail': on_fail})
suites.update(target['id'], {'scripts': entries})
except (test_suites.TestSuiteError, OSError) as e:
return None, 'cannot attach to the target suite: %s' % e, 500
@@ -6021,6 +6043,10 @@ def reconcile_scripts_to_suites(script_store, suite_store):
"""Make the ownership invariant hold again: every script belongs to
exactly one suite and that suite lists it.
- a script **listed by several suites** (a hand edit or an interrupted
move) stays with its explicit owner - the suite its ``suite_id`` names
when that suite lists it, else the first lister - and the other
listings are pruned;
- a script no suite lists (a v3.21.0 store, a suites import with
``mode: "replace"``, a hand edit or an interrupted move) is attached to
the auto-created "Imported scripts" suite;
@@ -6032,6 +6058,31 @@ def reconcile_scripts_to_suites(script_store, suite_store):
if script_store is None or suite_store is None:
return 0
suites = suite_store.list()
listings = {}
for s in suites:
for e in s['scripts']:
listings.setdefault(e['script_id'], []).append(s['id'])
by_id = {s['id']: s for s in script_store.list()}
pruned = 0
for sid, owners in listings.items():
if len(owners) < 2:
continue
explicit = (by_id.get(sid) or {}).get('suite_id') or None
keep = explicit if explicit in owners else owners[0]
for s in suites:
if s['id'] == keep or s['id'] not in owners:
continue
entries = [e for e in s['scripts'] if e['script_id'] != sid]
try:
suite_store.update(s['id'], {'scripts': entries})
s['scripts'] = entries
pruned += 1
sys.stderr.write('TESTSUITES: pruned %s from "%s" (owner "%s")\n'
% (sid, s['name'], keep))
except (test_suites.TestSuiteError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot prune %s from "%s": %s\n'
% (sid, s['name'], e))
# The owner map after the pruning (the member list is authoritative).
owner = {}
for s in suites:
for e in s['scripts']:
@@ -6053,40 +6104,43 @@ def reconcile_scripts_to_suites(script_store, suite_store):
% (script['id'], e))
continue
orphans.append(script)
if not orphans and not adopted:
if not orphans and not adopted and not pruned:
return 0
suite = suite_store.find_by_name('Imported scripts')
if suite is None:
suite = suite_store.add({'name': 'Imported scripts'})
entries = list(suite['scripts'])
known = {e['script_id'] for e in entries}
attached = 0
for s in orphans:
try:
script_store.update(s['id'], {'suite_id': suite['id']})
except (test_scripts.TestScriptError, OSError) as e:
# The member list is authoritative: list the script anyway. The
# store keeps a script that no longer validates (a tightened rule
# must not destroy it) and its suite_id write is refused by the
# same validator - without the listing the script would be
# invisible in the PWA and could never be fixed in the editor.
sys.stderr.write('TESTSUITES: cannot set the suite id of %s '
'(listed in "%s" so the editor can fix it): %s\n'
% (s['id'], suite['name'], e))
if s['id'] not in known:
entries.append({'script_id': s['id'], 'role': 'member', 'on_fail': 'stop'})
known.add(s['id'])
attached += 1
if attached:
try:
suite_store.update(suite['id'], {'scripts': entries})
except (test_suites.TestSuiteError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot fill "%s": %s\n' % (suite['name'], e))
if attached or adopted:
sys.stderr.write('TESTSUITES: reconciled %d script(s) (attached to "%s": %d, '
'adopted by their suite: %d)\n'
% (attached + adopted, suite['name'], attached, adopted))
return attached + adopted
if orphans:
suite = suite_store.find_by_name('Imported scripts')
if suite is None:
suite = suite_store.add({'name': 'Imported scripts'})
entries = list(suite['scripts'])
known = {e['script_id'] for e in entries}
for s in orphans:
try:
script_store.update(s['id'], {'suite_id': suite['id']})
except (test_scripts.TestScriptError, OSError) as e:
# The member list is authoritative: list the script anyway.
# The store keeps a script that no longer validates (a
# tightened rule must not destroy it) and its suite_id write
# is refused by the same validator - without the listing the
# script would be invisible in the PWA and could never be
# fixed in the editor.
sys.stderr.write('TESTSUITES: cannot set the suite id of %s '
'(listed in "%s" so the editor can fix it): %s\n'
% (s['id'], suite['name'], e))
if s['id'] not in known:
entries.append({'script_id': s['id'], 'role': 'member', 'on_fail': 'stop'})
known.add(s['id'])
attached += 1
if attached:
try:
suite_store.update(suite['id'], {'scripts': entries})
except (test_suites.TestSuiteError, OSError) as e:
sys.stderr.write('TESTSUITES: cannot fill "%s": %s\n' % (suite['name'], e))
if attached or adopted or pruned:
sys.stderr.write('TESTSUITES: reconciled %d script(s) (attached to '
'"Imported scripts": %d, adopted by their suite: %d, '
'pruned duplicate listings: %d)\n'
% (attached + adopted + pruned, attached, adopted, pruned))
return attached + adopted + pruned
def migrate_scripts_to_suites(script_store, suite_store):
@@ -6849,9 +6903,12 @@ def _test_run_expect(server, step, pending, ctx=None):
checks.append(_test_check_result('Command', True, step['command'].get('name') or type_name,
'%s (0x%02X)' % (type_name, cmd_type), 'ok'))
else:
# A different command pending is a check failure like any other: it
# honours the step's on_fail, so on_fail "warning" expresses "the
# applet either asks X or Y" (the command is still fetched/answered).
checks.append(_test_check_result(
'Command', False, step['command'].get('name') or '0x%02X' % want_type,
'%s (0x%02X)' % (type_name, cmd_type), 'error'))
'%s (0x%02X)' % (type_name, cmd_type), step['on_fail']))
if step.get('qualifier'):
actual_q = '%02X' % cmd_qual if cmd_qual is not None else None
checks.append(_test_check_result('Qualifier',