fix: SCP80/RAM re-read the preset before every operation; no counter bump on a rejected send (v3.6.3)
The SCP80/RAM forms hold a copy of the preset (counter, keys, TAR, SPI). Editing the preset on the Cards tab saved correctly, but the form kept the old copy: the operation sent the stale counter (the card answers cntr_low) and the post-send sync wrote the stale value back over the preset - the saved counter silently reverted. Reproduced in a real DOM: after select in SCP80: preset=0000000001 sp=0000000001 after Cards edit+save: preset=00000000AA sp=0000000001 after a sync: preset=0000000001 (edit lost) - `cardsApply()` split into `cardsApplyFields()` (field copy, no packet) and `cardsApply()` = fields + genSp; new `spRefreshFromPreset(selId)` re-reads the selected preset from `sp-card-sel` / `ram-card-sel` and re-applies it. - `pysimSendOta()` and `ramExecute()` call it before starting, so every SCP80/RAM operation uses the preset as it is now. - A rejected send no longer advances the counter: new `spPorAccepted(por)` gates the advance+write-back in `pysimSendOta`, the Explore pagination and its GET DATA step, and the server's RAM install (`_ram_next_cntr`: advance only for `por_ok`/`no_por` steps). A failed install still returns `final_cntr` (the accepted prefix) and the PWA persists it, so a retry never replays a counter the card already consumed. - tests: cards_counter.test.js (the stale-form regression, RAM selector, fields-without-genSp, spPorAccepted) and `_ram_next_cntr` cases; the cards_form/ram harnesses updated for the split. - docs/api.md counter semantics; AGENTS preset-source-of-truth rule. 607 frontend / 488 Python green; version 3.6.3; sw simple-v276.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
|
||||
'spRefreshFromPreset', 'spPorAccepted', 'spNextCntr']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
eval(code);
|
||||
|
||||
// The SP form is a working copy: the preset is the source of truth, and
|
||||
// every SCP80/RAM operation re-reads it before starting (v3.6.3).
|
||||
function fakeEnv(selValue, selId, presetCntr) {
|
||||
const els = {};
|
||||
for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-idx', 'sp-kic-alg',
|
||||
'sp-kid-idx', 'sp-kid-alg', 'sp-tar', 'sp-cntr', 'sp-kic-key', 'sp-kid-key']) {
|
||||
els[id] = { value: '' };
|
||||
}
|
||||
els[selId || 'sp-card-sel'] = { value: selValue };
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
globalThis.cards = [{ name: 'C', cntr: presetCntr, spi1: '16', spi2: '01',
|
||||
kic: '15', kid: '15', tar: '000000', uiccTar: 'B00000',
|
||||
kicKey: 'AA', kidKey: 'BB' }];
|
||||
globalThis._spTarKey = 'uiccTar';
|
||||
globalThis.updateSpKic = () => {};
|
||||
globalThis.updateSpKid = () => {};
|
||||
globalThis.spInvalidate = () => {};
|
||||
globalThis.updateSp = () => {};
|
||||
let gen = 0;
|
||||
globalThis.genSp = () => { gen++; };
|
||||
return { els, gen: () => gen };
|
||||
}
|
||||
|
||||
test('spRefreshFromPreset re-reads the edited preset before an operation', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '00000000AA');
|
||||
env.els['sp-cntr'].value = '0000000001'; // stale form copy
|
||||
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '00000000AA');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA');
|
||||
assert.ok(env.gen() > 0, 'the packet must be regenerated from the new counter');
|
||||
// no preset selected: the manual form is left alone
|
||||
globalThis.cards = [];
|
||||
assert.strictEqual(spRefreshFromPreset('sp-card-sel'), '');
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '00000000AA');
|
||||
});
|
||||
|
||||
test('spRefreshFromPreset for the RAM selector targets the ISD TAR', () => {
|
||||
const env = fakeEnv('0', 'ram-card-sel', '0000000010');
|
||||
assert.strictEqual(spRefreshFromPreset('ram-card-sel'), '0000000010');
|
||||
assert.strictEqual(_spTarKey, 'tar');
|
||||
assert.strictEqual(env.els['sp-tar'].value, '000000');
|
||||
});
|
||||
|
||||
test('cardsApplyFields fills the form without generating a packet', () => {
|
||||
const env = fakeEnv('0', 'sp-card-sel', '0000000007');
|
||||
env.els['sp-cntr'].value = 'nonsense';
|
||||
assert.strictEqual(cardsApplyFields(0), true);
|
||||
assert.strictEqual(env.els['sp-cntr'].value, '0000000007');
|
||||
assert.strictEqual(env.gen(), 0, 'a plain field refresh must not rebuild the packet');
|
||||
cardsApply(0);
|
||||
assert.strictEqual(env.gen(), 1);
|
||||
assert.strictEqual(cardsApplyFields(3), false);
|
||||
});
|
||||
|
||||
test('spPorAccepted treats a missing PoR and por_ok as accepted', () => {
|
||||
assert.strictEqual(spPorAccepted(undefined), true);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'por_ok' }), true);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'cntr_low' }), false);
|
||||
assert.strictEqual(spPorAccepted({ response_status: 'rc_cc_ds_failed' }), false);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEdit',
|
||||
'cardsAdd', 'cardsImport', 'cardsApply', 'ramApplyCard',
|
||||
'cardsAdd', 'cardsImport', 'cardsApplyFields', 'cardsApply', 'ramApplyCard',
|
||||
'cardsScp80Complete', 'cardsScp81Complete', 'cardsAdmPresent',
|
||||
'spTarKeyForPack', 'spPresetTar', 'packToSp']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
|
||||
@@ -45,6 +45,12 @@ const els = {};
|
||||
const doc = { getElementById: (id) => { if (!els[id]) els[id] = {value:''}; return els[id]; } };
|
||||
global.document = doc;
|
||||
|
||||
// ramApplyCard/ramExecute delegate to the SP-form helpers; their real
|
||||
// behaviour (and the preset re-read before an operation) is covered by
|
||||
// cards_counter.test.js, so keep them as global stubs here.
|
||||
globalThis.cardsApply = () => {};
|
||||
globalThis.spRefreshFromPreset = () => '';
|
||||
|
||||
function reset() { for (const id of Object.keys(els)) delete els[id]; }
|
||||
|
||||
function genRamResult(fields) {
|
||||
@@ -330,7 +336,8 @@ test('ramApplyCard remembers a valid picked preset', () => {
|
||||
ramApplyCard('');
|
||||
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
|
||||
delete globalThis.cards;
|
||||
delete globalThis.cardsApply;
|
||||
globalThis.cardsApply = () => {};
|
||||
delete globalThis.cards;
|
||||
});
|
||||
|
||||
test('ramExecute commits the dropdown selection before running', async () => {
|
||||
|
||||
Reference in New Issue
Block a user