v3.22.0 (review fixes): the suite members share the SCP80 counter; drain/PWA nits

The v3.22.0 review found that a suite's SCP80 counter state was rebuilt from
the preset snapshot for every member: a second SCP80 script sent the counter
the first had consumed (the store persists the advance, the in-memory preset
copy does not move) and the card answered cntr_low.  Reproduced with a
two-member suite (both packets carried CNTR=0000000010).

- runner: `_test_run_script` takes a `shared` counter state (created once per
  suite, passed to every member; a single script passes None) and copies the
  no-keyset counter back into it, so a suite behaves like one script for
  counter bookkeeping.  Pinned by a two-member test asserting the second
  packet's counter is the first's + 1 and the final counters.
- `_test_drain_pending`: the unknown-length FETCH uses 0xff - `'%02x' % 0x100`
  emitted an odd-length APDU (a malformed fetch) when a 9100 SW (length 0)
  reached the drain.
- PWA: the suite save adopts the stored suite (the server normalises the
  setup-first/teardown-last order); the role rows' up/down buttons are
  disabled (only members move); deleting a suite cancels its pending name
  debounce (no late 404); `refreshDynamicI18n` redraws the test views, so a
  language switch updates the suites list/report immediately.
- tests: the cross-member counter test (Python) and the save-adoption /
  move-guard / i18n wiring tests (Node).
  Tests: 835 frontend + 795 Python; sw cache simple-v398.
This commit is contained in:
2026-10-06 00:41:11 +03:00
parent 5976ef89b4
commit 22a0a58f27
5 changed files with 110 additions and 14 deletions
+36 -1
View File
@@ -26,9 +26,13 @@ function extractFunc(src, name, asyncFn) {
let code = '';
for (const fn of ['testStatusBadge', 'testRunStepHtml', 'testReportMemberHtml',
'testReportMarkdown', 'testSuiteProgress', 'testRoleOptions', 'testFailOptions']) {
'testReportMarkdown', 'testSuiteProgress', 'testRoleOptions', 'testFailOptions',
'testSuiteSave']) {
code += extractFunc(html, fn) + '\n';
}
code += 'var _testSuites = []; var _testScripts = []; var _testSuiteIdx = -1;'
+ ' var _testView = "suite"; var _testSuiteSaveTimer = null;'
+ ' var _testSuiteTarget = null; var _testScriptId = null; var _testScriptDraft = null;\n';
eval(code);
globalThis.t = s => s;
globalThis.esc = s => String(s);
@@ -149,3 +153,34 @@ test('a single script run sends its require_adm flag and keeps its id', () => {
assert.match(src, /body\.script_id = cur\.id/);
assert.match(src, /testRenderRun\(resp\)/);
});
test('the suite save adopts the stored suite (the server normalises the order)', async () => {
const calls = [];
const suite = {id: 'a'.repeat(32), name: 'edited', require_adm: false, scripts: []};
_testSuites = [suite];
_testSuiteIdx = 0;
globalThis.testCurrentSuite = () => suite;
globalThis.testSuites = () => _testSuites;
globalThis.testRenderSuiteScripts = () => {};
globalThis.ioStatus = () => {};
globalThis.pysimFetch = async (path, body) => {
calls.push([path, body]);
return {ok: true, suite: Object.assign({}, suite, {name: 'stored'})};
};
testSuiteSave(true);
await new Promise(r => setTimeout(r, 10));
assert.strictEqual(calls[0][0], '/api/test/suites/update');
assert.strictEqual(calls[0][1].suite.scripts.length, 0);
assert.strictEqual(_testSuites[0].name, 'stored');
});
test('role rows cannot be moved and the i18n refresh redraws the test views', () => {
const rows = extractFunc(html, 'testRenderSuiteScripts');
assert.match(rows, /const movable = e\.role === 'member'/);
assert.match(rows, /!movable \|\| i === 0/);
assert.match(rows, /!movable \|\| i === entries\.length - 1/);
const del = extractFunc(html, 'testSuiteDelete', true);
assert.match(del, /_testSuiteSaveTimer/);
const i18n = extractFunc(html, 'refreshDynamicI18n');
assert.match(i18n, /isViewVisible\('phone-sub-test'\)\) testRender\(\)/);
});