cards: From card button + normalized duplicate ICCID refusal (v2.2.18)

- The card create/edit form gets a From card button next to the ICCID
  field: it fills the field with the equipped card's EF.ICCID. It uses the
  availability model through a new 'card-iccid' requirement - enabled only
  while /api/status reports a connected card with a readable iccid; with a
  card but no readable ICCID the tooltip says so, otherwise the usual
  'insert and equip' hint applies. The last status ICCID is kept in
  _pysimCardIccid (cleared on disconnect) so the click needs no round-trip.
- Duplicate ICCID refusal now compares normalized values via
  cardsNormIccid/cardsFindDuplicateIccid (digits, spaced digits and raw EF
  hex all identify the same card), skips the row being edited, ignores an
  empty field, and names the conflicting preset in the alert (translated).
- i18n RU: From card -> 'С карты', duplicate/unreadable alerts.
- Tests: cardsFindDuplicateIccid across formats and edit-skip,
  cardsIccidFromCard fill + alert path, form wiring guards, _pysimCardIccid
  tracking in the card-state harness, card-iccid availability gating in the
  indicator harness. 261 Python + 400 frontend tests green.
- Docs: help EN+RU Cards field table, READMEs, AGENTS. SW otaman-v185.
This commit is contained in:
2026-09-18 23:57:05 +03:00
parent 235388a5d0
commit ce7d91cfe0
11 changed files with 151 additions and 18 deletions
+15 -1
View File
@@ -24,7 +24,7 @@ function extractFunc(src, name) {
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n'
+ 'var _cardsAutoIccid = null;\n';
+ 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
@@ -61,6 +61,7 @@ function setup() {
_pysimAdmVerified = null;
_pysimServerAvailable = null;
_cardsAutoIccid = null;
_pysimCardIccid = null;
globalThis.document = {
getElementById: id => id === 'state-indicator-adm' ? adm : el,
querySelectorAll: () => [],
@@ -122,6 +123,19 @@ test('a disconnect clears the ICCID auto-selection guard', () => {
assert.strictEqual(_cardsAutoIccid, null);
});
test('the last status ICCID is kept for the From card button', () => {
setup();
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
assert.strictEqual(_pysimCardIccid, '8970119000004600098');
// equipped but unreadable -> null (button disabled)
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null }));
assert.strictEqual(_pysimCardIccid, null);
// disconnect clears it as well
pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' }));
pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 6 }));
assert.strictEqual(_pysimCardIccid, null);
});
test('card session change triggers a data reset', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ card_session: 3 }));
+57 -2
View File
@@ -23,7 +23,8 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['swapNibbles', 'encIccid', 'decIccid',
'cardsNormIccid', 'cardsFindByIccid', 'cardsAutoSelectByIccid']) {
'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid',
'cardsIccidFromCard', 'cardsAutoSelectByIccid']) {
code += extractFunc(html, fn) + '\n';
}
eval(code);
@@ -33,11 +34,18 @@ const RAW_HEX = '980711090000640090F8';
const DIGITS = '8970119000004600098';
function fakeDoc() {
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' } };
const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' }, 'cards-iccid': { value: '' } };
globalThis.document = { getElementById: id => els[id] || null };
return els;
}
function fakeAlert() {
const seen = [];
globalThis.alert = msg => seen.push(msg);
globalThis.t = s => s;
return seen;
}
test('cardsNormIccid accepts digits, separators and raw EF hex', () => {
assert.strictEqual(cardsNormIccid(DIGITS), DIGITS);
assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS);
@@ -104,6 +112,53 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', ()
assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1);
});
test('cardsFindDuplicateIccid catches duplicates across stored formats', () => {
globalThis.cards = [
{ name: 'A', iccid: '1111111111111111111' },
{ name: 'B', iccid: RAW_HEX },
{ name: 'C', iccid: '' },
];
assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1);
assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1);
assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0);
assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1);
// empty ICCID is never a duplicate (the field is optional)
assert.strictEqual(cardsFindDuplicateIccid(''), -1);
assert.strictEqual(cardsFindDuplicateIccid(' '), -1);
});
test('cardsFindDuplicateIccid skips the row being edited', () => {
globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }];
assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1);
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1);
assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0);
});
test('cardsIccidFromCard fills the field from the equipped card', () => {
const els = fakeDoc();
const seen = fakeAlert();
globalThis._pysimCardIccid = DIGITS;
cardsIccidFromCard();
assert.strictEqual(els['cards-iccid'].value, DIGITS);
assert.deepStrictEqual(seen, []);
// no readable ICCID (state raced the click): alert, leave the field alone
els['cards-iccid'].value = 'keep-me';
globalThis._pysimCardIccid = null;
cardsIccidFromCard();
assert.strictEqual(els['cards-iccid'].value, 'keep-me');
assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']);
});
test('the From card button and the duplicate refusal are wired into the form', () => {
assert.match(html, /<button id="cards-iccid-from-card" data-needs="card-iccid" onclick="cardsIccidFromCard\(\)"/);
assert.ok(html.includes('data-l10n="From card"'));
// normalized duplicate check with the conflicting preset named
assert.ok(html.includes('cardsFindDuplicateIccid(v.iccid, _cardsEditIdx)'));
assert.ok(html.includes("t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name"));
assert.ok(html.includes("'From card': 'С карты'"));
assert.ok(html.includes("'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке'"));
});
test('the card-state update wires the ICCID into the preset selection', () => {
assert.ok(html.includes('cardsAutoSelectByIccid(status.iccid);'));
assert.ok(html.includes('_cardsAutoIccid = null;'));
+35 -2
View File
@@ -21,8 +21,11 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\nvar _pysimCardIccid = null;\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimNeedsHint') + '\n';
code += extractFunc(html, 'pysimApplyAvailability') + '\n';
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += 'globalThis.t = s => s;\n';
eval(code);
@@ -38,6 +41,7 @@ function fakeEl() {
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
getAttribute(k) { return this.attrs[k]; },
removeAttribute(k) { delete this.attrs[k]; },
};
}
@@ -49,10 +53,11 @@ function setup() {
'state-indicator-img': fakeEl(),
};
els['state-indicator-img'].src = '';
globalThis.document = { getElementById: id => els[id] || null };
globalThis.document = { getElementById: id => els[id] || null, querySelectorAll: () => [] };
_pysimServerAvailable = null;
_pysimCardEquipped = false;
_pysimEquipping = false;
_pysimCardIccid = null;
return els;
}
@@ -127,6 +132,34 @@ test('indicator markup carries the dot and image elements', () => {
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
});
test('card-iccid controls need an equipped card with a readable ICCID', () => {
const check = (state, iccid) => {
const el = fakeEl();
el.setAttribute('data-needs', 'card-iccid');
globalThis.document = { querySelectorAll: () => [el], getElementById: () => null };
_pysimServerAvailable = state !== 'server-down';
_pysimCardEquipped = state === 'card';
_pysimCardIccid = iccid;
pysimApplyAvailability();
return el;
};
// no card -> disabled
let el = check('no-card', null);
assert.strictEqual(el.disabled, true);
assert.strictEqual(el.attrs.title, 'Insert and equip a card');
// equipped but unreadable ICCID -> disabled with its own hint
el = check('card', null);
assert.strictEqual(el.disabled, true);
assert.strictEqual(el.attrs.title, 'Card equipped but its ICCID is not readable');
// equipped with an ICCID -> enabled
el = check('card', '8970119000004600098');
assert.strictEqual(el.disabled, false);
assert.strictEqual(el.attrs.title, undefined);
// server down -> disabled
el = check('server-down', '8970119000004600098');
assert.strictEqual(el.disabled, true);
});
test('indicator image stays within the 32px header row budget', () => {
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
assert.ok(m, 'inline image size not found');