frontend: fix equipped-card preset auto-selection race
release / release (push) Waiting to run

cardsAutoSelectByIccid latched its 'already handled' guard even when no
matching preset was found, so a card equipped before /api/presets had
loaded (the connect-time race with the status/availability polls) never
got auto-selected: navigating to SCP80/RAM showed no preset until the card
was re-inserted.  The guard is now latched only after a successful
selection, and cardsFetch re-runs the auto-selection once the store
arrives, so the card's preset is picked as soon as both resources are
available.

Bumps VERSION to 3.40.3 and the SW cache to simple-v488.
This commit is contained in:
catarrh committed 2026-10-27 18:58:18 +03:00
1 parent 8f63368008
commit 215d0a0ba0
6 files changed
+57 -5

No files matched your search

+12
View File
@@ -9,6 +9,18 @@ every change carries a descriptive commit message.
## [Unreleased] ## [Unreleased]
## [3.40.3] — 2026-10-27
### Fixed
- **Equipped card not always auto-selected in SCP80/RAM.** The ICCID-based
preset auto-selection latched its "already handled" guard even when no
matching preset was found, so a card equipped before `/api/presets` had
loaded (the connect-time race) never got auto-selected — navigating to
SCP80/RAM showed no preset until the card was re-inserted. The guard is
now latched only after a successful selection, and `cardsFetch` re-runs
the auto-selection once the store arrives.
## [3.40.2] — 2026-10-26 ## [3.40.2] — 2026-10-26
### Added ### Added
+11 -2
View File
@@ -1990,7 +1990,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.40.2'; const SIMPLE_VERSION = '3.40.3';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) ===== // ===== view history (the browser Back/Forward buttons) =====
@@ -14315,6 +14315,11 @@ async function cardsFetch() {
cardsRebuildSelect(); cardsRebuildSelect();
cardsRender(); cardsRender();
tarRender(); tarRender();
// A card may already be equipped when the presets arrive (the server
// starts with a card, or the fetch simply raced the first status poll):
// run the auto-selection now that the store is loaded. It is a no-op
// once the card's preset is already selected (the latched ICCID guard).
cardsAutoSelectByIccid(_pysimCardIccid);
scp81PushPskMap(); scp81PushPskMap();
spPresetWarningRender(); spPresetWarningRender();
return true; return true;
@@ -15209,12 +15214,16 @@ let _cardsAutoIccid = null;
function cardsAutoSelectByIccid(iccid) { function cardsAutoSelectByIccid(iccid) {
// Act only when the equip reports a *new* ICCID: a manual preset choice // Act only when the equip reports a *new* ICCID: a manual preset choice
// for the same card must not be overridden by later status updates. // for the same card must not be overridden by later status updates.
// The guard is latched only once the card's preset is actually found and
// selected - a miss (the preset store not loaded yet, or no preset for
// this card) is retried when the store arrives (cardsFetch) so a card
// equipped before the presets loaded still gets auto-selected.
const norm = cardsNormIccid(iccid); const norm = cardsNormIccid(iccid);
if (!norm) { _cardsAutoIccid = null; return -1; } if (!norm) { _cardsAutoIccid = null; return -1; }
if (norm === _cardsAutoIccid) return -1; if (norm === _cardsAutoIccid) return -1;
_cardsAutoIccid = norm;
const idx = cardsFindByIccid(norm); const idx = cardsFindByIccid(norm);
if (idx < 0) return -1; if (idx < 0) return -1;
_cardsAutoIccid = norm;
const sp = document.getElementById('sp-card-sel'); const sp = document.getElementById('sp-card-sel');
if (sp) sp.value = String(idx); if (sp) sp.value = String(idx);
const ram = document.getElementById('ram-card-sel'); const ram = document.getElementById('ram-card-sel');
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v487'; const CACHE = 'simple-v488';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+31
View File
@@ -146,6 +146,30 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1); assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
}); });
test('a miss is not latched, so the store arriving later still auto-selects', () => {
// The card equips before /api/presets fills cards[] (the connect-time
// race): the auto-selection must not latch the ICCID on a miss, or the
// later cardsFetch would be a no-op and the preset would stay unselected
// until the card is re-inserted.
const els = fakeDoc();
const applied = [];
globalThis.cardsApply = idx => applied.push(idx);
globalThis.cards = []; // presets not loaded yet
globalThis._ramCardIdx = null;
globalThis._cardsAutoIccid = null;
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), -1);
assert.strictEqual(globalThis._cardsAutoIccid, null,
'a missed auto-selection must not latch the ICCID');
// the presets arrive: the retry (from cardsFetch) finds and selects the card
globalThis.cards = [{ name: 'A', iccid: RAW_HEX }];
assert.strictEqual(cardsAutoSelectByIccid(DIGITS), 0);
assert.strictEqual(els['sp-card-sel'].value, '0');
assert.strictEqual(els['ram-card-sel'].value, '0');
assert.strictEqual(els['tar-card-sel'].value, '0');
assert.deepStrictEqual(applied, ['0']);
assert.strictEqual(globalThis._ramCardIdx, 0);
});
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => { test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
globalThis.cards = [ globalThis.cards = [
{ name: 'A', iccid: '1111111111111111111' }, { name: 'A', iccid: '1111111111111111111' },
@@ -201,6 +225,13 @@ test('the card-state update wires the ICCID into the preset selection', () => {
assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')")); assert.ok(html.includes("(data.iccid ? ' | ICCID: <b>' + esc(data.iccid) + '</b>' : '')"));
}); });
test('cardsFetch retries the auto-selection once the presets load', () => {
// The equip may be reported before /api/presets resolves, so the fetch
// must re-run the auto-selection (a no-op once the card is selected).
assert.ok(html.includes('cardsAutoSelectByIccid(_pysimCardIccid);'),
'cardsFetch must re-run the auto-selection after loading the store');
});
test('cardsRender highlights the equipped card preset', () => { test('cardsRender highlights the equipped card preset', () => {
globalThis.t = s => s; globalThis.t = s => s;
const els = { 'cards-tbody': { innerHTML: '' } }; const els = { 'cards-tbody': { innerHTML: '' } };
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.40.2" version = "3.40.3"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" } license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9" requires-python = ">=3.9"
+1 -1
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.40.2' VERSION = '3.40.3'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE