v3.22.0: the event action uses the Simulator's forms (all modelled events)

The test-script event action edited its fields as a JSON textarea and only
accepted the four events events.py builds; every other event needed the raw
envelope action with hand-copied hex.  It now renders the same EVENT_FORMS
form the Phone tab's send dialog uses, for every modelled event (29; no
release gating - a script may target another card).

- PWA: the field renderer/collector is shared (eventFormFieldsHtml /
  eventFormValues / eventFormPatternError / eventFormSrc / eventFieldGroupsUpdate);
  the Phone tab keeps its ids, its release gating and its onchange updaters,
  the step editor uses test-f-ev- ids, the generic group updater and a live
  Event data (hex) preview with the one-ENVELOPE check.  The event select
  lists all EVENT_FORMS events (the numeric keys are decimal strings -
  parseInt(k, 10)); the action stores the collected fields, the built data
  and the event's source (fixed cfg.src or the cfg.srcField input - the
  action never carried src before, so MT call / IMS registration / slices
  could not be network-sourced).  The JSON textarea is gone.
- engine: the `event` action keeps `fields` validation for the four
  server-built events (a stray data hex is ignored) and accepts the
  form-built `data` for every other event - hex-validated and checked
  against events.EVENT_DATA_MAX (244, the one-ENVELOPE budget; server.py's
  _EVENT_DATA_MAX now reads it from events.py).  The runner builds the four
  at run time (fresh host clock) and sends the stored data for the rest.
- tests: the engine's data path (accepted / missing / malformed / oversized,
  the four still fields-built), the runner sending the data (fake scc), the
  editor's collect/prefill/patterns/source (Node), the shared helpers.
  Docs: test-scripts.md, api.md, help EN/RU.  Tests: 838 frontend +
  797 Python; sw cache simple-v401.
This commit is contained in:
2026-10-06 01:07:12 +03:00
parent bcd9fb9a4c
commit 40c1e0f148
11 changed files with 389 additions and 112 deletions
+4 -2
View File
@@ -720,8 +720,10 @@ callers.
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`,
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` +
`event` (`event` name or hex + `fields` for the four server-built events
0x03/0x0B/0x12/0x1D, or the PWA form's built `data` hex for any other event -
validated as hex and against the one-ENVELOPE budget at load; optional `src`,
the device-identities override), `menu-select` (`item_id` 1-255, or `text` +
`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`
+12 -5
View File
@@ -259,7 +259,7 @@ consecutive runs continue correctly.
| kind | params | notes |
|---|---|---|
| `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) |
| `event` | `event` (name or hex), `fields` (object) or `data` (form-built hex), optional `src` | semantic ENVELOPE(Event Download): the four server-built events from `fields`, every other event from the editor's `data` (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; 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` |
@@ -278,10 +278,17 @@ a remote entity through OTA/RFM, TS 102 223 §7.5.9): assert the change with
### Semantic events (`kind: "event"`)
The server builds the event objects for the events a test needs most; an
event without a builder goes through the raw `envelope` action, whose data hex
the Phone tab's event form shows (**Event data (hex)**, copyable). Both
actions take the same optional `src` device-identities override.
The step editor renders the **same form the Phone tab's send dialog uses**
(`EVENT_FORMS`), for every modelled event - no release gating (a script may
target another card). The four events the server builds (`0x03`/`0x0B`/`0x12`/
`0x1D`) travel as `fields`: the server validates them at script load and builds
the objects at run time (so the data-connection host clock is fresh). Every
other event carries the form's built hex as `data` (the editor shows it live
under the form, with the one-ENVELOPE check; the engine validates it and the
single-envelope budget at load). The `src` device-identities source comes from
the event itself (e.g. MT call and IMS registration are network-sourced) or
from the form's source field; an explicit `src` overrides it. Both the
`event` and the raw `envelope` action take the same optional `src`.
```json
{"type": "action", "kind": "event",
+2 -2
View File
@@ -564,10 +564,10 @@
<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>
<p class="text-sm mb-2">У вкладки <strong>четыре представления</strong>: <strong>список наборов</strong> (по умолчанию; <em>Добавить набор</em> / <em>Редактировать</em> / <em>Запустить</em> / <em>Экспорт</em> / <em>Удалить</em>), <strong>редактор набора</strong> (скрипты набора в порядке выполнения &mdash; необязательный <em>setup</em> первым и <em>teardown</em> последним, с пометками ролей; у каждого скрипта <em>Запустить</em>/<em>Редактировать</em>/<em>Переместить</em>/<em>Скопировать</em>/<em>Удалить</em>, политика <em>при ошибке</em> <em>остановить</em> (по умолчанию) / <em>продолжить</em> и <em>Требуется ADM</em>), <strong>редактор скрипта</strong> (список шагов; кнопка <em>Старт</em> выполняет только этот скрипт и показывает результат под шагами &mdash; он не сохраняется) и <strong>отчёт набора</strong> (после запуска набора: статус каждого скрипта, таблица шагов с ожидаемым/фактическим, примечание об ошибке и выдержка из журнала запуска; сводка с успешно/предупреждения/ошибки/пропущено, время выполнения, счётчики SCP80 до&nbsp;&rarr;&nbsp;после по каждому keyset, пресет и предупреждение о смене сессии карты; экспорт в <em>JSON</em> или <em>Markdown</em>). <strong>Запуск набора</strong> выполняет его скрипты по порядку в <strong>одной сессии карты, без сбросов и без продолжения с места остановки</strong>; <em>при ошибке</em> решает, остановится ли набор на этом скрипте или запустится следующий, <strong>teardown выполняется всегда</strong> при остановке или ошибке (пока карта в ридере), чтобы следующий тест начался с чистого состояния, а сброс карты останавливает набор и отмечается в отчёте. <strong>Требуется ADM</strong> (у набора или скрипта) проверяет ключ ADM из подобранного пресета <strong>один раз</strong> перед запуском &mdash; ошибка отклоняет запуск (проверьте вручную через значок ADM; автоматических повторов нет). Скрипты хранятся в хранилище наборов (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); каждый скрипт принадлежит ровно одному набору, и скрипт можно переместить или скопировать в другой набор (экспорт набора &mdash; это пакет с набором и его скриптами; старый экспорт только скриптов импортируется в автоматически созданный набор &laquo;Imported scripts&raquo;).</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>«Учёт регистра»</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 опрашивает, пока карта не объявит команду) и <em>Очистка proactive</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды). У каждого действия есть проверка ответа: ожидаемые 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> (семантические формы: <strong>та же форма, что и в диалоге отправки на вкладке «Телефон»</strong>, для всех смоделированных событий &mdash; четыре события, которые собирает сервер (Location status, Access technology change, Network rejection, Data connection status change), передаются полями, которые он проверяет и собирает во время запуска, остальные несут собранный формой hex данных события (показывается под формой, с проверкой одного ENVELOPE); источник device identities берётся из события (например, MT call &rarr; сеть) или из его поля источника), <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 опрашивает, пока карта не объявит команду) и <em>Очистка proactive</em> (опрос STATUS и поглощение всех объявленных команд &mdash; результат TERMINAL RESPONSE для каждой, опционально другой результат для первой; граница попыток/интервала; опциональная проверка <em>require</em> по поглощённому. Пустой опрос проходит, финальный STATUS должен быть idle, а оставшаяся команда передаётся следующему шагу &mdash; используйте в начале, середине или конце скрипта, чтобы убедиться, что карта перестала слать команды). У каждого действия есть проверка ответа: ожидаемые 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; Ошибка останавливает скрипт, Предупреждение продолжает (терпимая форма &laquo;поглотить команду, если она есть&raquo;) &mdash; а действие STATUS с <em>попытками</em> &gt; 1 опрашивает, когда апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / 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 &mdash; SW R-APDU находится <em>внутри</em> полезной нагрузки ответа: собственный TAR апплета возвращает данные приложения, поэтому проверяйте данные, а SW оставляйте пустым; отклонённый ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) может вовсе не нести пакет ответа &mdash; для такого шага оставляйте проверку PoR «любой» или ожидайте «без PoR») и проверка <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>
<li><strong>Шаблоны</strong> &mdash; Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный (действие Event показывает форму события из симулятора), а набор экспортируется/импортируется пакетом JSON (набор и его скрипты).</li>
</ul>
<p class="text-sm mb-3">Каждый шаг пишет семантическую строку в журнал сервера под своим номером (<code class="font-mono text-sm">TEST-RUN step N: …</code>), запрос перед ответом: отправленный APDU/данные и полученный SW, для SCP80 — открытый C-APDU, собранный защищённый пакет и R-APDU из PoR, для ожидания — тройка FETCH / команда / TERMINAL RESPONSE, очистка proactive — каждый раунд STATUS/FETCH/команда/TR, а очистка в конце запуска — поглощённые команды (отвечаются <code class="font-mono text-sm">ok</code> &mdash; cancel отправляется только по явному запросу скрипта), а для выбора пункта меню — полный ENVELOPE с определённым пунктом. Запустите сервер с <code class="font-mono text-sm">--apdu-trace</code>, чтобы добавить и журнал сырых транспортных APDU.</p>
+2 -2
View File
@@ -563,10 +563,10 @@
<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>
<p class="text-sm mb-2">The pill has <strong>four views</strong>: the <strong>suite list</strong> (default; <em>Add suite</em> / <em>Edit</em> / <em>Run</em> / <em>Export</em> / <em>Delete</em>), the <strong>suite editor</strong> (the suite's scripts in run order &mdash; the optional <em>setup</em> first and the <em>teardown</em> last, marked with role badges; per script <em>Run</em>/<em>Edit</em>/<em>Move</em>/<em>Copy</em>/<em>Delete</em>, the per-member <em>on failure</em> policy <em>stop</em> (default) / <em>continue</em>, and <em>ADM required</em>), the <strong>script editor</strong> (the step list; its <em>Start</em> runs that single script and shows the result under the steps &mdash; not preserved) and the <strong>suite report</strong> (after a suite run: every script's status, its step table with expected/actual, the failing note and a run-log excerpt; the summary with passed/warnings/failed/skipped, wall time, the SCP80 counters before&nbsp;&rarr;&nbsp;after per keyset, the preset and a card-session-changed warning; export as <em>JSON</em> or <em>Markdown</em>). A <strong>suite run</strong> executes its scripts in order in <strong>one card session, without resets and without resume</strong>; <em>on failure</em> decides whether the suite stops at that script or runs the next one, the <strong>teardown always runs</strong> on a stop or a failure (while the card is in the reader) so the next test starts clean, and a card reset stops the suite and is flagged in the report. <strong>ADM required</strong> (on the suite or a script) verifies the matched preset's ADM key <strong>once</strong> before the start &mdash; a failure refuses the run (verify manually with the ADM badge; nothing is retried automatically). Scripts live in the suite's store (<code class="font-mono text-sm">~/.pysim-simple-server/test_suites.json</code>, <code class="font-mono text-sm">--test-suites</code>); every script belongs to exactly one suite, and a script can be moved or copied to another suite (a bundle export carries the suite plus its scripts; the old script-only export imports into an auto-created &ldquo;Imported scripts&rdquo; suite).</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 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), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive cleanup</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending). 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: the <strong>same form the Phone tab's send dialog uses</strong>, for every modelled event &mdash; the four events the server builds (Location status, Access technology change, Network rejection, Data connection status change) travel as fields it validates and builds at run time, every other event carries the form's built event-data hex (shown live under the form, with the one-ENVELOPE check); the device-identities source comes from the event (e.g. MT call &rarr; network) or its source field), <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), <em>STATUS</em> (with <em>attempts</em> &gt; 1 it polls until the card announces a command) and <em>Proactive cleanup</em> (poll STATUS and consume every announced command &mdash; the TERMINAL RESPONSE result for each, optionally a different result for the first; the attempt/interval bound; an optional <em>require</em> assertion over what was drained. An empty poll passes, the final STATUS must be idle, and a still-pending command is handed to the next step &mdash; use it at the start, in the middle or at the end of a script to make sure the card has stopped sending). 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;: the step fails at its fail level &mdash; Error stops the script, Warning continues (the tolerant &ldquo;consume a command if there is one&rdquo; form) &mdash; and a STATUS action with <em>attempts</em> &gt; 1 polls 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 &mdash; the R-APDU SW lives <em>inside</em> the response payload: an applet's own TAR returns application data, so check its data and leave the SW empty; a refused ENVELOPE (<code class="font-mono text-sm">62XX</code>/<code class="font-mono text-sm">63XX</code>) may carry no response packet at all &mdash; leave the PoR check at <em>any</em> or expect <em>no PoR</em> for such a step) 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); every step failure honours it, including an expectation that finds no pending command; 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 a suite exports/imports as a JSON bundle (the suite plus its scripts).</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 renders the Simulator's event form), and a suite exports/imports as a JSON bundle (the suite plus its scripts).</li>
</ul>
<p class="text-sm mb-3">Every step logs a semantic line to the server log under its step number (<code class="font-mono text-sm">TEST-RUN step N: …</code>), request before response: the APDU/data sent and the SW received, for SCP80 the plaintext C-APDU, the built secured packet and the PoR's R-APDU, for an expectation the FETCH / command / TERMINAL RESPONSE trio, a proactive cleanup every STATUS/FETCH/command/TR round and the run cleanup the commands it drained (answered <code class="font-mono text-sm">ok</code> &mdash; a cancel is only sent when a script asks for it), and for a menu selection the complete ENVELOPE with the resolved item. Start the server with <code class="font-mono text-sm">--apdu-trace</code> to add the raw transport APDU log as well.</p>
+212 -63
View File
@@ -7123,6 +7123,9 @@ function testScriptProblem(script) {
|| p.fields === null || Array.isArray(p.fields))) {
return at + t('event fields must be an object');
}
const data = String(p.data || '').replace(/\s/g, '');
if (!p.fields && !data) return at + t('event data is required');
if (data && !/^[0-9A-Fa-f]+$/.test(data)) return at + t('event data must be hex');
}
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
if (s.kind === 'proactive' && String(p.attempts === undefined ? '' : p.attempts).trim() !== ''
@@ -7196,6 +7199,7 @@ function testStepSummary(step) {
else if (step.kind === 'event') {
const code = parseInt(p.event, 16);
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
if (p.src) s += ' src=' + p.src;
}
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
else if (step.kind === 'scp80') s = 'SCP80 ' + ((p.source || (p.sp ? 'sp' : 'apdu')) === 'sp' ? (p.sp || '(secured packet)') : (p.apdu || '?')) + (p.tar ? ' TAR=' + p.tar : '') + (p.spi1 ? ' SPI1=' + p.spi1 : '') + (p.spi2 ? ' SPI2=' + p.spi2 : '') + (p.format && p.format !== 'compact' ? ' fmt=' + p.format : '');
@@ -7794,18 +7798,42 @@ function testStepRender() {
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
html += testFormRow('Source override (hex, optional)', testFormInput('test-f-eventsrc', p.src, '82 terminal / 83 network'));
} else if (step.kind === 'event') {
// The semantic event download: the four events the server builds
// (others go through the raw envelope action with the hex the
// Phone form shows).
html += testFormRow('Event', testFormSelect('test-f-evtype', p.event || '03',
[0x03, 0x0B, 0x12, 0x1D].map(c => ({
v: c.toString(16).toUpperCase().padStart(2, '0'),
l: EVENT_NAMES[c]}))));
html += testFormRow('Event fields (JSON)', '<textarea id="test-f-evfields" rows="3" ' +
'placeholder=\'{"status": 0}\' class="w-full font-mono border border-gray-300 ' +
'dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">' +
esc(p.fields && Object.keys(p.fields).length ? JSON.stringify(p.fields) : '') +
'</textarea>');
// The semantic event download: the same forms the Simulator uses
// (EVENT_FORMS), for every modelled event - the server builds the
// four it models from `fields`, the editor's built hex (`data`)
// covers the rest. No release gating: a script may target another
// card (the Phone tab's form gates against the equipped one).
// Object.keys(EVENT_FORMS) yields the numeric keys as decimal
// strings (the table is written with 0x… literals); the select's
// option values are the hex byte forms.
const codes = Object.keys(EVENT_FORMS).map(k => parseInt(k, 10))
.sort((a, b) => a - b);
const rawEvent = p.event === undefined ? '03' : p.event;
let cur = (typeof rawEvent === 'number') ? rawEvent : parseInt(String(rawEvent), 16);
if (!(Number.isInteger(cur) && cur >= 0 && cur <= 0xFF)) cur = 3;
const opts = codes.map(c => ({
v: c.toString(16).toUpperCase().padStart(2, '0'),
l: EVENT_NAMES[c] || ('0x' + c.toString(16).toUpperCase())}));
if (codes.indexOf(cur) < 0) {
opts.push({v: cur.toString(16).toUpperCase().padStart(2, '0'),
l: t('unknown') + ' 0x' + cur.toString(16).toUpperCase()});
}
html += testFormRow('Event', testFormSelect('test-f-evtype',
cur.toString(16).toUpperCase().padStart(2, '0'), opts,
'testEventTypeChanged()'));
const cfg = EVENT_FORMS[cur];
if (cfg) {
html += '<div id="test-f-ev-form" oninput="testEventPreview()" ' +
'onchange="testEventGroups();testEventPreview()">' +
eventFormFieldsHtml(cfg, cfg.fields, p.fields || {}, 'test-f-ev-', false) +
'</div>';
html += testFormRow('Event data (hex)', '<textarea id="test-f-evdata" rows="2" readonly placeholder="\u2014" ' +
'class="w-full font-mono border border-gray-300 dark:border-slate-600 text-xs rounded px-2 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>' +
'<p id="test-f-evwarn" class="hidden text-xs text-amber-600 dark:text-amber-400 mt-1"></p>');
} else {
html += '<p class="text-xs text-amber-600 dark:text-amber-400">' +
esc(t('No form for this event - use the raw Envelope action.')) + '</p>';
}
} else if (step.kind === 'menu-select') {
html += testFormRow('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? '' : p.item_id, '1..255'));
html += testFormRow('\u2026 or item text',
@@ -7910,6 +7938,10 @@ function testStepRender() {
{v: 'error', l: t('Error (stop the script)')}, {v: 'warning', l: t('Warning (continue)')}]));
}
body.innerHTML = html;
if (isAction && step.kind === 'event' && document.getElementById('test-f-ev-form')) {
testEventGroups();
testEventPreview();
}
if (!isAction) testRenderChecks();
}
@@ -7938,6 +7970,62 @@ function testStepKindChanged() {
testStepRender();
}
// The step editor's event form (the Simulator's EVENT_FORMS): the select
// rebuilds the form (the previous event's values do not apply), the generic
// group updater hides the fields a group excludes, and the preview shows the
// exact event-data hex the step will send.
function testEventTypeChanged() {
testStepCollect();
if (!_testEditStep || _testEditStep.type !== 'action') return;
const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16);
_testEditStep.params = {event: code.toString(16).toUpperCase().padStart(2, '0')};
testStepRender();
}
function testEventGroups() {
const box = document.getElementById('test-f-ev-form');
if (!box) return;
const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16);
const cfg = EVENT_FORMS[code];
const groups = {};
((cfg && cfg.fields) || []).forEach(f =>
String(f.g || '').split(',').filter(Boolean).forEach(x => { groups[x] = true; }));
let g = '';
((cfg && cfg.fields) || []).forEach(f => {
if (g || f.type !== 'select') return;
const sel = document.getElementById('test-f-ev-' + f.id);
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
const og = opt && opt.getAttribute ? opt.getAttribute('data-gr') : '';
if (og) { g = og; return; }
if (sel.value && groups[sel.value]) g = sel.value;
});
eventFieldGroupsUpdate(box, g);
}
function testEventPreview() {
const el = document.getElementById('test-f-evdata');
if (!el) return;
const code = parseInt((document.getElementById('test-f-evtype') || {}).value || '03', 16);
const cfg = EVENT_FORMS[code];
let hex = '';
if (cfg) {
try { hex = cfg.build(eventFormValues(cfg, null, 'test-f-ev-')) || ''; } catch (e) { hex = ''; }
}
el.value = hex;
const warn = document.getElementById('test-f-evwarn');
if (warn) {
if (hex && !eventFitsOneEnvelope(hex)) {
warn.textContent = t('The event data does not fit one ENVELOPE') + ' (' +
eventEnvelopeSize(hex) + ' > ' + EVENT_INNER_MAX + ' B). ' +
t('Multi-envelope (chained) delivery is not implemented - reduce the slice lists.');
warn.classList.remove('hidden');
} else {
warn.classList.add('hidden');
}
}
}
function testRenderChecks() {
const el = document.getElementById('test-checks');
if (!el) return;
@@ -8077,19 +8165,20 @@ function testStepCollect() {
}
}
else if (kind === 'event') {
p.event = val('test-f-evtype') || '03';
const raw = val('test-f-evfields');
p.fields = {};
// the flag reflects THIS collect, so fixing the JSON saves again
delete step._eventFieldsError;
if (raw) {
try {
const obj = JSON.parse(raw);
if (obj && typeof obj === 'object' && !Array.isArray(obj)) p.fields = obj;
else step._eventFieldsError = true;
} catch (e) {
step._eventFieldsError = true;
}
const evCode = parseInt(val('test-f-evtype') || '03', 16);
p.event = (Number.isInteger(evCode) && evCode >= 0 && evCode <= 0xFF ? evCode : 3)
.toString(16).toUpperCase().padStart(2, '0');
const cfg = EVENT_FORMS[parseInt(p.event, 16)];
if (cfg) {
// The form values (the server builds the four events it models
// from them) and the built hex the other events run with.
const values = eventFormValues(cfg, null, 'test-f-ev-');
p.fields = values;
let hex = '';
try { hex = cfg.build(values) || ''; } catch (e) { hex = ''; }
p.data = hex;
const src = eventFormSrc(cfg, values);
if (src) p.src = src;
}
}
else if (kind === 'file-write' || kind === 'file-read') {
@@ -8200,8 +8289,16 @@ function testStepFormError() {
const problem = testMenuSelectProblem(p);
if (problem) return problem;
}
if (step.kind === 'event' && step._eventFieldsError) {
return t('Event fields must be a JSON object');
if (step.kind === 'event') {
const code = parseInt(String((step.params || {}).event || ''), 16);
const cfg = EVENT_FORMS[code];
if (!cfg) return t('No form for this event - use the raw Envelope action.');
const bad = eventFormPatternError(cfg, (step.params || {}).fields || {});
if (bad) return t('Invalid') + ': ' + t(bad);
if (!String((step.params || {}).data || '')) return t('Event data is empty');
if (!eventFitsOneEnvelope((step.params || {}).data)) {
return t('The event data does not fit one ENVELOPE');
}
}
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
@@ -15792,13 +15889,49 @@ function eventFitsOneEnvelope(eventDataHex) {
return eventEnvelopeSize(eventDataHex) <= EVENT_INNER_MAX;
}
// The visible event-form values (reads the DOM; a hidden or missing field is
// left out). Shared by the send action and the hex preview.
function pysimEventValues() {
const cfg = EVENT_FORMS[_eventFormType];
// The event form's fields as HTML (pure): shared by the Phone tab's send form
// and the test-script step editor. `prefix` keeps the ids unique per form
// ('ev-f-' / 'test-f-ev-'), `values` prefills them (the step editor reopens a
// stored action) and `fields` limits the list (the Phone tab hides the fields
// introduced after the card's CAT release - the step editor shows all).
// `withOnchange` keeps the table's per-field onchange handlers (the Phone
// tab's updaters read its own ids; the step editor uses the generic group
// updater instead).
function eventFormFieldsHtml(cfg, fields, values, prefix, withOnchange) {
const vals = values || {};
let html = '';
(fields || []).forEach(f => {
const v = vals[f.id] === undefined || vals[f.id] === null ? '' : String(vals[f.id]);
html += '<div class="event-field" data-groups="' + (f.g || '') + '" data-id="' + f.id + '">';
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
if (f.type === 'select') {
html += '<select id="' + prefix + f.id + '"'
+ (withOnchange && f.onchange ? ' onchange="' + f.onchange + '"' : '')
+ ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
f.opts.forEach(o => html += '<option value="' + esc(o.v) + '"'
+ (o.g ? ' data-gr="' + esc(o.g) + '"' : '')
+ (v !== '' && String(o.v) === v ? ' selected' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
const attrs = [];
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
if (v !== '') attrs.push('value="' + esc(v) + '"');
html += '<input id="' + prefix + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
}
html += '</div>';
});
return html;
}
// The visible values of an event form (reads the DOM; a hidden or missing
// field is left out). `prefix` matches eventFormFieldsHtml; `fields`
// defaults to the config's list.
function eventFormValues(cfg, fields, prefix) {
const values = {};
((cfg && cfg.fields) || []).forEach(f => {
const el = document.getElementById('ev-f-' + f.id);
(fields || (cfg && cfg.fields) || []).forEach(f => {
const el = document.getElementById(prefix + f.id);
if (!el) return;
const wrap = el.closest ? el.closest('.event-field') : null;
if (wrap && wrap.style.display === 'none') return;
@@ -15807,6 +15940,42 @@ function pysimEventValues() {
return values;
}
// The first field whose value violates its pattern, or '' (pure).
function eventFormPatternError(cfg, values) {
let bad = '';
((cfg && cfg.fields) || []).forEach(f => {
const v = values[f.id];
if (bad || v === undefined || !f.pattern || !v) return;
if (!new RegExp('^' + f.pattern + '$').test(v)) bad = f.label;
});
return bad;
}
// The device-identities source of an event form (pure): the event's fixed
// value, overridden by its srcField (the call events differ near/far end).
function eventFormSrc(cfg, values) {
let src = (cfg && cfg.src) || '';
if (cfg && cfg.srcField && values && values[cfg.srcField]) src = values[cfg.srcField];
return src || '';
}
// Toggle an event form's grouped fields: a field without data-groups is
// always shown, otherwise one of its groups must equal `g`.
function eventFieldGroupsUpdate(container, g) {
if (!container) return;
container.querySelectorAll('.event-field').forEach(div => {
const groups = div.getAttribute('data-groups') || '';
div.style.display = (groups === '' || groups.indexOf(g) >= 0) ? '' : 'none';
});
}
// The visible event-form values (reads the DOM; a hidden or missing field is
// left out). Shared by the send action and the hex preview.
function pysimEventValues() {
const cfg = EVENT_FORMS[_eventFormType];
return eventFormValues(cfg, null, 'ev-f-');
}
// Build the current form's event data and show it - the exact bytes the send
// would deliver, so a raw envelope test step can be authored by copying it.
function pysimEventPreview() {
@@ -15853,24 +16022,7 @@ function pysimEventSendForm(eventType) {
// parameters introduced after it are hidden - the note names the source.
const eff = catEffectiveReleaseInfo(cards, _pysimCardIccid);
const avail = eventFieldsForRelease(cfg, eff.rel);
let html = '';
avail.fields.forEach(f => {
const rg = f.g ? f.g.split(',') : null;
html += '<div class="event-field" data-groups="' + (f.g||'') + '" data-id="' + f.id + '">';
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
if (f.type === 'select') {
html += '<select id="ev-f-' + f.id + '"' + (f.onchange ? ' onchange="' + f.onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (o.g ? ' data-gr="' + o.g + '"' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
const attrs = [];
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
html += '<input id="ev-f-' + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
}
html += '</div>';
});
let html = eventFormFieldsHtml(cfg, avail.fields, {}, 'ev-f-', true);
if (avail.hidden.length) {
const src = eff.preset ? t('preset:') + ' ' + eff.preset.name : t('default release');
html += '<p class="text-xs text-amber-600 dark:text-amber-400 mt-2" title="' +
@@ -15932,20 +16084,15 @@ async function pysimEventSend() {
if (cfg && cfg.build) {
const values = pysimEventValues();
let valid = true;
cfg.fields.forEach(f => {
const v = values[f.id];
if (v === undefined) return; // hidden or not rendered
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
err.textContent = t('Invalid') + ': ' + t(f.label);
err.classList.remove('hidden');
valid = false;
}
});
if (!valid) return;
const bad = eventFormPatternError(cfg, values);
if (bad) {
err.textContent = t('Invalid') + ': ' + t(bad);
err.classList.remove('hidden');
return;
}
try {
eventData = cfg.build(values);
if (cfg.srcField && values[cfg.srcField]) eventSrc = values[cfg.srcField];
eventSrc = eventFormSrc(cfg, values) || eventSrc;
} catch (e) {
err.textContent = 'Error: ' + e.message;
err.classList.remove('hidden');
@@ -21916,6 +22063,8 @@ const LANG_RU = {
'keyset number must be 1..15': 'номер keyset должен быть 1..15',
'Event fields (JSON)': 'Поля события (JSON)',
'Event fields must be a JSON object': 'Поля события должны быть объектом JSON',
'No form for this event - use the raw Envelope action.': 'Для этого события нет формы — используйте действие Envelope с сырыми данными.',
'Event data is empty': 'Данные события пусты',
'event is required': 'нужен тип события',
'event fields must be an object': 'поля события должны быть объектом',
'Event data (hex)': 'Данные события (hex)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v400';
const CACHE = 'simple-v401';
const URLS = [
'index.html',
'help.html',
+80 -20
View File
@@ -29,7 +29,19 @@ function extractFunc(src, name, asyncFn) {
}
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_NAMES = {', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
// the builders' pure helpers (eventFormFieldsHtml/Values are the shared form)
eval(extractFunc(html, 'bytesToHex'));
eval(extractFunc(html, 'eventAddressTlv'));
eval(extractFunc(html, 'encPlmn'));
eval(extractFunc(html, 'eventFormFieldsHtml'));
eval(extractFunc(html, 'eventFormValues'));
eval(extractFunc(html, 'eventFormPatternError'));
eval(extractFunc(html, 'eventFormSrc'));
eval(html.match(/const EVENT_INNER_MAX = \d+;/)[0].replace('const ', 'var '));
eval(extractFunc(html, 'eventEnvelopeSize'));
eval(extractFunc(html, 'eventFitsOneEnvelope'));
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'testTemplate'));
eval(extractFunc(html, 'testMenuSelectProblem'));
@@ -107,8 +119,14 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
params: {} }] }), /event is required/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '03', fields: [] } }] }), /object/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '00' } }] }), /event data is required/);
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '00', data: 'ZZ' } }] }), /hex/);
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '03', fields: { status: 0 } } }] }), '');
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
params: { event: '00', data: '1C0101', src: '83' } }] }), '');
assert.match(testScriptProblem({
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
@@ -412,29 +430,63 @@ test('the envelope step collects the source override', () => {
assert.deepStrictEqual(_testEditStep.params, { event: 3, data: '9B0100', src: '83' });
});
test('the event step collects the type and the JSON fields', () => {
test('the event step collects the form values, the built data and the source', () => {
_testEditStep = { type: 'action', kind: 'event', params: {} };
_testEditChecks = [];
_testEditStepIndex = 0;
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '12',
'test-f-evfields': '{"reg_type":"9","cause":"2"}',
// a server-built event: the fields travel for the server's own build
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
'test-f-ev-status': '0', 'test-f-ev-mcc': '250', 'test-f-ev-mnc': '01',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.deepStrictEqual(_testEditStep.params,
{ event: '12', fields: { reg_type: '9', cause: '2' } });
assert.strictEqual(_testEditStep._eventFieldsError, undefined);
// invalid JSON marks the form and the save refuses with a clear message
_testEditStep = { type: 'action', kind: 'event', params: {} };
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
'test-f-evfields': '{oops', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
assert.match(testStepFormError(), /JSON object/);
// fixing the JSON saves again - the error flag is not sticky
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '03',
'test-f-evfields': '{"status":0}', 'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
assert.strictEqual(_testEditStep.params.event, '03');
assert.strictEqual(_testEditStep.params.fields.status, '0');
assert.strictEqual(_testEditStep.params.fields.mcc, '250');
assert.match(_testEditStep.params.data, /^[0-9A-F]+$/);
assert.strictEqual(testStepFormError(), '');
// the summary names the event
// an event without a server builder (MT call): the built hex + its source
_testEditStep = { type: 'action', kind: 'event', params: {} };
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': '01',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.strictEqual(_testEditStep.params.event, '00');
assert.strictEqual(_testEditStep.params.data, '1C0101');
assert.strictEqual(_testEditStep.params.src, '83');
assert.strictEqual(testStepFormError(), '');
// a srcField event takes the source from the form (call disconnected)
_testEditStep = { type: 'action', kind: 'event', params: {} };
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '02', 'test-f-ev-src': '83',
'test-f-ev-ti': '00', 'test-f-ev-cause_mode': 'rlt',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
testStepCollect();
assert.strictEqual(_testEditStep.params.src, '83');
assert.strictEqual(_testEditStep.params.data, '1C01009A00');
// a bad field pattern refuses the save; an empty build does too
fakeForm({ 'test-step-kind': 'event', 'test-f-evtype': '00', 'test-f-ev-ti': 'ZZZ',
'test-f-sw': '', 'test-f-cdata': '', 'test-f-fail': 'error' });
assert.match(testStepFormError(), /Invalid/);
// the summary names the event and its source
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '03', fields: {} } }), 'EVENT Location status');
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
params: { event: '00', data: '1C0101', src: '83' } }),
'EVENT MT call src=83');
});
test('the shared event-form helpers render, prefill and collect', () => {
const cfg = EVENT_FORMS[0x00];
const htmlOut = eventFormFieldsHtml(cfg, cfg.fields, {ti: 'AB', ton: 1}, 'x-', false);
assert.match(htmlOut, /id="x-ti"/);
assert.match(htmlOut, /value="AB"/);
assert.match(htmlOut, /id="x-ton"/);
assert.match(htmlOut, /value="1" selected/);
// a srcField event reports its source; a fixed-source event its own
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x00], {}), '83');
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x02], {src: '82'}), '82');
assert.strictEqual(eventFormSrc(EVENT_FORMS[0x03], {}), '');
// the pattern check names the first offending field
assert.strictEqual(eventFormPatternError(cfg, {ti: 'AB'}), '');
assert.strictEqual(eventFormPatternError(cfg, {ti: 'ZZZ'}), 'Transaction identifier');
});
test('two quick saves of a draft create one script, then update it', async () => {
@@ -508,7 +560,7 @@ test('the step form renders the chosen source and no pre-filled SW', () => {
assert.match(body, /id="test-f-sw" value=""/);
});
test('the event step form renders the event select and the JSON fields', () => {
test('the event step form renders the event select and the Simulator fields', () => {
const els = {
'test-step-modal': { classList: { add: () => {}, remove: () => {} } },
'test-step-title': {}, 'test-step-body': {},
@@ -516,15 +568,23 @@ test('the event step form renders the event select and the JSON fields', () => {
};
globalThis.document = { getElementById: id => els[id] || null };
_testEditStep = { type: 'action', kind: 'event',
params: { event: '12', fields: { reg_type: '9' } } };
params: { event: '12', fields: { reg_type: '9', mcc: '250' } } };
_testEditChecks = [];
_testEditStepIndex = 0;
testStepRender();
const body = els['test-step-body'].innerHTML;
assert.match(body, /id="test-f-evtype"/);
assert.match(body, /<option value="12" selected>/);
assert.match(body, /id="test-f-evfields"/);
assert.ok(body.includes('{"reg_type":"9"}'), body);
// the event's own form (EVENT_FORMS) with the stored values prefilled and
// the built hex preview, not a JSON textarea
assert.match(body, /id="test-f-ev-reg_type"/);
assert.match(body, /id="test-f-ev-mcc"/);
assert.match(body, /value="250"/);
assert.match(body, /id="test-f-evdata"/);
assert.doesNotMatch(body, /test-f-evfields/);
// every modelled event is selectable (the four server-built ones included)
assert.match(body, /<option value="00"/);
assert.match(body, /<option value="1F"/);
});
test('hex fields strip mask wildcards, check values keep them', () => {
+6
View File
@@ -30,6 +30,12 @@ EVENT_ACCESS_TECH = 0x0B
EVENT_NETWORK_REJECTION = 0x12
EVENT_DATA_CONNECTION = 0x1D
# The single-ENVELOPE budget of the event data (TS 102 223: the inner data is
# capped at 252 bytes, 8 of them the event-list + device-identities header;
# chained delivery is not implemented). server.py's _EVENT_DATA_MAX and the
# PWA's EVENT_INNER_MAX mirror this value.
EVENT_DATA_MAX = 244
# The event code from a script's `event` value (a hex byte, or one of these
# names - the PWA's display names and short keywords).
EVENT_NAMES = {
+8 -3
View File
@@ -4885,7 +4885,7 @@ def _verify_adm(scc, app, adm_hex):
# Chained (multi-envelope) delivery is not implemented - the slice/S-NSSAI
# data must fit one envelope (see the help).
_EVENT_INNER_MAX = 252
_EVENT_DATA_MAX = _EVENT_INNER_MAX - 8
_EVENT_DATA_MAX = events.EVENT_DATA_MAX # = 252 - 8; events.py owns the value
def _send_event_download(scc, event_type, event_data=None, drain=True, src=None,
@@ -6544,8 +6544,13 @@ def _test_run_action(server, step, ctx):
% (sent, p.get('src') or '82', p['data'] or '(none)', sw or '(none)',
(' RESP=%s' % data) if data else ''))
elif kind == 'event':
# The semantic event download: the builder owns the byte layout.
data_hex = events.build(p['event'], p.get('fields') or {})
# The semantic event download: the server builds the events it models
# (run time, so the data-connection host clock is fresh); every other
# event carries the PWA form's built hex (validated at load).
if p.get('data') is not None:
data_hex = p['data']
else:
data_hex = events.build(p['event'], p.get('fields') or {})
data, sw = _send_event_download(scc, p['event'], bytes.fromhex(data_hex),
drain=False, src=p.get('src'), log=False)
sent = 'ENVELOPE(Event Download) type=0x%02X (%s) data=%s' % (
+23 -12
View File
@@ -243,23 +243,34 @@ def _normalise_params(kind, p, command_resolver=None):
out['src'] = src
return out
if kind == 'event':
# The semantic event download: the builder owns the field set and the
# byte layout (events.py mirrors the PWA's forms for the supported
# events; every other event uses the raw `envelope` action).
# The semantic event download: the same forms the Simulator uses
# (EVENT_FORMS). The four events events.py models are built from
# `fields` (validated here, so a script fails before the run starts);
# every other event carries the form-built `data` hex the PWA
# produces - the single-envelope budget is checked here too.
code = events.resolve_event(p.get('event'))
if code is None:
raise ScriptError('event: a supported event name or a hex byte is '
'required (supported: %s)'
% ', '.join('%s/0x%02X' % (events.event_name(c), c)
for c in sorted(events.BUILDERS)))
raise ScriptError('event: a hex byte or a known event name is required')
raw = p.get('fields')
if raw is not None and not isinstance(raw, dict):
raise ScriptError('event: fields must be an object')
try:
fields = events.normalise(code, raw or {})
except events.EventError as e:
raise ScriptError('event 0x%02X: %s' % (code, e))
out = {'event': code, 'fields': fields}
out = {'event': code}
if code in events.BUILDERS:
try:
out['fields'] = events.normalise(code, raw or {})
except events.EventError as e:
raise ScriptError('event 0x%02X: %s' % (code, e))
else:
data = re.sub(r'\s', '', str(p.get('data') or '')).upper()
if not re.fullmatch(r'(?:[0-9A-F]{2})+', data):
raise ScriptError('event 0x%02X: no server-side builder for this '
'event - pass the form-built `data` hex (or '
'use the raw envelope action)' % code)
if len(data) // 2 > events.EVENT_DATA_MAX:
raise ScriptError('event 0x%02X: the event data does not fit one '
'ENVELOPE (max %d bytes; chained delivery is '
'not implemented)' % (code, events.EVENT_DATA_MAX))
out['data'] = data
src = str(p.get('src') or '').strip().upper()
if src:
if not re.fullmatch(r'[0-9A-F]{2}', src):
+39 -2
View File
@@ -15,6 +15,7 @@ if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
import pysim_simple_server.server as S
import pysim_simple_server.events as events
import pysim_simple_server.testscript as T
@@ -218,6 +219,27 @@ class TestValidation(unittest.TestCase):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'envelope',
'params': {'event': 3, 'src': 'zz'}}]}, _resolver)
def test_event_action_accepts_the_form_built_data(self):
# The events events.py does not model carry the PWA form's built hex;
# a modelled event builds from `fields` (a stray data hex is ignored).
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'event',
'params': {'event': '0x00', 'data': '1c0100', 'src': '83'}}]}, _resolver)
self.assertEqual(script['steps'][0]['params'],
{'event': 0x00, 'data': '1C0100', 'src': '83'})
script = T.normalise_script({'steps': [
{'type': 'action', 'kind': 'event',
'params': {'event': '0x03', 'fields': {'status': 1}, 'data': 'AA'}}]}, _resolver)
self.assertNotIn('data', script['steps'][0]['params'])
self.assertEqual(script['steps'][0]['params']['fields'], {'status': 1})
# missing, malformed and oversized data are refused before the run
for bad in ({'event': '0x00'},
{'event': '0x00', 'data': 'ABC'},
{'event': '0x00', 'data': 'AA' * (events.EVENT_DATA_MAX + 1)}):
with self.assertRaises(T.ScriptError):
T.normalise_script({'steps': [{'type': 'action', 'kind': 'event',
'params': bad}]}, _resolver)
def test_scp80_keyset_number_and_new_content_checks(self):
# kvn (1..15) selects the preset's keyset; `por`/`files` are expect
# content checks (the PoR contents / the REFRESH file list).
@@ -850,8 +872,7 @@ class TestRunnerActions(RunnerTestCase):
# the runner logs one step-annotated ENVELOPE line, not the generic
# sender pair as well (its line carries the name, src and SW)
log = buf.getvalue()
self.assertIn('TEST-RUN step 1: ENVELOPE(Event Download) type=0x03 '
'(location_status) data=9B0100930752F01000FF0001 src=82 -> SW=9000',
self.assertIn('TEST-RUN step 1: ENVELOPE(Event Download) type=0x03 ' '(location_status) data=9B0100930752F01000FF0001 src=82 -> SW=9000',
log)
self.assertNotIn('ENVELOPE(Event Download): type=', log)
self.assertNotIn('ENVELOPE SW:', log)
@@ -878,6 +899,22 @@ class TestRunnerActions(RunnerTestCase):
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertIn('99010082028381', scc3.sent[0].upper())
def test_event_action_sends_the_form_built_data(self):
# The events events.py does not model (e.g. MT call) run with the
# PWA form's built hex and its source (network).
scc = FakeScc()
scc.push('80C2', '', '9000')
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
run = self.run_script(FakeServer(scc), [
{'type': 'action', 'kind': 'event',
'params': {'event': '0x00', 'data': '1C0100', 'src': '83'}},
])
self.assertEqual(run['status'], 'ok', run['steps'])
self.assertIn('99010082028381' + '1C0100', scc.sent[0].upper())
self.assertIn('type=0x00 (0x00) data=1C0100 src=83 -> SW=9000',
buf.getvalue())
def test_scp80_uses_the_preset_and_advances_the_counter(self):
scc = FakeScc()
server = FakeServer(scc)