From 7250dbd63a951ad15af981bca31da99902c23c8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Sun, 4 Oct 2026 21:05:19 +0300
Subject: [PATCH] v3.19.0 (cont.): live fixtures; menu text match is
case-insensitive by default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No test scripts have been authored yet, so the format is still free to
change - the menu-select text match is now case-insensitive by default
(menu labels are UI text; case_sensitive: true still forces an exact-case
match, and a miss on it names the case-insensitive candidates).
Verification against the live card's own bytes (captured 2026-10-04):
- the Annex A Variant 2 Cyrillic menu (ids 128..130) resolves by text and
the case behaviour above is pinned ('кофе' -> item 130);
- the applet's SEND SHORT MESSAGE (alpha 'Отправка...', SMS-SUBMIT to
+79332505884) pins the new alpha and sms checks and the run-log line.
docs/help/api updated; sw cache simple-v388 (help pages changed).
Tests: 819 frontend + 726 Python.
---
docs/api.md | 3 +-
docs/test-scripts.md | 11 +++--
frontend/help-ru.html | 2 +-
frontend/help.html | 2 +-
frontend/sw.js | 2 +-
pysim_simple_server/server.py | 20 +++++++--
pysim_simple_server/testscript.py | 5 ++-
tests/test_testscript.py | 73 ++++++++++++++++++++++++++++++-
8 files changed, 105 insertions(+), 13 deletions(-)
diff --git a/docs/api.md b/docs/api.md
index 5bdb4a0..78e0d08 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -671,7 +671,8 @@ 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
-optional `case_sensitive` - the text is resolved against the card's cached
+optionally `case_sensitive: true` for an exact-case match, case-insensitive by
+default - 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`),
diff --git a/docs/test-scripts.md b/docs/test-scripts.md
index 4bd7b6d..b32c3b3 100644
--- a/docs/test-scripts.md
+++ b/docs/test-scripts.md
@@ -177,7 +177,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`), 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), 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 |
@@ -362,8 +362,10 @@ then fetches.
]}
```
-The text is matched against the card's cached menu; the cache is refreshed
-whenever the card sends SET UP MENU (e.g. after an install + REFRESH). If the
+The text is matched against the card's cached menu, **case-insensitively by
+default** (`case_sensitive: true` forces an exact-case match); the cache is
+refreshed whenever the card sends SET UP MENU (e.g. after an install +
+REFRESH). If the
script must wait for the menu, add a `status` action and an
`expect SET UP MENU` before the selection.
@@ -427,6 +429,9 @@ PoR-in-submit (with the transport asserted by the expectation):
has no entry in the preset and the step carries no `spi1`.
- `no proactive command pending …` - an expectation ran without a `91XX` from
the previous step (add a `status` action when the card delivers on poll).
+- `menu-select: no menu item matches …` - the error lists the cached menu; a
+ miss on an explicit `case_sensitive: true` names the case-insensitive
+ candidates.
- `card verdict: no PoR (the card sent none for SPI2 …)` - an SCP80 operation
expected a PoR that never arrived (check SPI2 and the transport).
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 21cd1a3..34aa590 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -563,7 +563,7 @@
8.9 Тест-скрипт
Тест-скрипт — упорядоченный список действий и ожиданий проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 409), фоновый опрос STATUS приостанавливается; отчёт о запуске хранится в памяти (в том числе после перезагрузки страницы) до нажатия Очистить отчёт. Скрипты хранятся в хранилище на сервере (~/.pysim-simple-server/test_scripts.json, --test-scripts), поэтому все браузеры работают с одним набором; изменения записываются в хранилище по мере правки (кнопки «Сохранить» нет — черновик без валидного шага остаётся локальным); экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).
- - Действия — Envelope (Event Download: сырой тип события + данные, с опциональным переопределением источника — 82 терминал / 83 сеть) или Event (семантические формы: Location status, Access technology change, Network rejection, Data connection status change — поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), Menu Selection (item id 1–255 или текст пункта с точным/частичным совпадением — текст подбирается по меню карты, которое следует за параметрами установки апплета), UPDATE/READ файла (путь + режим; чтение проверяет данные файла), сырой APDU (без авто-обработчика), SCP80 (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды — компактный или обёрнутый в шаблон Command Scripting TS 102 226 §5.2.1 expanded (AA) / expanded AE 80; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и STATUS (при попытках > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой
? — 91??) и проверка PoR для SCP80 (любой / без PoR / por_ok или своя проверка декодированного PoR — статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).
+ - Действия — Envelope (Event Download: сырой тип события + данные, с опциональным переопределением источника — 82 терминал / 83 сеть) или Event (семантические формы: Location status, Access technology change, Network rejection, Data connection status change — поля редактируются как объект JSON в редакторе шагов, а для остальных событий форма события на вкладке «Телефон» показывает готовый hex), Menu Selection (item id 1–255 или текст пункта с точным/частичным совпадением, по умолчанию без учёта регистра — текст подбирается по меню карты, которое следует за параметрами установки апплета), UPDATE/READ файла (путь + режим; чтение проверяет данные файла), сырой APDU (без авто-обработчика), SCP80 (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2 и формат команды — компактный или обёрнутый в шаблон Command Scripting TS 102 226 §5.2.1 expanded (AA) / expanded AE 80; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и STATUS (при попытках > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой
? — 91??) и проверка PoR для SCP80 (любой / без PoR / por_ok или своя проверка декодированного PoR — статус / SW / данные; транспорт через SEND SHORT MESSAGE работает так же).
- Ожидания — карта должна объявить проактивную команду на предыдущем шаге (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); 9000 означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка Alpha (идентификатор Alpha команды, тег 05/85 — отличается от текстовой строки 8D), проверка SMS TPDU (submit-TPDU команды SEND SHORT MESSAGE: получатель / PID / DCS / UDL / данные — для собственного SMS апплета), проверка PoR (SEND SHORT MESSAGE несёт PoR, когда пакет запросил PoR-in-submit, бит SPI2 20: статус ответа, SW R-APDU и данные ответа; перед этим должен выполниться шаг SCP80) и проверка Files (File List команды REFRESH, например квалификатор 00 = NAA initialization with full file change notification). TERMINAL RESPONSE (результат, идентификатор пункта, текст, доп. TLV) является частью инструкции.
- Уровни ошибок — несовпадение помечает шаг как Ошибка (скрипт останавливается) или Предупреждение (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.
- Шаблоны — Empty, Обзор меню STK, Апплет через SCP80; редактор шагов — формный (поля действия Event вводятся объектом JSON), отдельные скрипты экспортируются/импортируются как JSON.
diff --git a/frontend/help.html b/frontend/help.html
index 5b663e4..12385fb 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -562,7 +562,7 @@
8.9 Test script
A test script is an ordered list of actions and proactive-command expectations executed server-side on the equipped card. While a run is active the card belongs to the script: other card actions are refused (HTTP 409) and background STATUS polling is paused; the run report is kept in memory (also after reloading the page) until Clear report. Scripts live in the server-side store (~/.pysim-simple-server/test_scripts.json, --test-scripts), so every browser manages the same set; edits are written to the store as you change them (there is no Save button — 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).
- - Actions — Envelope (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or Event (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change — 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), Menu Selection (item id 1–255, or the item's text with an exact/contains match — the text is resolved against the card's menu, which follows the applet's install parameters), UPDATE/READ file (path + mode; reads verify the file data), raw APDU (no auto-handler), SCP80 (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template expanded (AA) / expanded AE 80 around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and STATUS (with attempts > 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with
? nibble wildcards — 91??) plus a PoR check for SCP80 (any / no PoR / por_ok, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).
+ - Actions — Envelope (Event Download: raw event type + data, with an optional source override — 82 terminal / 83 network) or Event (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change — 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), Menu Selection (item id 1–255, or the item's text with an exact/contains match, case-insensitive by default — the text is resolved against the card's menu, which follows the applet's install parameters), UPDATE/READ file (path + mode; reads verify the file data), raw APDU (no auto-handler), SCP80 (C-APDU or a pre-built secured packet, optional keyset number (kvn) / TAR / SPI1 / SPI2 overrides and a command format — compact, or the TS 102 226 §5.2.1 Command Scripting template expanded (AA) / expanded AE 80 around the C-APDU; the keys and the counter come from the card preset matched by the equipped card's ICCID — the run names the stored preset, so it always uses its keysets, TARs and counter — and the counter is written back to it after the run) and STATUS (with attempts > 1 it polls until the card announces a command). Every action has a response check: expected SW and data (exact, or a mask with
? nibble wildcards — 91??) plus a PoR check for SCP80 (any / no PoR / por_ok, or a custom assertion of the decoded PoR — status / SW / data; the SEND SHORT MESSAGE transport works the same).
- Expectations — the card must have announced a proactive command in the previous step (SW
91XX, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a 9000 means “no command” and is an error — add a STATUS action when the applet delivers on poll. The command type, qualifier and contents are verified: text / item / raw checks, an Alpha check (the command's Alpha identifier, tag 05/85 — distinct from the 8D text string), an SMS TPDU check (the submit TPDU of a SEND SHORT MESSAGE: destination / PID / DCS / UDL / user data — for an applet's own SMS), a PoR check (a SEND SHORT MESSAGE carries the PoR when the packet requested PoR-in-submit, SPI2 bit 20: the response status, the R-APDU SW and the response data; make sure an SCP80 step ran first) and a Files check (the File List of a REFRESH, e.g. qualifier 00 = NAA initialization with full file change notification). The scripted TERMINAL RESPONSE (result, item identifier, text, extra TLVs) is part of the instruction.
- Fail levels — a mismatch flags the step as Error (terminates the script) or Warning (continues); each check is reported with expected vs actual values.
- Templates — Empty, STK menu browsing, Applet via SCP80; the step editor is form-based (the event action's fields are a JSON object) and individual scripts export/import as JSON.
diff --git a/frontend/sw.js b/frontend/sw.js
index 245459c..d61684d 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v387';
+const CACHE = 'simple-v388';
const URLS = [
'index.html',
'help.html',
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 029a8e8..3ffd3f4 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -6033,22 +6033,34 @@ def _test_run_scp80(server, step, ctx):
def _menu_item_by_text(server, p):
"""Resolve a menu-select step's text against the cached SET UP MENU items
(the item ids vary with the applet's install parameters, so a script
- matches the text the card actually shows). Raises a ScriptError naming
+ matches the text the card actually shows; the match is case-insensitive
+ unless the step sets `case_sensitive: true`). Raises a ScriptError naming
the available items when nothing - or more than one item - matches."""
menu = getattr(server, 'sim_menu', None) or {}
items = menu.get('items') or []
spec = {'mode': p.get('mode', 'exact'), 'value': p['text'],
- 'case_sensitive': p.get('case_sensitive', True)}
+ 'case_sensitive': p.get('case_sensitive', False)}
matches = [it for it in items
if testscript.match_text(spec, it.get('text') or '')]
if not matches:
listing = ', '.join('%s=%r' % (it.get('id'), it.get('text'))
for it in items)
+ hint = ''
+ if spec.get('case_sensitive') and items:
+ # A strict-case miss is a common authoring slip with non-ASCII
+ # menus: name the items a case-insensitive match would find.
+ loose = [it for it in items if testscript.match_text(
+ dict(spec, case_sensitive=False), it.get('text') or '')]
+ if loose:
+ hint = (' - a case-insensitive match exists (%s): set '
+ '"case_sensitive": false or fix the case'
+ % ', '.join('%s=%r' % (it.get('id'), it.get('text'))
+ for it in loose))
raise testscript.ScriptError(
- 'menu-select: no menu item matches %r (%s)'
+ 'menu-select: no menu item matches %r (%s)%s'
% (p['text'], 'menu: ' + listing if listing
else 'no menu cached - fetch the menu (a SET UP MENU '
- 'expectation) first'))
+ 'expectation) first', hint))
if len(matches) > 1:
raise testscript.ScriptError(
'menu-select: %d items match %r (%s) - use the item id'
diff --git a/pysim_simple_server/testscript.py b/pysim_simple_server/testscript.py
index d9e3ec8..d7dbb04 100644
--- a/pysim_simple_server/testscript.py
+++ b/pysim_simple_server/testscript.py
@@ -281,7 +281,10 @@ def _normalise_params(kind, p):
if mode not in ('exact', 'contains'):
raise ScriptError('menu-select: mode must be exact or contains')
out['mode'] = mode
- out['case_sensitive'] = bool(p.get('case_sensitive', True))
+ # Menu labels are UI text: case-insensitive by default (no test
+ # scripts predate this choice); `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')
if 'item_id' in out and 'text' in out:
diff --git a/tests/test_testscript.py b/tests/test_testscript.py
index 0097417..66d23b3 100644
--- a/tests/test_testscript.py
+++ b/tests/test_testscript.py
@@ -127,11 +127,17 @@ class TestValidation(unittest.TestCase):
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'menu-select', 'params': {'item_id': 3}},
{'type': 'action', 'kind': 'menu-select',
- 'params': {'text': 'My menu', 'mode': 'contains', 'case_sensitive': False}},
+ 'params': {'text': 'My menu', 'mode': 'contains'}},
+ {'type': 'action', 'kind': 'menu-select',
+ 'params': {'text': 'Exact', 'mode': 'exact', 'case_sensitive': True}},
]}, _resolver)
self.assertEqual(script['steps'][0]['params'], {'item_id': 3})
+ # the text match is case-insensitive by default (menu labels are UI
+ # text); an explicit true is kept for an exact-case match
self.assertEqual(script['steps'][1]['params'],
{'text': 'My menu', 'mode': 'contains', 'case_sensitive': False})
+ self.assertEqual(script['steps'][2]['params'],
+ {'text': 'Exact', 'mode': 'exact', 'case_sensitive': True})
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
'params': {}}]}, _resolver)
@@ -573,6 +579,38 @@ class TestRunnerActions(RunnerTestCase):
self.assertIn("3='Alpha'", note)
self.assertEqual(scc.sent, [])
+ # The live card's menu (captured 2026-10-04 20:58): Annex A Variant 2
+ # Cyrillic items under the card's ids 128..130 - the compressed
+ # `81 08 ` form (the menu-select text must resolve
+ # against it; the card's ids stay out of the script).
+ LIVE_MENU = {'title': 'GPB MOBILE', 'items': [
+ {'id': 128, 'text': 'Выбор сети'}, {'id': 129, 'text': 'Язык'},
+ {'id': 130, 'text': 'Кофе'}]}
+
+ def test_menu_text_selection_matches_variant2_cyrillic(self):
+ 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))
+ # an explicit case-sensitive spec misses and names the loose match
+ server = FakeServer(FakeScc())
+ server.sim_menu = dict(self.LIVE_MENU)
+ run = self.run_script(server, [
+ {'type': 'action', 'kind': 'menu-select',
+ 'params': {'text': 'кофе', 'case_sensitive': True}},
+ ])
+ self.assertEqual(run['status'], 'error')
+ note = run['steps'][0].get('note') or ''
+ self.assertIn("no menu item matches 'кофе'", note)
+ self.assertIn('case-insensitive match exists', note)
+ self.assertIn("130='Кофе'", note)
+
def test_menu_text_selection_refuses_an_ambiguous_match(self):
scc = FakeScc()
server = FakeServer(scc)
@@ -1009,6 +1047,39 @@ class TestRunnerActions(RunnerTestCase):
self.assertEqual(labels['SMS DCS']['expected'], 'AA')
self.assertEqual(labels['SMS DCS']['actual'], '00')
+ # The live applet's SEND SHORT MESSAGE (captured 2026-10-04 20:58): the
+ # Alpha identifier is Annex A Variant 2 Cyrillic ("Отправка...") and the
+ # TPDU is a real SMS-SUBMIT to +79332505884 with 7-bit user data.
+ APP_SEND_SM_CMD = ('D02F810301130082028183050E810B089EC2BFC0B0B2BAB02E2E2E'
+ '0B1401000B919733525088F40000084B1C12579C9D83')
+
+ def test_expect_alpha_and_sms_checks_on_the_live_applet_sms(self):
+ scc = FakeScc()
+ scc.push('80F2', '', '9102')
+ scc.push('8012', self.APP_SEND_SM_CMD, '9000')
+ scc.push('8014', '', '9000')
+ buf = io.StringIO()
+ with contextlib.redirect_stderr(buf):
+ run = self.run_script(FakeServer(scc), [
+ {'type': 'action', 'kind': 'status', 'params': {'attempts': 2},
+ 'check': {'sw': {'mode': 'mask', 'value': '91??'}}},
+ {'type': 'expect', 'command': 'SEND SHORT MESSAGE',
+ 'checks': [{'kind': 'alpha', 'mode': 'exact', 'value': 'Отправка...'},
+ {'kind': 'sms', 'da': '79332505884', 'pid': '00',
+ 'dcs': '00', 'udl': 8, 'ud': '4B1C12579C9D83'}],
+ 'respond': {'result': 'ok'}},
+ ])
+ self.assertEqual(run['status'], 'ok', run['steps'])
+ labels = {c['label']: c for c in run['steps'][1]['checks']}
+ self.assertTrue(labels['Alpha']['ok'], labels)
+ self.assertEqual(labels['Alpha']['actual'], 'Отправка...')
+ for label in ('SMS DA', 'SMS PID', 'SMS DCS', 'SMS UDL', 'SMS UD'):
+ self.assertTrue(labels[label]['ok'], labels)
+ self.assertEqual(run['steps'][1]['sms']['da'], '79332505884')
+ log = buf.getvalue()
+ self.assertIn('TEST-RUN step 2: SMS DA=79332505884 PID=00 DCS=00 UDL=8 '
+ 'UD=4B1C12579C9D83', log)
+
def test_scp80_format_wraps_the_apdu(self):
# `format` wraps the C-APDU in the expanded Command Scripting template
# (TS 102 226 5.2.1): `expanded` = AA//22/..., `expanded-ae` =