v3.19.0 (review fixes): expose the menu-select Match case choice; doc/comment fixes

Review of 7250dbd:
- the step editor's menu-select form gains a Match case select
  (Case-insensitive (default) / Case-sensitive); the step summary flags a
  case-sensitive step with (case), the saved params carry
  case_sensitive: true only when chosen, and the docs say the editor
  authors the flag.
- docs/api.md: fix the 'with an optionally' grammar.
- the Variant-2 menu test's comment claimed a contains match while it is an
  exact one; a real contains leg is added ('ыбор' -> item 128).
- timeless rationale for the case-insensitive default; the format note now
  dates the change.
sw cache simple-v389 (index.html/help changed).
Tests: 821 frontend + 726 Python.
This commit is contained in:
2026-10-04 21:18:36 +03:00
parent 7250dbd63a
commit 4a353720d0
9 changed files with 88 additions and 13 deletions
+2 -3
View File
@@ -670,9 +670,8 @@ callers.
optional `src` - the device-identities source override),
`event` (`event` name or hex + `fields`, optional `src` - the semantic event
download for 0x03/0x0B/0x12/0x1D), `menu-select` (`item_id` 1-255, or `text` +
`mode` `exact`/`contains` with an
optionally `case_sensitive: true` for an exact-case match, case-insensitive by
default - the text is resolved against the card's cached
`mode` `exact`/`contains` (case-insensitive by default; `case_sensitive: true`
forces an exact-case match) - the text is resolved against the card's cached
SET UP MENU, which is refreshed whenever the card sends the menu), `file-write`
(`path`, `data`, `mode` `auto`/`binary`/`record`, `record`), `file-read` (same,
verifies `check.data`),
+3 -2
View File
@@ -16,7 +16,8 @@ localStorage export imports as-is), and any API client can create or run them
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0, the
`envelope` action's `src` override since 3.18.1, the inline `por` object, the
`alpha`/`sms` checks and `scp80.params.format` since 3.19.0.
`alpha`/`sms` checks, `scp80.params.format` and the case-insensitive
`menu-select` text match since 3.19.0.
## Writing a test for an applet
@@ -177,7 +178,7 @@ continue correctly.
|---|---|---|
| `envelope` | `event` 0–255, `data` hex (optional), optional `src` | ENVELOPE(Event Download) with raw data; `src` overrides the device-identities source (`82` terminal / `83` network) |
| `event` | `event` (name or hex), `fields` (object), optional `src` | semantic ENVELOPE(Event Download) for 0x03/0x0B/0x12/0x1D (see below) |
| `menu-select` | `item_id` 1–255 **or** `text` + `mode` `exact`/`contains` (+ `case_sensitive: true` for an exact-case match — the default is case-insensitive), not both | ENVELOPE(Menu Selection); the text is resolved against the card's cached menu - refreshed whenever the card sends SET UP MENU, so it follows the applet's install parameters |
| `menu-select` | `item_id` 1–255 **or** `text` + `mode` `exact`/`contains` (+ `case_sensitive: true` for an exact-case match — the default is case-insensitive; the step editor's **Match case** choice writes it), not both | ENVELOPE(Menu Selection); the text is resolved against the card's cached menu - refreshed whenever the card sends SET UP MENU, so it follows the applet's install parameters |
| `file-write` | `path`, `data`, `mode` `auto`/`binary`/`record`, `record` 1–255 | UPDATE BINARY/RECORD |
| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
| `apdu` | `apdu` hex | raw transport, no auto-handler |
+1 -1
View File
@@ -563,7 +563,7 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> &mdash; упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP&nbsp;409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия <strong>Очистить отчёт</strong>. Скрипты хранятся в <strong>хранилище на сервере</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки &laquo;Сохранить&raquo; нет &mdash; черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li>
<li><strong>Действия</strong> &mdash; <em>Envelope</em> (Event Download: сырой тип события + данные, с опциональным переопределением источника &mdash; 82 терминал / 83 сеть) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change &mdash; поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1&ndash;255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра &mdash; выбор <em>«Учёт регистра»</em> в редакторе шагов включает точное совпадение; текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды &mdash; компактный или обёрнутый в шаблон Command Scripting TS 102 226 &sect;5.2.1 <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em>; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты &mdash; запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик &mdash; а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> &gt; 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> &mdash; <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em> или своя проверка декодированного PoR &mdash; статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).</li>
<li><strong>Ожидания</strong> &mdash; карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); <code class="font-mono text-sm">9000</code> означает &laquo;команды нет&raquo; и считается ошибкой &mdash; добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <em>Alpha</em> (идентификатор Alpha команды, тег <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; отличается от текстовой строки <code class="font-mono text-sm">8D</code>), проверка <em>SMS TPDU</em> (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные &mdash; для собственного SMS апплета), проверка <em>PoR</em> (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 <code class="font-mono text-sm">20</code>: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка <em>Files</em> (File List команды REFRESH, например квалификатор <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). <strong>TERMINAL RESPONSE</strong> (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.</li>
<li><strong>Уровни ошибок</strong> &mdash; несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.</li>
+1 -1
View File
@@ -562,7 +562,7 @@
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
<p class="text-sm mb-2">A <strong>test script</strong> is an ordered list of <strong>actions</strong> and proactive-command <strong>expectations</strong> executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP&nbsp;409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until <strong>Clear report</strong>. Scripts live in the <strong>server-side store</strong> (<code class="font-mono text-sm">~/.pysim-simple-server/test_scripts.json</code>, <code class="font-mono text-sm">--test-scripts</code>), so every browser manages the same set; edits are written to the store as you change them (there is no Save button &mdash; a draft without a valid step stays local until it is valid); JSON export/import remains for backup and for moving scripts between benches (the old localStorage export imports as-is).</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
<li><strong>Actions</strong> &mdash; <em>Envelope</em> (Event Download: raw event type + data, with an optional source override &mdash; 82 terminal / 83 network) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change &mdash; the fields are edited as a JSON object in the step editor, and the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (item id 1&ndash;255, or the item's text with an exact/contains match, case-insensitive by default &mdash; the step editor's <em>Match case</em> choice switches to an exact-case match; the text is resolved against the card's menu, which follows the applet's install parameters), <em>UPDATE/READ file</em> (path + mode; reads verify the file data), <em>raw APDU</em> (no auto-handler), <em>SCP80</em> (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format &mdash; compact, or the TS 102 226 &sect;5.2.1 Command Scripting template <em>expanded</em> (AA) / <em>expanded AE&nbsp;80</em> around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID &mdash; the run names the stored preset, so it always uses its keysets, TARs and counter &mdash; and the counter is written back to it after the run) and <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with <code class="font-mono text-sm">?</code> nibble wildcards &mdash; <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>, or a custom assertion of the decoded PoR &mdash; status / SW / data; the SEND SHORT MESSAGE transport works the same).</li>
<li><strong>Expectations</strong> &mdash; the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 &sect;7.4.2.1 / TS 102 223 &sect;6.3); a <code class="font-mono text-sm">9000</code> means &ldquo;no command&rdquo; and is an error &mdash; add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an <em>Alpha</em> check (the command's Alpha identifier, tag <code class="font-mono text-sm">05</code>/<code class="font-mono text-sm">85</code> &mdash; distinct from the <code class="font-mono text-sm">8D</code> text string), an <em>SMS TPDU</em> check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data &mdash; for an applet's own SMS), a <em>PoR</em> check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit <code class="font-mono text-sm">20</code>: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a <em>Files</em> check (the File List of a REFRESH, e.g. qualifier <code class="font-mono text-sm">00</code> = NAA initialization with full file change notification). The scripted <strong>TERMINAL RESPONSE</strong> (result, item identifier, text, extra TLVs) is part of the instruction.</li>
<li><strong>Fail levels</strong> &mdash; a mismatch flags the step as <em>Error</em> (terminates the script) or <em>Warning</em> (continues); each check is reported with expected vs actual values.</li>
<li><strong>Templates</strong> &mdash; Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.</li>
+11 -1
View File
@@ -7094,7 +7094,7 @@ function testStepSummary(step) {
if (step.kind === 'envelope') s = 'ENVELOPE event=0x' + Number(p.event || 0).toString(16).toUpperCase().padStart(2, '0') + (p.data ? ' data=' + p.data : '');
else if (step.kind === 'menu-select') {
s = 'ENVELOPE(Menu Selection) ' + (p.text
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text)
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text) + (p.case_sensitive ? ' (case)' : '')
: 'item=' + p.item_id);
}
else if (step.kind === 'event') {
@@ -7391,6 +7391,10 @@ function testStepRender() {
'<div class="flex gap-2">' + testFormInput('test-f-itemtext', p.text, t('menu item text'))
+ testFormSelect('test-f-itemmode', p.mode || 'exact',
[{v: 'exact', l: t('Exact')}, {v: 'contains', l: t('Contains')}]) + '</div>');
html += testFormRow('Match case', testFormSelect('test-f-itemcase',
p.case_sensitive ? 'true' : 'false',
[{v: 'false', l: t('Case-insensitive (default)')},
{v: 'true', l: t('Case-sensitive')}]));
} else if (step.kind === 'file-write' || step.kind === 'file-read') {
html += testFormRow('File path', testFormInput('test-f-path', p.path, 'ADF.USIM/EF.EST or MF/2FE2'));
html += testFormRow('Mode', testFormSelect('test-f-mode', p.mode || 'auto', [
@@ -7623,6 +7627,9 @@ function testStepCollect() {
if (text) {
p.text = text;
p.mode = val('test-f-itemmode') || 'exact';
// the editor authors the flag: true keeps an exact-case
// match (the server default is case-insensitive)
if (val('test-f-itemcase') === 'true') p.case_sensitive = true;
} else {
const id = num('test-f-item', 0);
if (id) p.item_id = id;
@@ -21141,6 +21148,9 @@ const LANG_RU = {
'give the item id or the text, not both': 'укажите item id или текст, но не оба',
'… or item text': '… или текст пункта',
'menu item text': 'текст пункта меню',
'Match case': 'Учёт регистра',
'Case-insensitive (default)': 'Без учёта регистра (по умолчанию)',
'Case-sensitive': 'С учётом регистра',
'file path is empty': 'путь к файлу пуст',
'command is required': 'команда обязательна',
'step must be an action or an expectation': 'шаг должен быть действием или ожиданием',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v388';
const CACHE = 'simple-v389';
const URLS = [
'index.html',
'help.html',
+55
View File
@@ -303,6 +303,61 @@ test('the menu-select editor collects the item id or the text', () => {
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
});
test('the menu-select editor authors the case-sensitive match', () => {
// the select stores the flag only when set (the server default is
// case-insensitive), so a step can keep an exact-case match
_testEditStep = { type: 'action', kind: 'menu-select',
params: { text: 'My menu', mode: 'contains' } };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
'test-f-itemmode': 'contains', 'test-f-itemcase': 'true',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params,
{ text: 'My menu', mode: 'contains', case_sensitive: true });
// the default is omitted from the stored params
_testEditStep = { type: 'action', kind: 'menu-select',
params: { text: 'My menu', mode: 'contains' } };
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '', 'test-f-itemtext': 'My menu',
'test-f-itemmode': 'contains', 'test-f-itemcase': 'false',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params, { text: 'My menu', mode: 'contains' });
// the summary flags it
assert.ok(testStepSummary({ type: 'action', kind: 'menu-select',
params: { text: 'My menu', case_sensitive: true } }).includes(' (case)'));
// switching to the item id drops it (there is no text match then)
_testEditStep = { type: 'action', kind: 'menu-select',
params: { text: 'My menu', case_sensitive: true } };
fakeForm({ 'test-step-kind': 'menu-select', 'test-f-item': '3', 'test-f-itemtext': '',
'test-f-itemmode': 'exact', 'test-f-itemcase': 'true',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
});
test('the menu-select step form renders the case select', () => {
const els = {
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
'test-step-title': {}, 'test-step-body': {},
'test-step-error': { classList: { add: () => {}, remove: () => {} } },
};
globalThis.document = { getElementById: id => els[id] || null };
_testEditStep = { type: 'action', kind: 'menu-select',
params: { text: 'My menu', case_sensitive: true } };
_testEditChecks = [];
_testEditStepIndex = 0;
testStepRender();
const body = els['test-step-body'].innerHTML;
assert.match(body, /id="test-f-itemcase"/);
assert.match(body, /<option value="true" selected>/);
// the default selection is case-insensitive
_testEditStep = { type: 'action', kind: 'menu-select', params: { text: 'My menu' } };
testStepRender();
assert.match(els['test-step-body'].innerHTML, /<option value="false" selected>/);
});
test('the envelope step collects the source override', () => {
_testEditStep = { type: 'action', kind: 'envelope', params: {} };
_testEditChecks = [];
+3 -3
View File
@@ -281,9 +281,9 @@ def _normalise_params(kind, p):
if mode not in ('exact', 'contains'):
raise ScriptError('menu-select: mode must be exact or contains')
out['mode'] = mode
# Menu labels are UI text: case-insensitive by default (no test
# scripts predate this choice); `case_sensitive: true` forces an
# exact-case match.
# Menu labels are UI text: a case slip should not fail a run, so
# the match is case-insensitive by default; `case_sensitive: true`
# forces an exact-case match.
out['case_sensitive'] = bool(p.get('case_sensitive', False))
if not out:
raise ScriptError('menu-select: item_id or text is required')
+11 -1
View File
@@ -588,16 +588,26 @@ class TestRunnerActions(RunnerTestCase):
{'id': 130, 'text': 'Кофе'}]}
def test_menu_text_selection_matches_variant2_cyrillic(self):
# a lowercase exact match resolves by default (case-insensitive)
scc = FakeScc().push('80C2', '', '9000')
server = FakeServer(scc)
server.sim_menu = dict(self.LIVE_MENU)
# a lowercase contains match resolves by default (case-insensitive)
run = self.run_script(server, [
{'type': 'action', 'kind': 'menu-select',
'params': {'text': 'кофе'}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertIn('D30702020181900182', ''.join(scc.sent))
# a lowercase contains match resolves too (item 128)
scc = FakeScc().push('80C2', '', '9000')
server = FakeServer(scc)
server.sim_menu = dict(self.LIVE_MENU)
run = self.run_script(server, [
{'type': 'action', 'kind': 'menu-select',
'params': {'text': 'ыбор', 'mode': 'contains'}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertIn('D30702020181900180', ''.join(scc.sent))
# an explicit case-sensitive spec misses and names the loose match
server = FakeServer(FakeScc())
server.sim_menu = dict(self.LIVE_MENU)