v3.18.0: the semantic event action for test scripts
The other-applet batch: a test script can send the common event downloads semantically instead of raw hex. - events.py: server-side mirrors of the PWA's event forms for Location status (0x03), Access technology change (0x0B), Network rejection (0x12 - the LA/RA/TA object selected by the registration group, the extended EMM cause and the extended information object) and Data connection status change (0x1D - status/type/TI plus the optional cause, host-clock 8.39 date-time, location information, access technology, APN and PDP/PDN/PDU type). Fields are validated before the run (a bad value is a script error, not a step failure); `src` overrides the device-identities source (default terminal, matching the Phone forms). - The engine gains the `event` action kind; the runner builds and sends it. The PWA's action-kind list stays in sync (drift-guarded - the guard itself caught its own regex bug on the first run). - Byte parity: the vectors are generated from the PWA's own builders and pinned in tests/test_events.py; the frontend suites assert the same values. PLMN and SCTS helpers are mirrored too. - The Phone tab's event form shows the built "Event data (hex)" (updated as the fields change), so every event without a builder can be used through the raw `envelope` action. - The test-step editor offers the event select + a JSON fields textarea; malformed JSON refuses the save with a clear message. - docs/test-scripts.md (the semantic-event table, an event example, the format stamp), docs/api.md and help EN/RU updated. Tests: 812 frontend + 717 Python; sw cache simple-v381.
This commit is contained in:
+3
-1
@@ -667,7 +667,9 @@ keysets, TARs and counters. The inline `preset` form stays for external
|
|||||||
callers.
|
callers.
|
||||||
|
|
||||||
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`),
|
**Action steps** (`type: "action"`): `kind` is `envelope` (`event`, `data`),
|
||||||
`menu-select` (`item_id` 1-255, or `text` + `mode` `exact`/`contains` with an
|
`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
|
optional `case_sensitive` - the text is resolved against the card's cached
|
||||||
SET UP MENU, which is refreshed whenever the card sends the menu), `file-write`
|
SET UP MENU, which is refreshed whenever the card sends the menu), `file-write`
|
||||||
(`path`, `data`, `mode` `auto`/`binary`/`record`, `record`), `file-read` (same,
|
(`path`, `data`, `mode` `auto`/`binary`/`record`, `record`), `file-read` (same,
|
||||||
|
|||||||
+47
-3
@@ -14,7 +14,7 @@ localStorage export imports as-is), and any API client can create or run them
|
|||||||
|
|
||||||
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
|
**Format**: server ≥ 3.15.0 (`kvn`, `por` and `files` checks), scripts stored
|
||||||
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
|
server-side since 3.16.0, `menu-select` by text and the spec-order TERMINAL
|
||||||
RESPONSE codings since 3.17.0.
|
RESPONSE codings since 3.17.0, the semantic `event` action since 3.18.0.
|
||||||
|
|
||||||
## Writing a test for an applet
|
## Writing a test for an applet
|
||||||
|
|
||||||
@@ -131,7 +131,8 @@ continue correctly.
|
|||||||
|
|
||||||
| kind | params | notes |
|
| kind | params | notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `envelope` | `event` 0–255, `data` hex (optional) | ENVELOPE(Event Download) |
|
| `envelope` | `event` 0–255, `data` hex (optional) | ENVELOPE(Event Download) with raw data |
|
||||||
|
| `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`), 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-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` |
|
| `file-read` | `path`, `mode`, `record` | READ BINARY/RECORD; verify with `check.data` |
|
||||||
@@ -142,6 +143,29 @@ continue correctly.
|
|||||||
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
|
`path` is `/`-separated: `MF` (or `3F00`) or an ADF name/AID first, then FIDs
|
||||||
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
|
or file names - e.g. `MF/7F20/6F07`, `ADF.USIM/EF.TEST`.
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "action", "kind": "event",
|
||||||
|
"params": {"event": "location_status",
|
||||||
|
"fields": {"status": 0, "mcc": "250", "mnc": "01",
|
||||||
|
"lac": "00FF", "cell": "0001"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `event` | fields |
|
||||||
|
|---|---|
|
||||||
|
| `location_status` / `0x03` | `status` 0 normal / 1 limited / 2 no service; for normal service `mcc`, `mnc`, `lac`, `cell` |
|
||||||
|
| `access_tech` / `0x0B` | `tech` (TS 102 223 8.61 coding: 0 GSM, 3 UTRAN, 8 E-UTRAN, 10 NG-RAN, …) |
|
||||||
|
| `network_rejection` / `0x12` | `reg_type` 0–17, `access_tech`, `cause`; the location object follows the registration group - LU: `lac`, GPRS: `lac`+`rac`, EPS/5GS: `tac` (4–6 hex) + optional `ext_cause`; `mcc`/`mnc`; optional `ext_info_type` (1 CAG ID / 2 NID / 3 RedCap) + `ext_info` |
|
||||||
|
| `data_connection` / `0x1D` | `status` 0–2, `type` 0–2, optional `cause`, `ti` (hex byte), `datetime: "now"` (host clock, 8.39), `mcc`/`mnc`/`lac`/`cell`, `tech`, `loc_status`, `apn`, `pdp_type` |
|
||||||
|
|
||||||
|
`src` overrides the device-identities source (`82` terminal, `83` network);
|
||||||
|
the default is the terminal, matching the Phone tab's forms.
|
||||||
|
|
||||||
### `check` on actions
|
### `check` on actions
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -245,8 +269,25 @@ pending command the next step does not expect.
|
|||||||
|
|
||||||
## Worked examples
|
## Worked examples
|
||||||
|
|
||||||
### Menu selection by text (the id varies with the install parameters)
|
### Event download before an applet action
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "applet event",
|
||||||
|
"steps": [
|
||||||
|
{"type": "action", "kind": "event",
|
||||||
|
"params": {"event": "location_status",
|
||||||
|
"fields": {"status": 0, "mcc": "250", "mnc": "01",
|
||||||
|
"lac": "00FF", "cell": "0001"}},
|
||||||
|
"check": {"sw": {"mode": "mask", "value": "91??"}}},
|
||||||
|
{"type": "expect", "command": "ANY", "respond": {"result": "ok"}}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
The event's default SW check is an exact `9000`; a mask (`91??`) is needed
|
||||||
|
when the applet answers with a proactive command, which the next expectation
|
||||||
|
then fetches.
|
||||||
|
|
||||||
|
### Menu selection by text (the id varies with the install parameters)
|
||||||
```json
|
```json
|
||||||
{"name": "applet menu",
|
{"name": "applet menu",
|
||||||
"steps": [
|
"steps": [
|
||||||
@@ -333,3 +374,6 @@ PoR-in-submit (with the transport asserted by the expectation):
|
|||||||
is checked on the action (`por`/`data`).
|
is checked on the action (`por`/`data`).
|
||||||
- `files` matches the File List as a set (order-insensitive); duplicate paths
|
- `files` matches the File List as a set (order-insensitive); duplicate paths
|
||||||
are kept.
|
are kept.
|
||||||
|
- Semantic `event` actions cover four events (0x03/0x0B/0x12/0x1D); every
|
||||||
|
other event uses the raw `envelope` action with the data hex from the
|
||||||
|
Phone tab's event form.
|
||||||
|
|||||||
@@ -563,7 +563,7 @@
|
|||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
|
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Тест-скрипт</h3>
|
||||||
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 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>), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
|
<p class="text-sm mb-2"><strong>Тест-скрипт</strong> — упорядоченный список <strong>действий</strong> и <strong>ожиданий</strong> проактивных команд, выполняемый на подключённой карте на стороне сервера. Пока запуск активен, карта принадлежит скрипту: остальные операции с картой отклоняются (HTTP 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>), поэтому все браузеры работают с одним набором; экспорт/импорт JSON остаётся для резервных копий и переноса скриптов между стендами (старый экспорт из localStorage импортируется как есть).</p>
|
||||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: тип события + данные), <em>Menu Selection</em> (item id 1–255 или текст пункта с точным/частичным совпадением — текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
<li><strong>Действия</strong> — <em>Envelope</em> (Event Download: сырой тип события + данные) или <em>Event</em> (семантические формы: Location status, Access technology change, Network rejection, Data connection status change — для остальных событий форма события на вкладке «Телефон» показывает готовый hex), <em>Menu Selection</em> (item id 1–255 или текст пункта с точным/частичным совпадением — текст подбирается по меню карты, которое следует за параметрами установки апплета), <em>UPDATE/READ файла</em> (путь + режим; чтение проверяет данные файла), <em>сырой APDU</em> (без авто-обработчика), <em>SCP80</em> (C-APDU или готовый защищённый пакет, опциональные переопределения номера keyset (kvn) / TAR / SPI1 / SPI2; ключи и счётчик берутся из пресета карты, подобранного по ICCID подключённой карты — запуск называет пресет в хранилище, поэтому всегда используются его keysets, TAR и счётчик — а счётчик записывается обратно после запуска) и <em>STATUS</em> (при <em>попытках</em> > 1 опрашивает, пока карта не объявит команду). У каждого действия есть проверка ответа: ожидаемые SW и данные (точно или маска с подстановкой <code class="font-mono text-sm">?</code> — <code class="font-mono text-sm">91??</code>) и проверка PoR для SCP80 (<em>любой / без PoR / por_ok</em>).</li>
|
||||||
<li><strong>Ожидания</strong> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <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> — карта должна объявить проактивную команду на предыдущем шаге (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); <code class="font-mono text-sm">9000</code> означает «команды нет» и считается ошибкой — добавьте действие STATUS, если апплет выдаёт команду при опросе. Проверяются тип команды, квалификатор и содержимое: текст / пункт / raw, проверка <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> — несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
|
<li><strong>Уровни ошибок</strong> — несовпадение помечает шаг как <em>Ошибка</em> (скрипт останавливается) или <em>Предупреждение</em> (продолжает); каждая проверка показывается с ожидаемым и фактическим значением.</li>
|
||||||
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
|
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
|
||||||
|
|||||||
+1
-1
@@ -562,7 +562,7 @@
|
|||||||
<h3 id="test-script" class="text-lg font-medium mb-2">8.9 Test script</h3>
|
<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 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; 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">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 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; 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">
|
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||||
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: event type + data), <em>Menu Selection</em> (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), <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; 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 <em>STATUS</em> (with <em>attempts</em> > 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 — <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li>
|
<li><strong>Actions</strong> — <em>Envelope</em> (Event Download: raw event type + data) or <em>Event</em> (the semantic forms: Location status, Access technology change, Network rejection, Data connection status change — the Phone tab's event form shows the built hex for the other events), <em>Menu Selection</em> (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), <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; 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 <em>STATUS</em> (with <em>attempts</em> > 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 — <code class="font-mono text-sm">91??</code>) plus a PoR check for SCP80 (<em>any / no PoR / por_ok</em>).</li>
|
||||||
<li><strong>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> 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, 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>Expectations</strong> — the card must have announced a proactive command in the previous step (SW <code class="font-mono text-sm">91XX</code>, TS 102 221 §7.4.2.1 / TS 102 223 §6.3); a <code class="font-mono text-sm">9000</code> 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, 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> — 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>Fail levels</strong> — 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> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based and individual scripts export/import as JSON.</li>
|
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based and individual scripts export/import as JSON.</li>
|
||||||
|
|||||||
+87
-7
@@ -1877,7 +1877,7 @@
|
|||||||
// ===== Version =====
|
// ===== Version =====
|
||||||
// Single source of truth for the PWA version: shown in the header and used
|
// Single source of truth for the PWA version: shown in the header and used
|
||||||
// by the server version check in pysimConnect().
|
// by the server version check in pysimConnect().
|
||||||
const SIMPLE_VERSION = '3.17.0';
|
const SIMPLE_VERSION = '3.18.0';
|
||||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||||
|
|
||||||
// ===== Tab switching =====
|
// ===== Tab switching =====
|
||||||
@@ -6872,7 +6872,7 @@ function decodeResponse() {
|
|||||||
// through /api/test/status. This UI edits the script and monitors the run;
|
// through /api/test/status. This UI edits the script and monitors the run;
|
||||||
// SCP80 steps use the card preset matched by the equipped card's ICCID, and
|
// SCP80 steps use the card preset matched by the equipped card's ICCID, and
|
||||||
// the final SCP80 counter is written back to it.
|
// the final SCP80 counter is written back to it.
|
||||||
const TEST_ACTION_KINDS = ['envelope', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status'];
|
const TEST_ACTION_KINDS = ['envelope', 'event', 'menu-select', 'file-write', 'file-read', 'apdu', 'scp80', 'status'];
|
||||||
let _testScripts = null;
|
let _testScripts = null;
|
||||||
let _testCurrentIdx = -1;
|
let _testCurrentIdx = -1;
|
||||||
let _testRunState = null;
|
let _testRunState = null;
|
||||||
@@ -7001,6 +7001,13 @@ function testScriptProblem(script) {
|
|||||||
const problem = testMenuSelectProblem(p);
|
const problem = testMenuSelectProblem(p);
|
||||||
if (problem) return at + problem;
|
if (problem) return at + problem;
|
||||||
}
|
}
|
||||||
|
if (s.kind === 'event') {
|
||||||
|
if (!String(p.event || '').trim()) return at + t('event is required');
|
||||||
|
if (p.fields !== undefined && (typeof p.fields !== 'object'
|
||||||
|
|| p.fields === null || Array.isArray(p.fields))) {
|
||||||
|
return at + t('event fields must be an object');
|
||||||
|
}
|
||||||
|
}
|
||||||
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
|
if ((s.kind === 'file-write' || s.kind === 'file-read') && !String(p.path || '').trim()) return at + t('file path is empty');
|
||||||
} else if (s.type === 'expect') {
|
} else if (s.type === 'expect') {
|
||||||
if (!String(s.command || '').trim()) return at + t('command is required');
|
if (!String(s.command || '').trim()) return at + t('command is required');
|
||||||
@@ -7056,6 +7063,10 @@ function testStepSummary(step) {
|
|||||||
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text)
|
? 'text ' + (p.mode === 'contains' ? '~' : '=') + JSON.stringify(p.text)
|
||||||
: 'item=' + p.item_id);
|
: 'item=' + p.item_id);
|
||||||
}
|
}
|
||||||
|
else if (step.kind === 'event') {
|
||||||
|
const code = parseInt(p.event, 16);
|
||||||
|
s = 'EVENT ' + (EVENT_NAMES[code] || ('0x' + String(p.event || '?')));
|
||||||
|
}
|
||||||
else if (step.kind === 'apdu') s = 'APDU ' + (p.apdu || '?');
|
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 : '');
|
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 : '');
|
||||||
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
else if (step.kind === 'file-write') s = 'UPDATE ' + (p.path || '?') + (p.mode && p.mode !== 'auto' ? ' (' + p.mode + (p.record ? ' ' + p.record : '') + ')' : '') + ' = ' + (p.data || '');
|
||||||
@@ -7317,6 +7328,19 @@ function testStepRender() {
|
|||||||
if (step.kind === 'envelope') {
|
if (step.kind === 'envelope') {
|
||||||
html += testFormRow('Event type', testFormInput('test-f-event', p.event === undefined ? 3 : p.event, '0..255'));
|
html += testFormRow('Event type', testFormInput('test-f-event', p.event === undefined ? 3 : p.event, '0..255'));
|
||||||
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
|
html += testFormRow('Event data (hex, optional)', testFormInput('test-f-data', p.data, '9B0100'));
|
||||||
|
} 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>');
|
||||||
} else if (step.kind === 'menu-select') {
|
} 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('Item id (1-255)', testFormInput('test-f-item', p.item_id === undefined ? '' : p.item_id, '1..255'));
|
||||||
html += testFormRow('\u2026 or item text',
|
html += testFormRow('\u2026 or item text',
|
||||||
@@ -7524,6 +7548,21 @@ function testStepCollect() {
|
|||||||
if (id) p.item_id = id;
|
if (id) p.item_id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (kind === 'event') {
|
||||||
|
p.event = val('test-f-evtype') || '03';
|
||||||
|
const raw = val('test-f-evfields');
|
||||||
|
p.fields = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!step._eventFieldsError && !raw) delete step._eventFieldsError;
|
||||||
|
}
|
||||||
else if (kind === 'file-write' || kind === 'file-read') {
|
else if (kind === 'file-write' || kind === 'file-read') {
|
||||||
p.path = val('test-f-path');
|
p.path = val('test-f-path');
|
||||||
p.mode = val('test-f-mode') || 'auto';
|
p.mode = val('test-f-mode') || 'auto';
|
||||||
@@ -7599,6 +7638,9 @@ function testStepFormError() {
|
|||||||
const problem = testMenuSelectProblem(p);
|
const problem = testMenuSelectProblem(p);
|
||||||
if (problem) return problem;
|
if (problem) return problem;
|
||||||
}
|
}
|
||||||
|
if (step.kind === 'event' && step._eventFieldsError) {
|
||||||
|
return t('Event fields must be a JSON object');
|
||||||
|
}
|
||||||
if ((step.kind === 'file-write' || step.kind === 'file-read') && !p.path) return t('file path is empty');
|
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');
|
if (step.kind === 'status' && !(p.attempts >= 1)) return t('attempts must be at least 1');
|
||||||
} else if (!step.command) {
|
} else if (!step.command) {
|
||||||
@@ -14963,6 +15005,34 @@ function eventFitsOneEnvelope(eventDataHex) {
|
|||||||
return eventEnvelopeSize(eventDataHex) <= EVENT_INNER_MAX;
|
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];
|
||||||
|
const values = {};
|
||||||
|
((cfg && cfg.fields) || []).forEach(f => {
|
||||||
|
const el = document.getElementById('ev-f-' + f.id);
|
||||||
|
if (!el) return;
|
||||||
|
const wrap = el.closest ? el.closest('.event-field') : null;
|
||||||
|
if (wrap && wrap.style.display === 'none') return;
|
||||||
|
values[f.id] = String(el.value === undefined ? '' : el.value).trim();
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const el = document.getElementById('event-send-preview');
|
||||||
|
if (!el) return;
|
||||||
|
const cfg = EVENT_FORMS[_eventFormType];
|
||||||
|
let hex = '';
|
||||||
|
if (cfg && cfg.build) {
|
||||||
|
try { hex = cfg.build(pysimEventValues()) || ''; } catch (e) { hex = ''; }
|
||||||
|
}
|
||||||
|
el.value = hex ? hex.toUpperCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function pysimEventSendForm(eventType) {
|
function pysimEventSendForm(eventType) {
|
||||||
const modal = document.getElementById('event-send-modal');
|
const modal = document.getElementById('event-send-modal');
|
||||||
const title = document.getElementById('event-send-title');
|
const title = document.getElementById('event-send-title');
|
||||||
@@ -15021,7 +15091,14 @@ function pysimEventSendForm(eventType) {
|
|||||||
esc(t('hidden parameters:') + ' ' + avail.hidden.length + ' \u00b7 ' + t('introduced after') + ' ' +
|
esc(t('hidden parameters:') + ' ' + avail.hidden.length + ' \u00b7 ' + t('introduced after') + ' ' +
|
||||||
catReleaseLabel(eff.rel) + ' \u00b7 ' + src) + '</p>';
|
catReleaseLabel(eff.rel) + ' \u00b7 ' + src) + '</p>';
|
||||||
}
|
}
|
||||||
|
html += '<div class="mt-3"><label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' +
|
||||||
|
esc(t('Event data (hex)')) + '</label>' +
|
||||||
|
'<textarea id="event-send-preview" 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></div>';
|
||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
|
body.oninput = pysimEventPreview;
|
||||||
|
body.onchange = pysimEventPreview;
|
||||||
|
pysimEventPreview();
|
||||||
btn.classList.remove('hidden');
|
btn.classList.remove('hidden');
|
||||||
if (eventType === 0x12) updateNetRejectionFields();
|
if (eventType === 0x12) updateNetRejectionFields();
|
||||||
if (eventType === 0x02) updateCallCauseFields();
|
if (eventType === 0x02) updateCallCauseFields();
|
||||||
@@ -15067,13 +15144,11 @@ async function pysimEventSend() {
|
|||||||
let eventSrc = (cfg && cfg.src) || null;
|
let eventSrc = (cfg && cfg.src) || null;
|
||||||
|
|
||||||
if (cfg && cfg.build) {
|
if (cfg && cfg.build) {
|
||||||
const values = {};
|
const values = pysimEventValues();
|
||||||
let valid = true;
|
let valid = true;
|
||||||
cfg.fields.forEach(f => {
|
cfg.fields.forEach(f => {
|
||||||
const el = document.getElementById('ev-f-' + f.id);
|
const v = values[f.id];
|
||||||
if (!el || el.closest('.event-field').style.display === 'none') return;
|
if (v === undefined) return; // hidden or not rendered
|
||||||
const v = el.value.trim();
|
|
||||||
values[f.id] = v;
|
|
||||||
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
|
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
|
||||||
err.textContent = t('Invalid') + ': ' + t(f.label);
|
err.textContent = t('Invalid') + ': ' + t(f.label);
|
||||||
err.classList.remove('hidden');
|
err.classList.remove('hidden');
|
||||||
@@ -21030,6 +21105,11 @@ const LANG_RU = {
|
|||||||
'PoR': 'PoR',
|
'PoR': 'PoR',
|
||||||
'Files': 'Файлы',
|
'Files': 'Файлы',
|
||||||
'keyset number must be 1..15': 'номер keyset должен быть 1..15',
|
'keyset number must be 1..15': 'номер keyset должен быть 1..15',
|
||||||
|
'Event fields (JSON)': 'Поля события (JSON)',
|
||||||
|
'Event fields must be a JSON object': 'Поля события должны быть объектом JSON',
|
||||||
|
'event is required': 'нужен тип события',
|
||||||
|
'event fields must be an object': 'поля события должны быть объектом',
|
||||||
|
'Event data (hex)': 'Данные события (hex)',
|
||||||
'Could not load the test scripts from the server': 'Не удалось загрузить тест-скрипты с сервера',
|
'Could not load the test scripts from the server': 'Не удалось загрузить тест-скрипты с сервера',
|
||||||
'Could not save the test script': 'Не удалось сохранить тест-скрипт',
|
'Could not save the test script': 'Не удалось сохранить тест-скрипт',
|
||||||
'Could not delete the test script': 'Не удалось удалить тест-скрипт',
|
'Could not delete the test script': 'Не удалось удалить тест-скрипт',
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'simple-v380';
|
const CACHE = 'simple-v381';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -296,3 +296,28 @@ test('the single-envelope budget is enforced by the size helper', () => {
|
|||||||
assert.ok(!eventFitsOneEnvelope('AB'.repeat(245)));
|
assert.ok(!eventFitsOneEnvelope('AB'.repeat(245)));
|
||||||
assert.ok(eventFitsOneEnvelope(null));
|
assert.ok(eventFitsOneEnvelope(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the event form previews the bytes the send would deliver', () => {
|
||||||
|
// the preview builds from the visible fields - the same bytes the send
|
||||||
|
// uses, so a raw-envelope test step can be authored by copying it
|
||||||
|
const els = {
|
||||||
|
'ev-f-status': { value: '0' },
|
||||||
|
'ev-f-mcc': { value: '250' },
|
||||||
|
'ev-f-mnc': { value: '01' },
|
||||||
|
'ev-f-lac': { value: '00FF' },
|
||||||
|
'ev-f-cell': { value: '0001' },
|
||||||
|
'event-send-preview': { value: '' },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
globalThis._eventFormType = 0x03;
|
||||||
|
pysimEventPreview();
|
||||||
|
assert.strictEqual(els['event-send-preview'].value, '9B0100930752F01000FF0001');
|
||||||
|
// a hidden field is left out of the build
|
||||||
|
els['ev-f-mcc'].closest = () => ({ style: { display: 'none' } });
|
||||||
|
pysimEventPreview();
|
||||||
|
assert.strictEqual(els['event-send-preview'].value, '9B0100');
|
||||||
|
// an event without a form leaves the preview empty
|
||||||
|
globalThis._eventFormType = 0x99;
|
||||||
|
pysimEventPreview();
|
||||||
|
assert.strictEqual(els['event-send-preview'].value, '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function extractFunc(src, name, asyncFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
|
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 TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
|
eval(extractBlock('const TEST_ACTION_KINDS = [', 'let _testScripts').replace(/^const /gm, 'var '));
|
||||||
eval(extractFunc(html, 'testTemplate'));
|
eval(extractFunc(html, 'testTemplate'));
|
||||||
eval(extractFunc(html, 'testMenuSelectProblem'));
|
eval(extractFunc(html, 'testMenuSelectProblem'));
|
||||||
@@ -44,6 +45,7 @@ eval(extractFunc(html, 'testFormSelect'));
|
|||||||
eval(extractFunc(html, 'testStepRender'));
|
eval(extractFunc(html, 'testStepRender'));
|
||||||
eval(extractFunc(html, 'testRenderChecks'));
|
eval(extractFunc(html, 'testRenderChecks'));
|
||||||
eval(extractFunc(html, 'testStepCollect'));
|
eval(extractFunc(html, 'testStepCollect'));
|
||||||
|
eval(extractFunc(html, 'testStepFormError'));
|
||||||
eval(extractFunc(html, 'testWriteBackCounter'));
|
eval(extractFunc(html, 'testWriteBackCounter'));
|
||||||
eval(extractFunc(html, 'testRunPreset'));
|
eval(extractFunc(html, 'testRunPreset'));
|
||||||
eval(extractFunc(html, 'testScriptsWriteback', true));
|
eval(extractFunc(html, 'testScriptsWriteback', true));
|
||||||
@@ -93,6 +95,12 @@ test('testScriptProblem accepts good scripts and names bad ones', () => {
|
|||||||
params: {} }] }), /item id or text/);
|
params: {} }] }), /item id or text/);
|
||||||
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'menu-select',
|
||||||
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
|
params: { item_id: 1, text: 'My menu' } }] }), /not both/);
|
||||||
|
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||||
|
params: {} }] }), /event is required/);
|
||||||
|
assert.match(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||||
|
params: { event: '03', fields: [] } }] }), /object/);
|
||||||
|
assert.strictEqual(testScriptProblem({ steps: [{ type: 'action', kind: 'event',
|
||||||
|
params: { event: '03', fields: { status: 0 } } }] }), '');
|
||||||
assert.match(testScriptProblem({
|
assert.match(testScriptProblem({
|
||||||
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
steps: [{ type: 'action', kind: 'file-read', params: {} }] }), /file path/);
|
||||||
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
|
assert.match(testScriptProblem({ steps: [{ type: 'expect' }] }), /command is required/);
|
||||||
@@ -201,6 +209,27 @@ test('the menu-select editor collects the item id or the text', () => {
|
|||||||
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
|
assert.deepStrictEqual(_testEditStep.params, { item_id: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the event step collects the type and the JSON fields', () => {
|
||||||
|
_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"}',
|
||||||
|
'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/);
|
||||||
|
// the summary names the event
|
||||||
|
assert.strictEqual(testStepSummary({ type: 'action', kind: 'event',
|
||||||
|
params: { event: '03', fields: {} } }), 'EVENT Location status');
|
||||||
|
});
|
||||||
|
|
||||||
test('the STATUS step leaves the SW check to the server default', () => {
|
test('the STATUS step leaves the SW check to the server default', () => {
|
||||||
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
_testEditStep = { type: 'action', kind: 'status', params: { attempts: 5, interval_ms: 200 } };
|
||||||
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
fakeForm({ 'test-step-kind': 'status', 'test-f-attempts': '5', 'test-f-interval': '200',
|
||||||
@@ -227,6 +256,25 @@ test('the step form renders the chosen source and no pre-filled SW', () => {
|
|||||||
assert.match(body, /id="test-f-sw" value=""/);
|
assert.match(body, /id="test-f-sw" value=""/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the event step form renders the event select and the JSON fields', () => {
|
||||||
|
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: 'event',
|
||||||
|
params: { event: '12', fields: { reg_type: '9' } } };
|
||||||
|
_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);
|
||||||
|
});
|
||||||
|
|
||||||
test('hex fields strip mask wildcards, check values keep them', () => {
|
test('hex fields strip mask wildcards, check values keep them', () => {
|
||||||
_testEditStep = { type: 'action', kind: 'apdu', params: {} };
|
_testEditStep = { type: 'action', kind: 'apdu', params: {} };
|
||||||
fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2??',
|
fakeForm({ 'test-step-kind': 'apdu', 'test-f-apdu': '80E2??',
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-simple-server"
|
name = "pysim-simple-server"
|
||||||
version = "3.17.0"
|
version = "3.18.0"
|
||||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||||
requires-python = ">=3.8"
|
requires-python = ">=3.8"
|
||||||
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Event-data builders for the test scripts' semantic ``event`` action.
|
||||||
|
|
||||||
|
The PWA's Phone tab builds the same objects client-side (``EVENT_FORMS``);
|
||||||
|
this module is the server-side mirror for the events a test script can send
|
||||||
|
semantically. Every other event goes through the raw ``envelope`` action
|
||||||
|
(the Phone form shows its built hex, which can be pasted into ``data``).
|
||||||
|
|
||||||
|
Field sets and defaults follow the PWA's forms and the pinned specs
|
||||||
|
(TS 31.111 7.5.x, TS 102 223 8.x); the byte vectors are pinned in both the
|
||||||
|
Node and the Python suites so the two implementations cannot drift apart.
|
||||||
|
|
||||||
|
Supported events:
|
||||||
|
|
||||||
|
* ``0x03`` Location status - status + optional Location information (8.19,
|
||||||
|
normal service only).
|
||||||
|
* ``0x0B`` Access technology change - the 8.61 single-access-technology byte.
|
||||||
|
* ``0x12`` Network rejection - registration type, access technology, cause,
|
||||||
|
the location area (LAI/RAI/TAI by registration group), the optional
|
||||||
|
extended EMM cause and the extended information object.
|
||||||
|
* ``0x1D`` Data connection status change - status/type/transaction id plus
|
||||||
|
the optional cause, host-clock date-time (8.39), location information,
|
||||||
|
access technology, location status, APN and PDP/PDN/PDU type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
EVENT_LOCATION_STATUS = 0x03
|
||||||
|
EVENT_ACCESS_TECH = 0x0B
|
||||||
|
EVENT_NETWORK_REJECTION = 0x12
|
||||||
|
EVENT_DATA_CONNECTION = 0x1D
|
||||||
|
|
||||||
|
# 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 = {
|
||||||
|
'location status': EVENT_LOCATION_STATUS,
|
||||||
|
'location_status': EVENT_LOCATION_STATUS,
|
||||||
|
'location-status': EVENT_LOCATION_STATUS,
|
||||||
|
'access technology change': EVENT_ACCESS_TECH,
|
||||||
|
'access_tech': EVENT_ACCESS_TECH,
|
||||||
|
'access-technology-change': EVENT_ACCESS_TECH,
|
||||||
|
'network rejection': EVENT_NETWORK_REJECTION,
|
||||||
|
'network_rejection': EVENT_NETWORK_REJECTION,
|
||||||
|
'network-rejection': EVENT_NETWORK_REJECTION,
|
||||||
|
'data connection status change': EVENT_DATA_CONNECTION,
|
||||||
|
'data_connection': EVENT_DATA_CONNECTION,
|
||||||
|
'data-connection-status-change': EVENT_DATA_CONNECTION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventError(ValueError):
|
||||||
|
"""Invalid event parameters - a user-facing message."""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_event(value):
|
||||||
|
"""The event code of a script's `event` value (a hex byte or a name), or
|
||||||
|
None when it is neither."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value if 0 <= value <= 0xFF else None
|
||||||
|
s = str(value or '').strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if re.fullmatch(r'(0x)?[0-9A-Fa-f]{1,2}', s):
|
||||||
|
return int(s, 16)
|
||||||
|
return EVENT_NAMES.get(s.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def event_name(code):
|
||||||
|
"""A display name for an event code (the short keyword)."""
|
||||||
|
for name, c in (('location_status', EVENT_LOCATION_STATUS),
|
||||||
|
('access_tech', EVENT_ACCESS_TECH),
|
||||||
|
('network_rejection', EVENT_NETWORK_REJECTION),
|
||||||
|
('data_connection', EVENT_DATA_CONNECTION)):
|
||||||
|
if c == code:
|
||||||
|
return name
|
||||||
|
return '0x%02X' % code
|
||||||
|
|
||||||
|
|
||||||
|
# ─── field helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DIGITS_RE = re.compile(r'^\d+$')
|
||||||
|
|
||||||
|
|
||||||
|
def _int(fields, key, default, lo, hi):
|
||||||
|
v = fields.get(key)
|
||||||
|
if v in (None, ''):
|
||||||
|
return default
|
||||||
|
text = str(v).strip()
|
||||||
|
try:
|
||||||
|
n = int(text, 16) if text.lower().startswith('0x') else int(text, 10)
|
||||||
|
except ValueError:
|
||||||
|
raise EventError('%s must be an integer' % key)
|
||||||
|
if not lo <= n <= hi:
|
||||||
|
raise EventError('%s must be %d..%d' % (key, lo, hi))
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _hex(fields, key, default, lo_len, hi_len):
|
||||||
|
v = re.sub(r'\s', '', str(fields.get(key) or default or '')).upper()
|
||||||
|
if len(v) % 2 or not (lo_len <= len(v) <= hi_len) or \
|
||||||
|
not re.fullmatch(r'[0-9A-F]+', v):
|
||||||
|
raise EventError('%s must be %d-%d hex digits'
|
||||||
|
% (key, lo_len, hi_len))
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _digits(fields, key, default, lo, hi):
|
||||||
|
v = str(fields.get(key) or default or '').strip()
|
||||||
|
if not _DIGITS_RE.match(v) or not lo <= len(v) <= hi:
|
||||||
|
raise EventError('%s must be %d-%d digits' % (key, lo, hi))
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def plmn_hex(mcc, mnc):
|
||||||
|
"""MCC/MNC -> the 3-byte TS 24.008 BCD PLMN (the PWA's encPlmn: MCC
|
||||||
|
digits swapped, MNC padded to 3 digits with 'F')."""
|
||||||
|
d = mcc.ljust(3, '0')[:3] + mnc.ljust(3, 'F')[:3]
|
||||||
|
return (d[1] + d[0] + d[5] + d[2] + d[4] + d[3]).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_now():
|
||||||
|
return time.localtime()
|
||||||
|
|
||||||
|
|
||||||
|
def _tz_offset_minutes():
|
||||||
|
"""Minutes east of UTC for the host clock."""
|
||||||
|
try:
|
||||||
|
return int(time.localtime().tm_gmtoff // 60)
|
||||||
|
except AttributeError: # Windows
|
||||||
|
return -int(time.timezone // 60)
|
||||||
|
|
||||||
|
|
||||||
|
def scts_bytes(st, tz_min):
|
||||||
|
"""The 7-byte TS 123 040 TP-SCTS (8.39 Date-Time and Time zone): swapped
|
||||||
|
semi-octet BCD digits, the time zone in quarters of an hour with the sign
|
||||||
|
in bit 3."""
|
||||||
|
semi = lambda v: ((v % 10) << 4) | (v // 10)
|
||||||
|
off = int(tz_min)
|
||||||
|
tz = semi(int(round(abs(off) / 15.0)) & 0xFF) | (0x08 if off < 0 else 0)
|
||||||
|
return bytes([semi(st.tm_year % 100), semi(st.tm_mon), semi(st.tm_mday),
|
||||||
|
semi(st.tm_hour), semi(st.tm_min), semi(st.tm_sec), tz])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── the builders ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_location_status(fields):
|
||||||
|
"""TS 31.111 7.5.2: status + Location information (8.19) for normal
|
||||||
|
service only."""
|
||||||
|
status = _int(fields, 'status', 0, 0, 2)
|
||||||
|
out = '9B01%02X' % status
|
||||||
|
if status == 0 and fields.get('mcc') not in (None, ''):
|
||||||
|
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||||
|
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||||
|
lac = _hex(fields, 'lac', '0000', 4, 4)
|
||||||
|
cell = _hex(fields, 'cell', '0001', 4, 4)
|
||||||
|
out += '9307' + plmn_hex(mcc, mnc) + lac + cell
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_access_tech(fields):
|
||||||
|
"""TS 102 223 8.61: the single access technology byte."""
|
||||||
|
tech = _int(fields, 'tech', 0, 0, 255)
|
||||||
|
return 'BF01%02X' % tech
|
||||||
|
|
||||||
|
|
||||||
|
def build_network_rejection(fields):
|
||||||
|
"""TS 31.111 7.5.5 (the PWA form's build): registration type selects the
|
||||||
|
location object - LAI for the location-updating group, RAI for GPRS, TAI
|
||||||
|
for EPS/5GS."""
|
||||||
|
rt = _int(fields, 'reg_type', 0x00, 0x00, 0x11)
|
||||||
|
access_tech = _int(fields, 'access_tech', 0x00, 0, 255)
|
||||||
|
cause = _int(fields, 'cause', 0x02, 0, 255)
|
||||||
|
group = 'lu' if rt <= 0x02 else ('gprs' if rt <= 0x08 else 'eps')
|
||||||
|
mcc = _digits(fields, 'mcc', '250', 3, 3)
|
||||||
|
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||||
|
plmn = plmn_hex(mcc, mnc)
|
||||||
|
out = 'F401%02X' % rt
|
||||||
|
out += 'BF01%02X' % access_tech
|
||||||
|
out += 'F501%02X' % cause
|
||||||
|
if group == 'lu':
|
||||||
|
out += '9305' + plmn + _hex(fields, 'lac', '0000', 4, 4)
|
||||||
|
elif group == 'gprs':
|
||||||
|
out += 'F306' + plmn + _hex(fields, 'lac', '0000', 4, 4) \
|
||||||
|
+ _hex(fields, 'rac', '01', 2, 2)
|
||||||
|
else:
|
||||||
|
tac = _hex(fields, 'tac', '0001', 4, 6)
|
||||||
|
out += '7D0%X' % (3 + len(tac) // 2) + plmn + tac
|
||||||
|
if fields.get('ext_cause') not in (None, ''):
|
||||||
|
out += '5701%02X' % _int(fields, 'ext_cause', 0, 0, 255)
|
||||||
|
ext_type = fields.get('ext_info_type')
|
||||||
|
if ext_type not in (None, ''):
|
||||||
|
ext = _hex(fields, 'ext_info', '', 2, 20)
|
||||||
|
out += 'F2%02X%02X%s' % (1 + len(ext) // 2, _int(fields, 'ext_info_type', 0, 0, 255), ext)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_data_connection(fields, now=None):
|
||||||
|
"""TS 31.111 7.5.25.2 object order; the conditional objects are omitted
|
||||||
|
when left empty. ``now`` (a struct_time) makes the date-time testable."""
|
||||||
|
status = _int(fields, 'status', 0, 0, 2)
|
||||||
|
dtype = _int(fields, 'type', 0, 0, 2)
|
||||||
|
out = '9D01%02X' % status
|
||||||
|
out += 'AA01%02X' % dtype
|
||||||
|
if fields.get('cause') not in (None, ''):
|
||||||
|
out += 'AE01%02X' % _int(fields, 'cause', 0, 0, 255)
|
||||||
|
ti = str(fields.get('ti') or '00').strip().upper()
|
||||||
|
if not re.fullmatch(r'[0-9A-F]{2}', ti):
|
||||||
|
raise EventError('ti must be one hex byte')
|
||||||
|
out += '1C01' + ti
|
||||||
|
if fields.get('datetime') == 'now':
|
||||||
|
out += '2607' + scts_bytes(now or _local_now(), _tz_offset_minutes()).hex().upper()
|
||||||
|
if fields.get('mcc') not in (None, ''):
|
||||||
|
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||||
|
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||||
|
out += '1307' + plmn_hex(mcc, mnc) \
|
||||||
|
+ _hex(fields, 'lac', '0000', 4, 4) + _hex(fields, 'cell', '0001', 4, 4)
|
||||||
|
if fields.get('tech') not in (None, ''):
|
||||||
|
out += 'BF01%02X' % _int(fields, 'tech', 0, 0, 255)
|
||||||
|
out += '9B01%02X' % _int(fields, 'loc_status', 0, 0, 2)
|
||||||
|
if str(fields.get('apn') or '').strip():
|
||||||
|
body = str(fields['apn']).encode('utf-8')
|
||||||
|
if len(body) > 100:
|
||||||
|
raise EventError('apn must be at most 100 bytes')
|
||||||
|
out += 'C7%02X%s' % (len(body), body.hex().upper())
|
||||||
|
if fields.get('pdp_type') not in (None, ''):
|
||||||
|
out += '0B01%02X' % _int(fields, 'pdp_type', 0, 0, 255)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
BUILDERS = {
|
||||||
|
EVENT_LOCATION_STATUS: build_location_status,
|
||||||
|
EVENT_ACCESS_TECH: build_access_tech,
|
||||||
|
EVENT_NETWORK_REJECTION: build_network_rejection,
|
||||||
|
EVENT_DATA_CONNECTION: build_data_connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(event, fields):
|
||||||
|
"""Validate an event's fields (returning the cleaned dict) - the same
|
||||||
|
function the builder runs, so a script fails before the run starts."""
|
||||||
|
fields = dict(fields or {})
|
||||||
|
if event not in BUILDERS:
|
||||||
|
raise EventError('no semantic builder for event 0x%02X - use the '
|
||||||
|
'envelope action with the data hex' % event)
|
||||||
|
BUILDERS[event](fields)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def build(event, fields, now=None):
|
||||||
|
"""The event-data hex for a semantic event action."""
|
||||||
|
fields = normalise(event, fields)
|
||||||
|
if event == EVENT_DATA_CONNECTION:
|
||||||
|
return BUILDERS[event](fields, now=now)
|
||||||
|
return BUILDERS[event](fields)
|
||||||
@@ -23,6 +23,7 @@ from pysim_simple_server import capmem
|
|||||||
from pysim_simple_server import testscript
|
from pysim_simple_server import testscript
|
||||||
from pysim_simple_server import presets
|
from pysim_simple_server import presets
|
||||||
from pysim_simple_server import test_scripts
|
from pysim_simple_server import test_scripts
|
||||||
|
from pysim_simple_server import events
|
||||||
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
||||||
from cmd2.exceptions import CommandSetRegistrationError
|
from cmd2.exceptions import CommandSetRegistrationError
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
|
|||||||
from osmocom.utils import rpad
|
from osmocom.utils import rpad
|
||||||
|
|
||||||
|
|
||||||
VERSION = '3.17.0'
|
VERSION = '3.18.0'
|
||||||
|
|
||||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||||
|
|
||||||
@@ -5582,7 +5583,8 @@ _TEST_RUN = {
|
|||||||
_TEST_LOCK = threading.Lock()
|
_TEST_LOCK = threading.Lock()
|
||||||
_TEST_THREAD = None
|
_TEST_THREAD = None
|
||||||
_TEST_KIND_LABELS = {
|
_TEST_KIND_LABELS = {
|
||||||
'envelope': 'ENVELOPE(Event Download)', 'menu-select': 'ENVELOPE(Menu Selection)',
|
'envelope': 'ENVELOPE(Event Download)', 'event': 'ENVELOPE(Event Download)',
|
||||||
|
'menu-select': 'ENVELOPE(Menu Selection)',
|
||||||
'file-write': 'UPDATE FILE', 'file-read': 'READ FILE', 'apdu': 'APDU',
|
'file-write': 'UPDATE FILE', 'file-read': 'READ FILE', 'apdu': 'APDU',
|
||||||
'scp80': 'SCP80', 'status': 'STATUS', 'cleanup': 'CLEANUP',
|
'scp80': 'SCP80', 'status': 'STATUS', 'cleanup': 'CLEANUP',
|
||||||
}
|
}
|
||||||
@@ -5953,6 +5955,13 @@ def _test_run_action(server, step, ctx):
|
|||||||
bytes.fromhex(p['data']) if p['data'] else None,
|
bytes.fromhex(p['data']) if p['data'] else None,
|
||||||
drain=False)
|
drain=False)
|
||||||
sent = 'ENVELOPE(Event Download) type=0x%02X' % p['event']
|
sent = 'ENVELOPE(Event Download) type=0x%02X' % p['event']
|
||||||
|
elif kind == 'event':
|
||||||
|
# The semantic event download: the builder owns the byte layout.
|
||||||
|
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'))
|
||||||
|
sent = 'ENVELOPE(Event Download) type=0x%02X (%s) data=%s' % (
|
||||||
|
p['event'], events.event_name(p['event']), data_hex)
|
||||||
elif kind == 'menu-select':
|
elif kind == 'menu-select':
|
||||||
item_id = p.get('item_id')
|
item_id = p.get('item_id')
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import re
|
|||||||
|
|
||||||
import gsm0338 # registers the 'gsm03.38' codec (the default alphabet coding)
|
import gsm0338 # registers the 'gsm03.38' codec (the default alphabet coding)
|
||||||
|
|
||||||
|
from pysim_simple_server import events
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ScriptError', 'ACTION_KINDS', 'FAIL_LEVELS', 'POR_CHECKS', 'RESULT_NAMES',
|
'ScriptError', 'ACTION_KINDS', 'FAIL_LEVELS', 'POR_CHECKS', 'RESULT_NAMES',
|
||||||
'normalise_script', 'normalise_step', 'normalise_respond',
|
'normalise_script', 'normalise_step', 'normalise_respond',
|
||||||
@@ -30,8 +32,8 @@ __all__ = [
|
|||||||
'pack_gsm7',
|
'pack_gsm7',
|
||||||
]
|
]
|
||||||
|
|
||||||
ACTION_KINDS = ('envelope', 'menu-select', 'file-write', 'file-read', 'apdu',
|
ACTION_KINDS = ('envelope', 'event', 'menu-select', 'file-write', 'file-read',
|
||||||
'scp80', 'status')
|
'apdu', 'scp80', 'status')
|
||||||
FAIL_LEVELS = ('error', 'warning')
|
FAIL_LEVELS = ('error', 'warning')
|
||||||
POR_CHECKS = ('none', 'ok', 'any')
|
POR_CHECKS = ('none', 'ok', 'any')
|
||||||
|
|
||||||
@@ -218,6 +220,31 @@ def _normalise_params(kind, p):
|
|||||||
raise ScriptError('envelope: event is required')
|
raise ScriptError('envelope: event is required')
|
||||||
return {'event': _int(p['event'], 'envelope event', 0, 255),
|
return {'event': _int(p['event'], 'envelope event', 0, 255),
|
||||||
'data': _data_hex(p.get('data'), 'envelope data', allow_empty=True)}
|
'data': _data_hex(p.get('data'), 'envelope data', allow_empty=True)}
|
||||||
|
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).
|
||||||
|
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)))
|
||||||
|
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}
|
||||||
|
src = str(p.get('src') or '').strip().upper()
|
||||||
|
if src:
|
||||||
|
if not re.fullmatch(r'[0-9A-F]{2}', src):
|
||||||
|
raise ScriptError('event: src must be one hex byte '
|
||||||
|
'(82 terminal / 83 network)')
|
||||||
|
out['src'] = src
|
||||||
|
return out
|
||||||
if kind == 'menu-select':
|
if kind == 'menu-select':
|
||||||
# The item is named by its id, or by its text - the id varies with the
|
# The item is named by its id, or by its text - the id varies with the
|
||||||
# applet's install parameters, so a script can match the text the card
|
# applet's install parameters, so a script can match the text the card
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Server-side event-data builders (the semantic test-script `event` action).
|
||||||
|
|
||||||
|
The byte vectors are generated from the PWA's own `EVENT_FORMS` builders (the
|
||||||
|
Node generator run over `frontend/index.html`) and pinned here, so the two
|
||||||
|
implementations cannot drift apart: every vector below is asserted by the
|
||||||
|
frontend suite too (or was produced by the very code it asserts).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pysim_simple_server import events
|
||||||
|
|
||||||
|
|
||||||
|
def st(year=2026, mon=9, day=30, hour=21, minute=55, sec=0):
|
||||||
|
return time.struct_time((year, mon, day, hour, minute, sec, 0, 0, -1))
|
||||||
|
|
||||||
|
|
||||||
|
class EventVectorTests(unittest.TestCase):
|
||||||
|
def test_location_status(self):
|
||||||
|
cases = [
|
||||||
|
({'status': '0', 'mcc': '250', 'mnc': '01', 'lac': '00FF', 'cell': '0001'},
|
||||||
|
'9B0100930752F01000FF0001'),
|
||||||
|
({'status': '1', 'mcc': '250'}, '9B0101'),
|
||||||
|
({'status': '0'}, '9B0100'),
|
||||||
|
({'status': '0', 'mcc': '310', 'mnc': '410', 'lac': 'ABCD', 'cell': '1234'},
|
||||||
|
'9B01009307130014ABCD1234'),
|
||||||
|
]
|
||||||
|
for fields, want in cases:
|
||||||
|
self.assertEqual(events.build(0x03, fields).upper(), want)
|
||||||
|
|
||||||
|
def test_access_tech(self):
|
||||||
|
for fields, want in [({'tech': '0'}, 'BF0100'), ({'tech': '8'}, 'BF0108'),
|
||||||
|
({'tech': '12'}, 'BF010C')]:
|
||||||
|
self.assertEqual(events.build(0x0B, fields).upper(), want)
|
||||||
|
|
||||||
|
def test_network_rejection(self):
|
||||||
|
cases = [
|
||||||
|
# location updating -> LAI, defaults filled
|
||||||
|
({'reg_type': '0'}, 'F40100BF0100F50102930552F0100000'),
|
||||||
|
# GPRS -> RAI
|
||||||
|
({'reg_type': '3', 'mcc': '262', 'mnc': '02', 'lac': '00FF',
|
||||||
|
'rac': '05', 'access_tech': '3', 'cause': '15'},
|
||||||
|
'F40103BF0103F5010FF30662F22000FF05'),
|
||||||
|
# EPS -> TAI (6-hex TAC) + the extended EMM cause
|
||||||
|
({'reg_type': '9', 'mcc': '310', 'mnc': '260', 'tac': 'A1B2C3',
|
||||||
|
'access_tech': '8', 'cause': '7', 'ext_cause': '22'},
|
||||||
|
'F40109BF0108F501077D06130062A1B2C3570116'),
|
||||||
|
# 5GS -> TAI (4-hex TAC)
|
||||||
|
({'reg_type': '17', 'tac': '0001', 'access_tech': '10', 'cause': '2'},
|
||||||
|
'F40111BF010AF501027D0552F0100001'),
|
||||||
|
# the extended information object (CAG ID)
|
||||||
|
({'reg_type': '9', 'access_tech': '8', 'cause': '2',
|
||||||
|
'ext_info_type': '1', 'ext_info': '00000001'},
|
||||||
|
'F40109BF0108F501027D0552F0100001F2050100000001'),
|
||||||
|
]
|
||||||
|
for fields, want in cases:
|
||||||
|
self.assertEqual(events.build(0x12, fields).upper(), want)
|
||||||
|
|
||||||
|
def test_data_connection(self):
|
||||||
|
cases = [
|
||||||
|
({'status': '0', 'type': '0', 'ti': '00', 'loc_status': '0'},
|
||||||
|
'9D0100AA01001C01009B0100'),
|
||||||
|
({'status': '1', 'type': '2', 'cause': '26', 'ti': '85', 'mcc': '250',
|
||||||
|
'mnc': '01', 'lac': '00FF', 'cell': '0001', 'tech': '8',
|
||||||
|
'loc_status': '1', 'apn': 'internet', 'pdp_type': '3'},
|
||||||
|
'9D0101AA0102AE011A1C0185130752F01000FF0001BF01089B0101'
|
||||||
|
'C708696E7465726E65740B0103'),
|
||||||
|
({'status': '2', 'type': '1', 'ti': '', 'mcc': '250', 'loc_status': '2'},
|
||||||
|
'9D0102AA01011C0100130752F010000000019B0102'),
|
||||||
|
({'status': '0', 'type': '2', 'ti': 'AA', 'mcc': '262', 'mnc': '01',
|
||||||
|
'lac': '0001', 'cell': '0002', 'tech': '7', 'loc_status': '1',
|
||||||
|
'apn': 'web.operator.example', 'pdp_type': '4'},
|
||||||
|
'9D0100AA01021C01AA130762F21000010002BF01079B0101'
|
||||||
|
'C7147765622E6F70657261746F722E6578616D706C650B0104'),
|
||||||
|
]
|
||||||
|
for fields, want in cases:
|
||||||
|
self.assertEqual(events.build(0x1D, fields).upper(), want)
|
||||||
|
|
||||||
|
def test_the_host_clock_date_time_object(self):
|
||||||
|
# the PWA encoder's vector: UTC+3, 2026-09-30 21:55:00
|
||||||
|
self.assertEqual(events.scts_bytes(st(), 180).hex().upper(), '62900312550021')
|
||||||
|
# UTC-2:30: 10 quarters with the sign bit
|
||||||
|
self.assertEqual(events.scts_bytes(st(2026, 1, 5, 8, 7, 9), -150).hex().upper(),
|
||||||
|
'62105080709009')
|
||||||
|
# the data-connection builder embeds it as the 8.39 object 26 07 ...
|
||||||
|
out = events.build(0x1D, {'status': '0', 'type': '0', 'ti': '00',
|
||||||
|
'loc_status': '0', 'datetime': 'now'},
|
||||||
|
now=st()).upper()
|
||||||
|
self.assertRegex(out, r'^9D0100AA01001C01002607[0-9A-F]{14}9B0100$')
|
||||||
|
|
||||||
|
def test_plmn_encoding(self):
|
||||||
|
self.assertEqual(events.plmn_hex('250', '01'), '52F010')
|
||||||
|
self.assertEqual(events.plmn_hex('310', '410'), '130014')
|
||||||
|
self.assertEqual(events.plmn_hex('262', '02'), '62F220')
|
||||||
|
self.assertEqual(events.plmn_hex('310', '260'), '130062')
|
||||||
|
|
||||||
|
|
||||||
|
class EventValidationTests(unittest.TestCase):
|
||||||
|
def test_resolve_event_accepts_names_and_hex(self):
|
||||||
|
self.assertEqual(events.resolve_event('location status'), 0x03)
|
||||||
|
self.assertEqual(events.resolve_event('Location_Status'), 0x03)
|
||||||
|
self.assertEqual(events.resolve_event('0B'), 0x0B)
|
||||||
|
self.assertEqual(events.resolve_event('0x12'), 0x12)
|
||||||
|
self.assertEqual(events.resolve_event(0x1D), 0x1D)
|
||||||
|
self.assertIsNone(events.resolve_event('nope'))
|
||||||
|
self.assertIsNone(events.resolve_event(300))
|
||||||
|
self.assertIsNone(events.resolve_event(True))
|
||||||
|
self.assertIsNone(events.resolve_event(''))
|
||||||
|
|
||||||
|
def test_field_validation(self):
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x03, {'status': '9'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x03, {'status': '0', 'mcc': '25'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x0B, {'tech': '300'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x12, {'reg_type': '0x12'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x12, {'reg_type': '9', 'tac': 'ABC'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x12, {'reg_type': '9', 'ext_info_type': '1', 'ext_info': 'A'})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x1D, {'status': '0', 'apn': 'x' * 101})
|
||||||
|
with self.assertRaises(events.EventError):
|
||||||
|
events.build(0x1D, {'status': '0', 'ti': 'ZZ'})
|
||||||
|
# an event without a semantic builder is refused with a hint
|
||||||
|
with self.assertRaises(events.EventError) as ctx:
|
||||||
|
events.normalise(0x05, {})
|
||||||
|
self.assertIn('envelope', str(ctx.exception))
|
||||||
|
|
||||||
|
def test_normalise_returns_the_validated_fields(self):
|
||||||
|
fields = {'status': '1', 'mcc': '250'}
|
||||||
|
self.assertEqual(events.normalise(0x03, fields), fields)
|
||||||
|
self.assertEqual(events.normalise(0x0B, None), {})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -981,6 +981,19 @@ class PresetStoreHttpTests(unittest.TestCase):
|
|||||||
self.assertEqual(status, 503, resp)
|
self.assertEqual(status, 503, resp)
|
||||||
|
|
||||||
|
|
||||||
|
class TestActionKindsDriftTests(unittest.TestCase):
|
||||||
|
"""The PWA's test-script action kinds must match the engine's (a missing
|
||||||
|
kind in either side breaks scripts silently)."""
|
||||||
|
|
||||||
|
def test_the_pwa_and_the_server_share_the_action_kinds(self):
|
||||||
|
from pysim_simple_server import testscript
|
||||||
|
html = (ROOT / 'frontend' / 'index.html').read_text(encoding='utf-8')
|
||||||
|
m = re.search(r"const TEST_ACTION_KINDS = \[(.*?)\];", html)
|
||||||
|
self.assertIsNotNone(m, 'TEST_ACTION_KINDS not found in the PWA')
|
||||||
|
pwa = re.findall(r"'([a-z0-9-]+)'", m.group(1))
|
||||||
|
self.assertEqual(pwa, list(testscript.ACTION_KINDS))
|
||||||
|
|
||||||
|
|
||||||
class TestScriptStoreHttpTests(unittest.TestCase):
|
class TestScriptStoreHttpTests(unittest.TestCase):
|
||||||
"""The test script endpoints (v3.16.0): the PWA's Test script pill talks
|
"""The test script endpoints (v3.16.0): the PWA's Test script pill talks
|
||||||
to the server store instead of localStorage, and a run may name a stored
|
to the server store instead of localStorage, and a run may name a stored
|
||||||
|
|||||||
@@ -140,6 +140,34 @@ class TestValidation(unittest.TestCase):
|
|||||||
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
|
T.normalise_script({'steps': [{'type': 'action', 'kind': 'menu-select',
|
||||||
'params': {'text': 'x', 'mode': 'nope'}}]}, _resolver)
|
'params': {'text': 'x', 'mode': 'nope'}}]}, _resolver)
|
||||||
|
|
||||||
|
def test_event_action_normalises_the_semantic_fields(self):
|
||||||
|
script = T.normalise_script({'steps': [
|
||||||
|
{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': 'location status',
|
||||||
|
'fields': {'status': 1, 'mcc': '250'}}},
|
||||||
|
{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': '0x1D', 'src': '83',
|
||||||
|
'fields': {'status': '0', 'type': '0', 'ti': '00',
|
||||||
|
'loc_status': '0'}}},
|
||||||
|
]}, _resolver)
|
||||||
|
p = script['steps'][0]['params']
|
||||||
|
self.assertEqual(p['event'], 0x03)
|
||||||
|
self.assertEqual(p['fields'], {'status': 1, 'mcc': '250'})
|
||||||
|
self.assertNotIn('src', p)
|
||||||
|
p = script['steps'][1]['params']
|
||||||
|
self.assertEqual(p['event'], 0x1D)
|
||||||
|
self.assertEqual(p['src'], '83')
|
||||||
|
# an unknown name, a bad field and a bad source are refused before the run
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': 'nope'}}]}, _resolver)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': '0x03', 'fields': {'status': 9}}}]}, _resolver)
|
||||||
|
with self.assertRaises(T.ScriptError):
|
||||||
|
T.normalise_script({'steps': [{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': 3, 'src': 'zz'}}]}, _resolver)
|
||||||
|
|
||||||
def test_scp80_keyset_number_and_new_content_checks(self):
|
def test_scp80_keyset_number_and_new_content_checks(self):
|
||||||
# kvn (1..15) selects the preset's keyset; `por`/`files` are expect
|
# kvn (1..15) selects the preset's keyset; `por`/`files` are expect
|
||||||
# content checks (the PoR contents / the REFRESH file list).
|
# content checks (the PoR contents / the REFRESH file list).
|
||||||
@@ -482,6 +510,30 @@ class TestRunnerActions(RunnerTestCase):
|
|||||||
self.assertIn('2 items match', note)
|
self.assertIn('2 items match', note)
|
||||||
self.assertIn('use the item id', note)
|
self.assertIn('use the item id', note)
|
||||||
|
|
||||||
|
def test_event_action_sends_the_built_object(self):
|
||||||
|
scc = FakeScc()
|
||||||
|
scc.push('80C2', '', '9000')
|
||||||
|
run = self.run_script(FakeServer(scc), [
|
||||||
|
{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': 'location status',
|
||||||
|
'fields': {'status': '0', 'mcc': '250', 'mnc': '01',
|
||||||
|
'lac': '00FF', 'cell': '0001'}}},
|
||||||
|
])
|
||||||
|
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||||
|
apdu = scc.sent[0].upper()
|
||||||
|
# event list + device identities (terminal default) + the built object
|
||||||
|
self.assertIn('99010382028281' + '9B0100930752F01000FF0001', apdu)
|
||||||
|
# the src override names the network as the source
|
||||||
|
scc2 = FakeScc()
|
||||||
|
scc2.push('80C2', '', '9000')
|
||||||
|
run = self.run_script(FakeServer(scc2), [
|
||||||
|
{'type': 'action', 'kind': 'event',
|
||||||
|
'params': {'event': '0x0B', 'fields': {'tech': '8'}, 'src': '83'}},
|
||||||
|
])
|
||||||
|
self.assertEqual(run['status'], 'ok', run['steps'])
|
||||||
|
apdu = scc2.sent[0].upper()
|
||||||
|
self.assertIn('99010B82028381' + 'BF0108', apdu)
|
||||||
|
|
||||||
def test_scp80_uses_the_preset_and_advances_the_counter(self):
|
def test_scp80_uses_the_preset_and_advances_the_counter(self):
|
||||||
scc = FakeScc()
|
scc = FakeScc()
|
||||||
server = FakeServer(scc)
|
server = FakeServer(scc)
|
||||||
|
|||||||
Reference in New Issue
Block a user