6 Commits

Author SHA1 Message Date
catarrh 851ff65af6 changelog: note the README Telegram channel link
release / release (push) Waiting to run
2026-10-23 21:46:10 +03:00
catarrh 68a22e854b readme: link the project news Telegram channel
Add @youeyeseesee (releases and updates for my GitHub projects) near the
top of both READMEs.
2026-10-23 21:44:45 +03:00
catarrh 39d384636e batch waiting cue: move the contact pad to the bottom edge
The card's contact pad sat mid-card; it now sits near the bottom edge,
away from the top-right cut corner (the cut side is the card's top).
No other geometry or animation changes.

1024 Python + 1063 frontend; simple-v473.
2026-10-23 21:41:30 +03:00
catarrh 14d15b1fe8 batch waiting cue: use the app's SIM silhouette for the card
Redraw the inline card-into-reader SVG after sim.svg: a rounded card
with the top-right cut corner and the 3x2 contact pad, sliding behind
the reader bar.  The viewBox grew to 72x48 and the resting position is
0, so the card is no longer clipped at the top; the insert slide is
20px.  The currentColor/Tailwind theming and the reduced-motion
fallback are unchanged.

1024 Python + 1063 frontend; simple-v472.
2026-10-23 21:37:15 +03:00
catarrh 110dc1f1ad batch delete: report an already-absent object as such
A delete run whose DELETE answered 6A88/6A82 is still a success (the
object is gone, the goal is met) but it is no longer reported as a plain
"OK": the attempt result is 'already_absent', the counts gain that
bucket ("Deleted: N - Already absent: M"), the verdict reads "Already
absent" and the notice says the card was already absent.  A reinstall's
tolerated pre-delete keeps the install outcome (the install is the
point).

1024 Python + 1063 frontend; simple-v471.
2026-10-23 21:23:55 +03:00
catarrh fb65644328 v3.36.0: a deliberate "insert the next card" cue for batch runs
The waiting phase is the only moment the operator must act, so it now:
- shows a card-into-reader SVG animation in the status modal (inline
  SVG, CSS keyframes in src/style.css; reduced-motion users get the
  static card-above-the-slot picture);
- plays a short Web Audio chime once per card, edge-triggered in the
  2 s poll so it never repeats (the AudioContext is unlocked by the
  Start click);
- sets the tab title to "Insert next card" while the window is hidden;
- offers a speaker toggle next to the status (persisted as
  simple_ram_batch_sound, on by default).

The prebuilt style.css was rebuilt: it had gone stale and lacked
utilities the UI already used (bg-blue-50 on the batch notice,
inline-flex, mx-2, max-h-48, w-44, min-w-24).

1022 Python + 1062 frontend green; simple-v470.
2026-10-23 21:14:32 +03:00
13 changed files with 482 additions and 30 deletions
+30
View File
@@ -9,6 +9,36 @@ every change carries a descriptive commit message.
## [Unreleased]
## [3.36.0] — 2026-10-23
### Added
- The batch **"waiting for the next card"** state is now a deliberate cue: the status
modal shows a card-into-reader animation next to the prompt, a short chime sounds
**once per card** when the run enters the waiting phase (Web Audio — no audio asset;
edge-triggered in the 2 s poll, so it does not repeat), and while the tab is hidden the
title hints "Insert next card". The speaker button next to the status mutes the chime
and the choice is remembered (`simple_ram_batch_sound`); the chime is unlocked by the
Start click (browser autoplay policy); reduced-motion users get the static card-above-
the-slot picture. The card follows the app's SIM silhouette (`sim.svg`): a cut corner
at the top and the contact pad near the bottom edge, sliding behind the reader bar.
### Changed
- A **batch delete that finds the object already gone** is still a success — the goal is met
and the run continues — but it is no longer reported as a plain "OK": the attempt's verdict
reads **Already absent**, the delete run's counts separate real deletions from no-ops
(`Deleted: N · Already absent: M`), and the notice says the card was already absent. The
counts gain an `already_absent` bucket (the attempt result is `already_absent`); a
reinstall's tolerated pre-delete is *not* affected — its outcome is still the install.
- The READMEs link the project's news channel: [Telegram — @youeyeseesee](https://t.me/youeyeseesee).
### Fixed
- The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI
already used but the output lacked (e.g. `bg-blue-50` on the batch notice, `inline-flex`,
`mx-2`, `max-h-48`, `w-44`, `min-w-24`).
## [3.35.0] — 2026-10-23
### Added
+2
View File
@@ -2,6 +2,8 @@
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
+2
View File
@@ -2,6 +2,8 @@
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -324,13 +324,13 @@
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) &mdash; grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version &rarr; REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click &mdash; the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 &mdash; card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) &mdash; a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card &mdash; the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word &mdash; the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports &ldquo;already processed&rdquo; &mdash; restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package &mdash; no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as <strong>Already absent</strong> and counted separately from real deletions &mdash; it is still a success (the goal is met) and the run continues; any other failure is reported with the card's status word.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component &mdash; the point where the JCRE verifies the import list &mdash; then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at &ge; that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 &mdash; if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block &mdash; on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' &mdash; a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
</tbody>
</table>
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or &ldquo;Insert next card. Waiting for a card...&rdquo;) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends &mdash; the run itself is server-side and survives a reload. After a finished run the panel&rsquo;s <strong>Show status</strong> button reopens the report.</p>
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or &ldquo;Insert next card. Waiting for a card...&rdquo;) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends &mdash; the run itself is server-side and survives a reload. After a finished run the panel&rsquo;s <strong>Show status</strong> button reopens the report. While the run waits for a card the modal shows a card-into-reader animation and plays a short chime once per card (the speaker button next to the status mutes it and the choice is remembered); when the window is in the background the tab title asks for the next card.</p>
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
<p class="text-sm mb-2">After &ldquo;Explore Card&rdquo; runs, the explorer displays:</p>
+133 -4
View File
@@ -1918,7 +1918,22 @@
</div>
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
<div id="ram-op-spin" class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<span id="ram-op-insert" class="hidden items-center" aria-hidden="true">
<svg viewBox="0 0 72 48" width="64" height="43" class="shrink-0">
<!-- the card follows the app's SIM silhouette: a cut corner and the contact pad -->
<g class="ram-insert-card text-blue-600 dark:text-blue-400">
<path d="M29.5 0.5 H39.5 L45 6 V24 A2.5 2.5 0 0 1 42.5 26.5 H29.5 A2.5 2.5 0 0 1 27 24 V3 A2.5 2.5 0 0 1 29.5 0.5 Z" fill="currentColor"></path>
<rect x="31.5" y="16.5" width="9" height="8" rx="1.2" fill="#fff" opacity="0.92"></rect>
<path d="M31.5 20.5h9M34.5 16.5v8M37.5 16.5v8" stroke="currentColor" stroke-width="0.55" opacity="0.6"></path>
</g>
<!-- the reader bar and its slot, drawn after the card so it slides behind -->
<rect x="3" y="27" width="66" height="20" rx="4" fill="currentColor" class="text-gray-500 dark:text-slate-400"></rect>
<rect x="8" y="33" width="56" height="5" rx="2.5" fill="currentColor" class="text-gray-900" opacity="0.55"></rect>
<rect class="ram-insert-glow text-amber-500 dark:text-amber-400" x="8" y="33" width="56" height="5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.2"></rect>
</svg>
</span>
<span id="ram-op-status" data-l10n="Working...">Working...</span>
<button type="button" id="ram-op-sound" onclick="ramBatchToggleSound()" class="ml-auto hidden px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700" aria-pressed="true" title="Sound on" aria-label="Sound on">🔊</button>
</div>
<div class="mt-2 w-full bg-gray-200 dark:bg-slate-700 rounded h-1.5">
<div id="ram-op-bar" class="bg-blue-600 dark:bg-blue-500 h-1.5 rounded" style="width:0%"></div>
@@ -1944,7 +1959,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.35.0';
const SIMPLE_VERSION = '3.36.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -10273,6 +10288,8 @@ function ramBatchModalOpen() {
document.getElementById('ram-op-spin').classList.add('hidden');
document.getElementById('ram-op-warn').classList.add('hidden');
document.getElementById('ram-op-batch-info').classList.remove('hidden');
const sound = document.getElementById('ram-op-sound');
if (sound) { sound.classList.remove('hidden'); ramBatchUpdateSoundButton(); }
modal.classList.remove('hidden');
ramBatchRender(); // paint from the current state now
}
@@ -10306,7 +10323,9 @@ function ramBatchModalRender(st, okLabel, actLabel) {
document.getElementById('ram-op-bar').style.width = pct + '%';
// the counts and the last finished card's verdict
const c = st.counts || {};
let info = okLabel + ': ' + (c.installed || 0) + ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
let info = okLabel + ': ' + (c.installed || 0);
if (c.already_absent) info += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
info += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
if (st.attempts_total) info += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
if (st.notice) info += '\n' + st.notice;
@@ -10327,6 +10346,98 @@ function ramBatchModalRender(st, okLabel, actLabel) {
document.getElementById('ram-op-close').disabled = active;
}
// ===== batch "insert the next card" cue (v3.36.0) =====
// The waiting phase is the only moment the operator must act, so it gets a
// card-into-reader animation in the modal, an optional chime (edge-triggered:
// once per card, not on every 2 s poll) and a tab-title hint while the tab is
// hidden. The chime needs an AudioContext unlocked by a gesture: the Start
// click (and the sound toggle) do that; a reloaded page stays silent until the
// next click.
let _ramBatchWaiting = false;
let _ramBatchAudio = null;
let _ramBatchTitleBase = '';
function ramBatchSoundEnabled() {
try { return localStorage.getItem('simple_ram_batch_sound') !== '0'; }
catch (e) { return true; }
}
function ramBatchUpdateSoundButton() {
const btn = document.getElementById('ram-op-sound');
if (!btn) return;
const on = ramBatchSoundEnabled();
btn.textContent = on ? '\u{1F50A}' : '\u{1F507}';
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.title = t(on ? 'Sound on' : 'Sound off');
btn.setAttribute('aria-label', t(on ? 'Sound on' : 'Sound off'));
}
function ramBatchToggleSound() {
try { localStorage.setItem('simple_ram_batch_sound', ramBatchSoundEnabled() ? '0' : '1'); }
catch (e) { /* private mode */ }
ramBatchUpdateSoundButton();
if (ramBatchSoundEnabled()) ramBatchChime(); // a preview when turning it on
}
function ramBatchAudioUnlock() {
try {
if (typeof window === 'undefined') return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
if (!_ramBatchAudio) _ramBatchAudio = new Ctor();
if (_ramBatchAudio.state === 'suspended') _ramBatchAudio.resume();
} catch (e) { _ramBatchAudio = null; }
}
function ramBatchChime() {
if (!ramBatchSoundEnabled()) return;
try {
if (typeof window === 'undefined') return;
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return;
if (!_ramBatchAudio) _ramBatchAudio = new Ctor();
const ctx = _ramBatchAudio;
if (ctx.state === 'suspended') { ctx.resume().catch(() => {}); }
const start = ctx.currentTime + 0.02;
[880, 660].forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const at = start + i * 0.18;
gain.gain.setValueAtTime(0.0001, at);
gain.gain.exponentialRampToValueAtTime(0.18, at + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(at);
osc.stop(at + 0.17);
});
} catch (e) { /* no audio available: the visual cue remains */ }
}
function ramBatchTitleHint(waiting) {
if (!_ramBatchTitleBase) _ramBatchTitleBase = document.title || 'SIMple';
const hint = !!waiting && !!document.hidden;
document.title = hint ? t('Insert next card') + ' \u2014 SIMple' : _ramBatchTitleBase;
}
// Called from ramBatchRender (the 2 s poll): returns true only on the
// transition into the waiting phase, and updates the animation/title.
function ramBatchWaitingCue(waiting) {
const entered = !!waiting && !_ramBatchWaiting;
_ramBatchWaiting = !!waiting;
const ins = document.getElementById('ram-op-insert');
if (ins) {
ins.classList.toggle('hidden', !waiting);
ins.classList.toggle('flex', !!waiting);
}
ramBatchTitleHint(waiting);
return entered;
}
document.addEventListener('visibilitychange', () => ramBatchTitleHint(_ramBatchWaiting));
async function ramBatchFetch() {
if (_ramBatchFetching) return;
_ramBatchFetching = true;
@@ -10378,6 +10489,10 @@ function ramBatchRender() {
const st = ramBatchMergedState();
const active = !!st.active;
const phase = st.phase || (active ? 'waiting_card' : 'idle');
// The waiting cue: the animation in the modal, the chime once per card and
// the tab-title hint (v3.36.0).
const waiting = active && phase !== 'installing' && phase !== 'stopping';
if (ramBatchWaitingCue(waiting)) ramBatchChime();
// The success labels follow the operation (v3.29.0): the counts bucket is
// 'installed' for every kind - "the operation succeeded". The last seen
// kind is remembered for the modal's title and these labels, so a cleared
@@ -10433,8 +10548,9 @@ function ramBatchRender() {
const counts = document.getElementById('ram-batch-counts');
if (counts) {
const c = st.counts || {};
let text = okLabel + ': ' + (c.installed || 0)
+ ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
let text = okLabel + ': ' + (c.installed || 0);
if (c.already_absent) text += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
text += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
if (st.attempts_total) text += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
if (active && _ramBatchProgress && _ramBatchProgress.active && _ramBatchProgress.name) {
@@ -10470,7 +10586,10 @@ function ramBatchAttemptsHtml(attempts) {
for (let i = attempts.length - 1; i >= 0; i--) {
const a = attempts[i];
const when = a.ts ? new Date(a.ts * 1000).toLocaleTimeString() : '';
// A delete that found the object already gone is a success, but not a
// deletion: say so instead of a plain OK (v3.36.0).
const verdict = a.result === 'installed' ? t('OK')
: a.result === 'already_absent' ? t('Already absent')
: (a.result === 'failed' ? t('Failed') : t('Skipped'));
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');
@@ -10526,6 +10645,8 @@ function ramInstallCapFields() {
}
async function ramBatchStart() {
// The click is the gesture the waiting chime needs later.
ramBatchAudioUnlock();
// The operation selects the batch kind (v3.29.0): the .cap install, the
// applet reinstall (no .cap - the package is loaded) or a GP delete. The
// server builds and validates the plan once at Start.
@@ -10634,6 +10755,10 @@ function ramOpModalBegin(title) {
'mt-2 text-xs font-mono whitespace-pre-wrap overflow-auto text-gray-700 dark:text-slate-300';
document.getElementById('ram-op-close').disabled = true;
document.getElementById('ram-op-spin').classList.remove('hidden');
const ins = document.getElementById('ram-op-insert');
if (ins) { ins.classList.add('hidden'); ins.classList.remove('flex'); }
const snd = document.getElementById('ram-op-sound');
if (snd) snd.classList.add('hidden');
document.getElementById('ram-op-warn').classList.remove('hidden');
document.getElementById('ram-op-batch-info').classList.add('hidden');
document.getElementById('ram-op-batch-stop').classList.add('hidden');
@@ -26786,6 +26911,10 @@ const LANG_RU = {
'Reinstalling...': 'Переустановка…',
'Stopping after the current card...': 'Остановка после текущей карты…',
'Insert next card. Waiting for a card...': 'Вставьте следующую карту. Ожидание карты…',
'Insert next card': 'Вставьте следующую карту',
'Already absent': 'Уже отсутствует',
'Sound on': 'Звук включён',
'Sound off': 'Звук выключен',
'Installed': 'Установлено',
'Deleted': 'Удалено',
'Reinstalled': 'Переустановлено',
+25 -1
View File
@@ -4,4 +4,28 @@
.dark .text-heading {
color: #f1f5f9;
}
}
/* RAM batch: the "insert the next card" cue (v3.36.0).
The card slides into the reader slot and back; the slot glows while the
run waits. The static transform is the reduced-motion fallback: the card
rests above the slot. */
.ram-insert-card {
/* the resting position is the card just above the slot (0); the insert
slide takes it 20px down, behind the reader front */
animation: ram-insert 2.8s ease-in-out infinite;
}
@keyframes ram-insert {
0%, 12% { transform: translateY(0); }
45%, 55% { transform: translateY(20px); }
88%, 100% { transform: translateY(0); }
}
.ram-insert-glow {
animation: ram-insert-glow 1.4s ease-in-out infinite;
}
@keyframes ram-insert-glow {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.95; }
}
@media (prefers-reduced-motion: reduce) {
.ram-insert-card, .ram-insert-glow { animation: none; }
}
+97 -10
View File
@@ -616,6 +616,10 @@ video {
pointer-events: none;
}
.\!visible {
visibility: visible !important;
}
.visible {
visibility: visible;
}
@@ -684,6 +688,11 @@ video {
grid-column: span 4 / span 4;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
@@ -807,6 +816,10 @@ video {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
@@ -843,6 +856,10 @@ video {
max-height: 8rem;
}
.max-h-48 {
max-height: 12rem;
}
.max-h-56 {
max-height: 14rem;
}
@@ -935,6 +952,10 @@ video {
width: 10rem;
}
.w-44 {
width: 11rem;
}
.w-48 {
width: 12rem;
}
@@ -980,6 +1001,10 @@ video {
min-width: 0px;
}
.min-w-24 {
min-width: 6rem;
}
.min-w-\[12rem\] {
min-width: 12rem;
}
@@ -1395,6 +1420,11 @@ video {
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
}
.bg-blue-50 {
--tw-bg-opacity: 1;
background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
}
.bg-blue-50\/50 {
background-color: rgb(239 246 255 / 0.5);
}
@@ -1796,6 +1826,11 @@ video {
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
.text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
@@ -1893,10 +1928,61 @@ video {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.dark .text-heading {
color: #f1f5f9;
}
/* RAM batch: the "insert the next card" cue (v3.36.0).
The card slides into the reader slot and back; the slot glows while the
run waits. The static transform is the reduced-motion fallback: the card
rests above the slot. */
.ram-insert-card {
/* the resting position is the card just above the slot (0); the insert
slide takes it 20px down, behind the reader front */
animation: ram-insert 2.8s ease-in-out infinite;
}
@keyframes ram-insert {
0%, 12% {
transform: translateY(0);
}
45%, 55% {
transform: translateY(20px);
}
88%, 100% {
transform: translateY(0);
}
}
.ram-insert-glow {
animation: ram-insert-glow 1.4s ease-in-out infinite;
}
@keyframes ram-insert-glow {
0%, 100% {
opacity: 0.35;
}
50% {
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.ram-insert-card, .ram-insert-glow {
animation: none;
}
}
.hover\:bg-amber-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
@@ -1956,11 +2042,6 @@ video {
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
}
.hover\:bg-red-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
@@ -2093,6 +2174,10 @@ video {
background-color: rgb(30 58 138 / 0.1);
}
.dark\:bg-blue-900\/20:is(.dark *) {
background-color: rgb(30 58 138 / 0.2);
}
.dark\:bg-blue-900\/40:is(.dark *) {
background-color: rgb(30 58 138 / 0.4);
}
@@ -2258,10 +2343,6 @@ video {
background-color: rgb(6 78 59 / 0.4);
}
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.4);
}
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
background-color: rgb(127 29 29 / 0.5);
}
@@ -2289,6 +2370,11 @@ video {
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:hover\:text-slate-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
.dark\:hover\:text-slate-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
@@ -2328,7 +2414,8 @@ video {
.lg\:flex-row {
flex-direction: row;
}
}/* ===== Theme contrast adjustments ===== */
}
/* ===== Theme contrast adjustments ===== */
/* Light theme: darken softer (muted) text one step for higher contrast */
.text-gray-400 {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v469';
const CACHE = 'simple-v473';
const URLS = [
'index.html',
'help.html',
+126 -4
View File
@@ -28,7 +28,10 @@ for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
'ramBatchStepsText',
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
'ramOpModalClose', 'esc']) {
'ramOpModalClose', 'esc',
'ramBatchSoundEnabled', 'ramBatchUpdateSoundButton', 'ramBatchToggleSound',
'ramBatchAudioUnlock', 'ramBatchChime', 'ramBatchTitleHint',
'ramBatchWaitingCue']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
@@ -39,7 +42,9 @@ const els = {};
function setEl(id, value) {
els[id] = {
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
disabled: false, style: {},
disabled: false, style: {}, title: '', attrs: {},
setAttribute(k, v) { els[id].attrs[k] = String(v); },
getAttribute(k) { return els[id].attrs[k]; },
classes: {},
classList: {
add(c) { els[id].classes[c] = true; },
@@ -64,7 +69,8 @@ function clearEls() {
function setModalEls() {
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear']) setEl(id, '');
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear',
'ram-op-insert', 'ram-op-sound']) setEl(id, '');
}
function setPanelEls() {
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
@@ -75,7 +81,17 @@ function setPanelEls() {
const app = new Function('els', `
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
var document = { getElementById: id => els[id] || null };
var document = {
getElementById: id => els[id] || null,
hidden: false, title: 'SIMple',
addEventListener: () => {},
};
var _ls = {};
var localStorage = {
getItem: k => (k in _ls ? _ls[k] : null),
setItem: (k, v) => { _ls[k] = String(v); },
removeItem: k => { delete _ls[k]; },
};
var t = k => k;
var isViewVisible = () => sandbox.visibility;
var alert = m => sandbox.alerts.push(String(m));
@@ -109,6 +125,9 @@ const app = new Function('els', `
var _ramBatchFetchAt = 0;
var _ramBatchSummary = null;
var _ramBatchProgress = null;
var _ramBatchWaiting = false;
var _ramBatchAudio = null;
var _ramBatchTitleBase = '';
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
${code}
sandbox.getLast = () => _ramBatchLast;
@@ -134,6 +153,16 @@ const app = new Function('els', `
sandbox.ramBatchStart = ramBatchStart;
sandbox.ramBatchStop = ramBatchStop;
sandbox.ramBatchClear = ramBatchClear;
sandbox.ramBatchWaitingCue = ramBatchWaitingCue;
sandbox.ramBatchTitleHint = ramBatchTitleHint;
sandbox.ramBatchSoundEnabled = ramBatchSoundEnabled;
sandbox.ramBatchUpdateSoundButton = ramBatchUpdateSoundButton;
sandbox.ramBatchToggleSound = ramBatchToggleSound;
sandbox.setHidden = v => { document.hidden = !!v; };
sandbox.getTitle = () => document.title;
sandbox.resetWaiting = () => { _ramBatchWaiting = false; };
sandbox.storage = _ls;
sandbox.resetStorage = () => { for (const k of Object.keys(_ls)) delete _ls[k]; };
return sandbox;
`)(els);
@@ -639,3 +668,96 @@ test('the stop and clear calls hit their endpoints', async () => {
await new Promise(r => setImmediate(r));
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
});
test('the waiting cue fires once per card and drives the insert animation (v3.36.0)', () => {
clearEls();
setPanelEls();
setModalEls();
app.resetWaiting();
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'entering waiting cues');
assert.strictEqual(app.ramBatchWaitingCue(true), false, 'still waiting: no repeat');
assert.strictEqual(app.ramBatchWaitingCue(false), false, 'installing resets');
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'the next card cues again');
assert.ok(els['ram-op-insert'].classes['flex'] === true);
assert.ok(!els['ram-op-insert'].classes['hidden']);
app.ramBatchWaitingCue(false);
assert.ok(els['ram-op-insert'].classes['hidden']);
// the render drives it from the phase
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
app.ramBatchRender();
assert.ok(!els['ram-op-insert'].classes['hidden']);
app.setLast({ active: true, phase: 'installing', current: { iccid: '8970119000004600098' },
attempts: [], counts: {}, attempts_total: 1 });
app.ramBatchRender();
assert.ok(els['ram-op-insert'].classes['hidden']);
});
test('the sound toggle persists and updates the button (v3.36.0)', () => {
clearEls();
setModalEls();
app.resetStorage();
assert.strictEqual(app.ramBatchSoundEnabled(), true, 'sound is on by default');
app.ramBatchUpdateSoundButton();
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F50A}');
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'true');
app.ramBatchToggleSound();
assert.strictEqual(app.ramBatchSoundEnabled(), false);
assert.strictEqual(app.storage['simple_ram_batch_sound'], '0');
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F507}');
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'false');
app.ramBatchToggleSound();
assert.strictEqual(app.ramBatchSoundEnabled(), true);
});
test('the tab title hints while the run waits and the tab is hidden (v3.36.0)', () => {
app.setHidden(false);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'SIMple');
app.setHidden(true);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'Insert next card \u2014 SIMple');
app.setHidden(false);
app.ramBatchTitleHint(true);
assert.strictEqual(app.getTitle(), 'SIMple');
});
test('the waiting cue is wired into the poll render, the Start gesture and the CSS (v3.36.0)', () => {
const render = extractFunc(html, 'ramBatchRender');
assert.match(render, /const waiting = active && phase !== 'installing' && phase !== 'stopping';/);
assert.match(render, /if \(ramBatchWaitingCue\(waiting\)\) ramBatchChime\(\);/);
assert.match(extractFunc(html, 'ramBatchStart'), /ramBatchAudioUnlock\(\)/);
assert.match(html, /id="ram-op-insert"/);
assert.match(html, /id="ram-op-sound"/);
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
assert.match(css, /\.ram-insert-card/);
assert.match(css, /@keyframes ram-insert/);
assert.match(css, /\.ram-insert-glow/);
assert.match(css, /prefers-reduced-motion: reduce/);
});
test('an already-absent delete is reported as such, not as a deletion (v3.36.0)', () => {
clearEls();
setPanelEls();
setModalEls();
app.resetBatchModal();
app.setLast({ active: false, phase: 'finished', kind: 'delete',
attempts: [{
iccid: '8970119000000115026', preset: 'Alfa', result: 'already_absent',
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true }],
steps_total: 1, duration_ms: 1200,
}],
counts: { installed: 2, already_absent: 1, failed: 0, skipped: 0 },
attempts_total: 3, notice: 'Card 8970119000000115026 was already absent.' });
app.ramBatchRender();
// the counts separate a real deletion from a no-op
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
assert.match(els['ram-batch-counts'].textContent, /Already absent: 1/);
// the attempt's verdict says it - not a plain OK - and keeps the step line
assert.match(els['ram-batch-attempts'].innerHTML, /Already absent/);
assert.match(els['ram-batch-attempts'].innerHTML, /6A88 \(not found\)/);
// the modal carries the same counts
app.ramBatchModalOpen();
assert.match(els['ram-op-batch-info'].textContent, /Deleted: 2/);
assert.match(els['ram-op-batch-info'].textContent, /Already absent: 1/);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.35.0"
version = "3.36.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9"
+16 -4
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.35.0'
VERSION = '3.36.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -2914,7 +2914,7 @@ def _ram_batch_new_state(plan):
return {'active': True, 'phase': 'waiting_card', 'stop': False,
'started': time.time(), 'stopped': None, 'plan': plan,
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'current': None, 'notice': ''}
@@ -2939,6 +2939,8 @@ def _ram_batch_record(state, rec):
verb = {'delete': 'deleted',
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
state['notice'] = 'Card %s %s.' % (who, verb)
elif result == 'already_absent':
state['notice'] = 'Card %s was already absent.' % who
elif result == 'failed':
step = rec.get('failed_step') or ''
sw = rec.get('por_sw') or rec.get('sw') or ''
@@ -2947,8 +2949,9 @@ def _ram_batch_record(state, rec):
else:
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
detail = rec.get('reason') or rec.get('failed_step') or ''
label = {'already_absent': 'already absent'}.get(result, result)
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
label, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
def _ram_batch_failure_fields(result):
@@ -3044,6 +3047,15 @@ def _ram_batch_handle_card(state, server):
'module_aid': result.get('module_aid'),
'nv_delta': result.get('nv_delta')}
rec.update(_ram_batch_steps_fields(result))
# A delete run whose DELETE answered 'not found' is a success (the
# object is gone, the goal is met) but not a deletion: report it as
# "already absent" and count it separately (v3.36.0). Only the delete
# kind: a reinstall's tolerated pre-delete is not the outcome.
if (state.get('plan') or {}).get('kind') == 'delete' and any(
s.get('delete_not_found') for s in (result.get('steps') or [])):
outcome = 'already_absent'
rec['result'] = outcome
rec['not_found'] = True
elif result is not None:
outcome = 'failed'
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
@@ -3178,7 +3190,7 @@ def _ram_batch_status():
if _RAM_BATCH is None:
return {'active': False, 'phase': 'idle', 'attempts': [],
'attempts_total': 0, 'current': None, 'notice': '',
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
'started': None, 'stopped': None, 'progress': None,
'kind': None}
return {'active': bool(_RAM_BATCH.get('active')),
+45 -1
View File
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
'reason': 'no preset matches this ICCID'})
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1})
self.assertEqual(st['counts'], {'installed': 1, 'already_absent': 0,
'failed': 1, 'skipped': 1})
self.assertEqual(st['attempts_total'], 3)
# the notice names the last attempt's outcome
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
@@ -230,6 +231,49 @@ class BatchCardTests(unittest.TestCase):
self.assertEqual(seen['current']['iccid'], '8970119000004600098')
self.assertIn('installed', st['notice']) # ...and set for the new outcome
def test_a_delete_that_finds_nothing_is_reported_already_absent(self):
"""A delete run whose DELETE answered 6A88 is a success (the object is
gone, the goal is met) but not a deletion: it is counted in its own
bucket and the notice says so (v3.36.0)."""
st = srv._ram_batch_new_state(_plan('delete'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True, 'final_cntr': '0000000002',
'steps': [{'name': 'DELETE (application)', 'sw': '9000',
'por_status': 'por_ok', 'por_sw': '6A88',
'delete_not_found': True}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
rec = st['attempts'][0]
self.assertEqual(rec['result'], 'already_absent')
self.assertTrue(rec['not_found'])
self.assertEqual(st['counts']['already_absent'], 1)
self.assertEqual(st['counts']['installed'], 0)
self.assertIn('already absent', st['notice'])
self.assertEqual(st['seen']['8970119000004600098'], 'already_absent')
def test_a_reinstall_with_a_tolerated_pre_delete_is_still_an_install(self):
"""The reinstall's pre-delete may tolerate 'not found', but the
outcome is the INSTALL, not 'already absent'."""
st = srv._ram_batch_new_state(_plan('reinstall-app'))
preset = _preset('8970119000004600098', 'Alfa')
def fake_run(server, body, plan, scc):
return {'success': True,
'steps': [{'name': 'DELETE (application)', 'por_sw': '6A88',
'delete_not_found': True},
{'name': 'INSTALL [for install]', 'por_sw': '9000'}]}
with mock.patch.object(srv, '_ram_plan_run', fake_run):
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
_FakeStore([preset])))
self.assertEqual(st['attempts'][0]['result'], 'installed')
self.assertEqual(st['counts']['installed'], 1)
self.assertEqual(st['counts']['already_absent'], 0)
def test_a_card_already_processed_is_reported_not_reinstalled(self):
st = srv._ram_batch_new_state(_plan())
preset = _preset('8970119000004600098', 'Alfa')