diff --git a/docs/api.md b/docs/api.md index 3e958d1..c52c74f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1163,7 +1163,9 @@ interrupt it; the state lives in memory and a server restart ends the run. `phase` is `idle` (no run), `waiting_card`, `installing`, `stopping`, `stopped` or `finished`. A failed attempt carries `failed_step` (e.g. `LOAD (3/7)`), `por_sw` (the card's own status word), `por_status`, - `por_error` and `sw`; a skipped one carries `reason`. `current` names the + `por_error` and `sw` — or, when the chain died before a verdict (a lost + card, a transport error), just `error`; a skipped one carries `reason` + (a request/eligibility error: the install never started). `current` names the card being installed (`{iccid, preset, started}`) and `progress` mirrors the RAM progress payload while a chain runs. The report keeps the last 500 attempts (`attempts_total` counts them all). diff --git a/frontend/index.html b/frontend/index.html index dcd0e95..73d7dd2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10213,6 +10213,9 @@ function ramBatchRender() { stopBtn.textContent = phase === 'stopping' ? t('Stopping...') : t('Stop'); stopBtn.disabled = phase === 'stopping'; } + // the server refuses a clear while the run is active + const clearBtn = document.getElementById('ram-batch-clear'); + if (clearBtn) clearBtn.disabled = active; const stateEl = document.getElementById('ram-batch-state'); if (stateEl) { if (!active) { @@ -10261,7 +10264,7 @@ function ramBatchAttemptsHtml(attempts) { const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400' : (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'); const detail = a.result === 'failed' - ? [a.failed_step, a.por_sw || a.sw].filter(Boolean).join(' \u2014 ') + ? [a.failed_step, a.por_sw || a.sw || a.error].filter(Boolean).join(' \u2014 ') : (a.reason || ''); html += '' + '' + esc(when) + '' diff --git a/frontend/tests/ram_batch.test.js b/frontend/tests/ram_batch.test.js index 28a9752..459d449 100644 --- a/frontend/tests/ram_batch.test.js +++ b/frontend/tests/ram_batch.test.js @@ -140,6 +140,18 @@ test('ramBatchAttemptsHtml renders every verdict with its detail', () => { // newest first assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098')); assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./); + // a failure without a step (a lost card mid-chain) shows its error + assert.match(app.ramBatchAttemptsHtml([{ iccid: '8970119000004600122', result: 'failed', + error: 'card removed' }]), /card removed/); +}); + +test('the install and the batch send the form\'s ram_format', () => { + const fields = extractFunc(html, 'ramInstallCapFields'); + assert.match(fields, /ram_format: \(document\.getElementById\('ram-format'\) \|\| \{\}\)\.value \|\| 'auto'/); + // the single install merges the shared fields into its request body + assert.match(extractFunc(html, 'ramInstallCap', true), /Object\.assign\(\{/); + assert.match(extractFunc(html, 'ramBatchStart', true), + /pysimFetchOk\('\/api\/ram-batch\/start', fields\)/); }); test('ramBatchRender shows the phase, the counts and the stop button', () => { @@ -152,12 +164,14 @@ test('ramBatchRender shows the phase, the counts and the stop button', () => { setEl('ram-batch-notice', ''); setEl('ram-batch-counts', ''); setEl('ram-batch-attempts', ''); + setEl('ram-batch-clear', ''); // waiting for a card app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' }); app.ramBatchRender(); assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...'); assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined); assert.ok(els['ram-batch-start'].classes['hidden']); + assert.ok(els['ram-batch-clear'].disabled); // the server refuses a clear while active // installing card X with the live step app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 }, attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } }); @@ -177,6 +191,7 @@ test('ramBatchRender shows the phase, the counts and the stop button', () => { assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped'); assert.ok(els['ram-batch-start'].classes['hidden'] === undefined); assert.ok(els['ram-batch-stop'].classes['hidden']); + assert.ok(!els['ram-batch-clear'].disabled); // clearable again }); test('ramBatchApplyStatus fetches only when the summary changed', async () => { diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 060289a..1542245 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -2640,12 +2640,18 @@ def _ram_batch_handle_card(state, server): started = time.time() result = None error = None + request_error = False try: with _CARD_LOCK: result = _ram_install_run(server, body, state['plan'], server.scc) + except ValueError as e: + # A request/eligibility error (the preset's ISD keyset, the plan): the + # install never started - the card is skipped. + error = str(e) + request_error = True except Exception as e: - # A refused keyset, a lost card, a transport error: the card's outcome - # is a failure - the batch never retries it. + # The chain started and died (a lost card, a transport error): the + # attempt failed. error = str(e) finally: with _RAM_BATCH_LOCK: @@ -2667,11 +2673,16 @@ def _ram_batch_handle_card(state, server): 'cntr_low': bool(result.get('cntr_low')), 'error': result.get('error') or ''} rec.update(_ram_batch_failure_fields(result)) - else: + elif request_error: outcome = 'skipped' rec = {'iccid': iccid, 'preset': name, 'result': outcome, 'duration_ms': duration, 'reason': error or 'the install could not start'} + else: + outcome = 'failed' + rec = {'iccid': iccid, 'preset': name, 'result': outcome, + 'duration_ms': duration, + 'error': error or 'the install failed'} with _RAM_BATCH_LOCK: state['seen'][iccid] = outcome _ram_batch_record(state, rec) diff --git a/tests/test_ram_batch.py b/tests/test_ram_batch.py index 0414ac2..5a2a77c 100644 --- a/tests/test_ram_batch.py +++ b/tests/test_ram_batch.py @@ -180,6 +180,23 @@ class BatchCardTests(unittest.TestCase): self.assertIn('needs a default keyset', rec['reason']) self.assertEqual(st['seen']['8970119000004600098'], 'skipped') + def test_a_transport_error_mid_chain_is_a_failure(self): + st = srv._ram_batch_new_state(_plan()) + preset = _preset('8970119000004600098') + + def fake_run(*a, **k): + raise OSError('card removed') + + with mock.patch.object(srv, '_ram_install_run', fake_run): + srv._ram_batch_handle_card(st, _fake_server('8970119000004600098', + _FakeStore([preset]))) + rec = st['attempts'][0] + # the chain started and died: a failure, not a skip + self.assertEqual(rec['result'], 'failed') + self.assertIn('card removed', rec['error']) + self.assertEqual(st['counts']['failed'], 1) + self.assertEqual(st['seen']['8970119000004600098'], 'failed') + def test_a_card_already_processed_is_reported_not_reinstalled(self): st = srv._ram_batch_new_state(_plan()) preset = _preset('8970119000004600098', 'Alfa') @@ -225,6 +242,26 @@ class BatchCardTests(unittest.TestCase): self.assertFalse(srv._ram_batch_summary()['active']) +class RamInstallPlanTests(unittest.TestCase): + """The shared card-independent plan (v3.28.0): the block-size/hex checks + and the transport knobs the single install and the batch both use.""" + + def test_the_plan_validates_the_input(self): + with mock.patch.object(srv, '_cap_parse', lambda h: ('A1', 'A2', b'\x01')), \ + mock.patch.object(srv, '_cap_apdu_sequence', lambda *a, **k: ['80E60200']): + plan = srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': '120'}) + self.assertEqual(plan['block_size'], 120) + self.assertEqual(plan['spi2'], '01') + self.assertEqual(plan['ram_format'], 'auto') + self.assertEqual(plan['seq'], ['80E60200']) + with self.assertRaises(ValueError): + srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': 300}) + with self.assertRaises(ValueError): + srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': 'x'}) + with self.assertRaises(ValueError): + srv._ram_install_plan({'cap_hex': ''}) + + class BatchLifecycleTests(unittest.TestCase): def setUp(self): srv._RAM_BATCH = None