Compare commits
19 Commits
f16e24ba11
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0370fa58ad | |||
| 2213e25385 | |||
| bf8abbb0fd | |||
| a9a4d03b09 | |||
| fb6f80ef1d | |||
| 2494e04984 | |||
| b49e6728b4 | |||
| dfb9551eb7 | |||
| 8459040856 | |||
| a5cccee17c | |||
| 2a559371ae | |||
| e0e86e31af | |||
| 2ec7a2d6c0 | |||
| 26f0063696 | |||
| ddc6cb8f9d | |||
| 85a66af7a5 | |||
| 92271d6726 | |||
| 4598a70db2 | |||
| 82fb2c8800 |
@@ -31,7 +31,7 @@ npm run build
|
|||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
Five top-level tabs: **Remote APDU**, **SCP80**, **Card reader**, **Profiler**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -490,6 +490,9 @@ Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and
|
|||||||
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
- **Read** — reads the selected file (auto-detects transparent vs record files)
|
||||||
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back
|
||||||
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
|
||||||
|
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
|
||||||
|
- Missing files are shown in red (✗); a present but empty DF shows `(empty)`
|
||||||
|
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
|
||||||
|
|
||||||
### Command Hints
|
### Command Hints
|
||||||
|
|
||||||
@@ -501,7 +504,7 @@ Type a command name in the **pySim command line** input. Usage hints appear as a
|
|||||||
|
|
||||||
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
|
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
|
||||||
|
|
||||||
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
- **New profile** creates an empty ruleset; **Profile from card** scans the equipped card and generates one rule per existing file; **Profile from snapshot** generates the same ruleset from a saved snapshot (same ignore/mask/FCP-FCI options, no card reader, name prefilled from the snapshot); **Import profile** loads a ruleset from JSON (the name is stored inside the file).
|
||||||
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
|
- Each profile row has **Check card ▶** (run against the equipped card), **Check card snapshot** (run offline against a saved snapshot), **Edit**, **Export**, and **Delete**.
|
||||||
|
|
||||||
A filesystem rule is defined by:
|
A filesystem rule is defined by:
|
||||||
@@ -511,7 +514,7 @@ A filesystem rule is defined by:
|
|||||||
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
|
- **File attributes** — file type, size, record length and record count, taken from the FCP template (any may be left unset).
|
||||||
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
|
- **Check contents** (optional) — **Exact** hex equality, or **Mask** where `?` is a per-nibble wildcard (a mask with no `?` is a prefix match, e.g. `0891` for the IMSI MCC/MNC). Record files store a per-record list.
|
||||||
|
|
||||||
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
The check report marks each verified aspect (e.g. *filetype ✓, size ✗, contents ✓*), lists mismatches as read-only monospace expected/actual fields aligned in one column, and shows a decoded per-parameter FCI comparison for FCI mismatches. Corrupt FCI data shows whatever decoded before the faulty part plus an explicit decode-failure note; record mismatches list the *matching records*. In the report the mismatch fields and FCI comparison columns are labelled `expected (profile name)` and `actual (card ICCID)` for a live check, or `actual (snapshot name)` for a snapshot check; the results header reads `Profile verification results for: <profile> → <card ICCID>` (or `… → <snapshot name>`; snapshot comparison: `Snapshot comparison results: <master> → <checked>`). **Only mismatches** in the results header hides all passing files and keeps failures and errors only.
|
||||||
|
|
||||||
#### “Profile from card” scan options
|
#### “Profile from card” scan options
|
||||||
|
|
||||||
@@ -538,7 +541,8 @@ Files not in pysim's model can be added manually:
|
|||||||
1. Switch to the **Custom files** tab in the Profiler list
|
1. Switch to the **Custom files** tab in the Profiler list
|
||||||
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
|
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
|
||||||
3. Click **Add** — the file appears in the tree in italics (unverified)
|
3. Click **Add** — the file appears in the tree in italics (unverified)
|
||||||
4. Click the file to verify existence — on success, it behaves like a model file
|
4. Use **Edit** on a row to reload it into the form (the button becomes **Save** and a **Cancel** button appears) or **Delete** to remove it
|
||||||
|
5. Click the file to verify existence — on success, it behaves like a model file
|
||||||
|
|
||||||
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
|
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -31,7 +31,7 @@ npm run build
|
|||||||
|
|
||||||
## Интерфейс
|
## Интерфейс
|
||||||
|
|
||||||
Пять вкладок: **Remote APDU**, **SCP80**, **Card reader**, **Profiler** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -464,6 +464,9 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
- **Read** — чтение файла (автоопределение transparent/record)
|
- **Read** — чтение файла (автоопределение transparent/record)
|
||||||
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
|
||||||
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
|
||||||
|
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
|
||||||
|
- Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
|
||||||
|
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
|
||||||
|
|
||||||
### Подсказки команд
|
### Подсказки команд
|
||||||
|
|
||||||
@@ -475,7 +478,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
|
|
||||||
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
|
||||||
|
|
||||||
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
- **Новый профиль** создаёт пустой набор правил; **Профиль с карты** сканирует подключённую карту и создаёт по правилу на каждый существующий файл; **Профиль из снимка** создаёт тот же набор правил из сохранённого снимка (те же опции игнорирования/масок/FCP-FCI, без картридера, имя подставляется из снимка); **Импорт профиля** загружает набор из JSON (имя хранится внутри файла).
|
||||||
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
|
- В каждой строке профиля: **Проверить карту ▶** (на подключённой карте), **Проверить снимок карты** (offline по сохранённому снимку), **Редактировать**, **Экспорт** и **Удалить**.
|
||||||
|
|
||||||
Правило файловой системы задаётся:
|
Правило файловой системы задаётся:
|
||||||
@@ -485,7 +488,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
- **Атрибуты файла** — тип, размер, длина и число записей из шаблона FCP (любое можно не задавать).
|
||||||
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
- **Проверка содержимого** (опционально) — **Exact** (точное равенство hex) или **Mask**, где `?` — пониббловый джокер (маска без `?` — префиксное совпадение, напр. `0891` для MCC/MNC IMSI). Для record-файлов хранится список записей.
|
||||||
|
|
||||||
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
Отчёт проверки помечает каждый аспект (напр. *тип файла ✓, размер ✗, содержимое ✓*), показывает расхождения как поля только для чтения (ожидаемое/фактическое в одной колонке) и декодированное сравнение параметров FCI для расхождений FCI. Повреждённые FCI показывают всё, что удалось декодировать, плюс явное сообщение об ошибке; для записей указываются *совпадающие записи*. В отчёте поля расхождений и колонки сравнения FCI подписаны `ожидалось (имя профиля)` и `фактически (ICCID карты)` для проверки карты либо `фактически (имя снимка)` для проверки снимка; в заголовке отчёта — `Результаты проверки профиля: <профиль> → <ICCID карты>` (или `… → <имя снимка>`; для сравнения снимков — `Результаты сравнения снимков: <эталон> → <проверяемый>`). Опция **«Только расхождения»** скрывает все совпавшие файлы, оставляя несовпадения и ошибки.
|
||||||
|
|
||||||
#### Опции сканирования «Профиль с карты»
|
#### Опции сканирования «Профиль с карты»
|
||||||
|
|
||||||
@@ -512,7 +515,8 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
|
|||||||
1. Перейдите на вкладку **Custom files** в списке профайлера
|
1. Перейдите на вкладку **Custom files** в списке профайлера
|
||||||
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
|
||||||
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
|
||||||
4. Кликните для проверки существования — при успехе работает как обычный файл
|
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
|
||||||
|
5. Кликните для проверки существования — при успехе работает как обычный файл
|
||||||
|
|
||||||
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
|
|||||||
|
|
||||||
**Example response:**
|
**Example response:**
|
||||||
```json
|
```json
|
||||||
{"version": "2.0.0"}
|
{"version": "2.1.2"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/status`
|
### `GET /api/status`
|
||||||
@@ -272,7 +272,7 @@ Returns the current STK session state.
|
|||||||
Read file content. Auto-detects transparent vs record files.
|
Read file content. Auto-detects transparent vs record files.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"}
|
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns transparent data:
|
Returns transparent data:
|
||||||
@@ -298,12 +298,12 @@ it for snapshot timing statistics. Other commands are not reported.
|
|||||||
Write raw hex data to a file.
|
Write raw hex data to a file.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"}
|
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
For record files:
|
For record files:
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"}
|
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -316,9 +316,19 @@ Returns:
|
|||||||
Select a file by name or FID, with optional parent selection.
|
Select a file by name or FID, with optional parent selection.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"}
|
{"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`parent_path` lists the path segments from MF to the parent (ADF names or
|
||||||
|
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
|
||||||
|
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
|
||||||
|
are selected through the requested parent only (pySim `select_file()`), never
|
||||||
|
via pySim's global selectables or its `probe_file()` model injection, so a
|
||||||
|
same-FID file under another parent is never picked and the filesystem model
|
||||||
|
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
|
||||||
|
model-unknown 4-hex FID to be selected directly; any temporary model object
|
||||||
|
created for it is detached again before the response is sent.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
```json
|
```json
|
||||||
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
|
||||||
@@ -340,6 +350,9 @@ Get directory listing with typed children.
|
|||||||
{"name": "MF", "fid": "3F00"}
|
{"name": "MF", "fid": "3F00"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
|
||||||
|
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
```json
|
```json
|
||||||
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<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> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
|
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
|
||||||
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
|
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
|
||||||
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>), <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>) и <strong>«Симулятор телефона»</strong>.</li>
|
<li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>«Парсер ответов»</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>«Профайлер»</strong> (вкладки <strong>«Профили»</strong>, <strong>«Снимки карт»</strong>, <strong>«Пользовательские файлы»</strong>), <strong>«Картридер»</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>«Симулятор телефона»</strong>.</li>
|
||||||
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §5).</li>
|
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка «Профайлер» открывает §5).</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -317,11 +317,12 @@
|
|||||||
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>«Профайлер»</strong> и <strong>«Симулятор телефона»</strong> — отдельные вкладки верхнего уровня.</p>
|
||||||
|
|
||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
|
||||||
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>).</p>
|
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</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> — чтение файла (автоопределение transparent/record)</li>
|
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
|
||||||
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
|
||||||
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
|
||||||
|
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
|
||||||
@@ -375,6 +376,7 @@
|
|||||||
<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> — создаёт пустой набор правил, запросив имя.</li>
|
<li><strong>Новый профиль</strong> — создаёт пустой набор правил, запросив имя.</li>
|
||||||
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
<li><strong>Профиль с карты</strong> — сканирует подключённую карту и создаёт по одному правилу на каждый существующий файл (см. ниже), затем открывает редактор.</li>
|
||||||
|
<li><strong>Профиль из снимка</strong> — выбирает сохранённый снимок карты и создаёт по правилу на каждый захваченный файл с теми же опциями сканирования (см. ниже), без картридера; имя профиля подставляется из имени снимка.</li>
|
||||||
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
<li><strong>Импорт профиля</strong> — загружает набор правил из JSON-файла (имя хранится внутри JSON).</li>
|
||||||
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
<li>В каждой строке профиля показаны имя и время создания, а также действия <strong>Проверить карту ▶</strong>, <strong>Проверить снимок карты</strong>, <strong>Редактировать</strong>, <strong>Экспорт</strong> (скачать JSON) и <strong>Удалить</strong>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -386,9 +388,9 @@
|
|||||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
||||||
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
<li><strong>Проверить содержимое</strong> (опционально) — <strong>Точное</strong> (точное совпадение hex) или <strong>Маска</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
|
<p class="text-sm mb-3"><strong>Проверить карту</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>полный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCI также отображается рядом с полем FCI hex при редактировании правила. Если данные FCI повреждены, показывается всё, что удалось декодировать до места ошибки, вместе с явным сообщением об ошибке декодирования. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали. В отчёте поля расхождений и колонки сравнения FCI подписаны <em>ожидалось (имя профиля)</em> и <em>фактически (ICCID карты)</em>, а в заголовке отчёта выводится <em>Результаты проверки профиля: <профиль> → <ICCID карты></em>. Опция <strong>«Только расхождения»</strong> в заголовке отчёта скрывает все совпавшие файлы и оставляет только несовпадения и ошибки.</p>
|
||||||
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
<h4 class="font-medium mb-1">Опции сканирования «Профиль с карты»</h4>
|
||||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования.</p>
|
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«Проверка FCP/FCI»</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Игнорировать содержимое файлов»</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>«Сравнивать первые 4 байта для»</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>«Пользовательские файлы»</strong> включаются с той же проверкой существования. Тот же диалог и опции использует <strong>«Профиль из снимка»</strong>: вместо карты обходятся захваченные файлы выбранного снимка; для файлов, содержимое которых не было захвачено, правило создаётся без проверки содержимого (при последующей проверке профиля они помечаются как непроверяемые).</p>
|
||||||
|
|
||||||
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
|
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
|
||||||
<p class="text-sm mb-2">Представление списка имеет две вкладки — <strong>«Профили»</strong> и <strong>«Снимки карт»</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
|
<p class="text-sm mb-2">Представление списка имеет две вкладки — <strong>«Профили»</strong> и <strong>«Снимки карт»</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
|
||||||
@@ -396,8 +398,8 @@
|
|||||||
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
<li><strong>Новый снимок</strong> — запрашивает имя и сканирует карту, затем возвращает к списку.</li>
|
||||||
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
<li><strong>Импорт снимка</strong> — загружает снимок из JSON-файла.</li>
|
||||||
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
|
||||||
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты: фактическая сторона подписана именем снимка (<em>фактически (имя снимка)</em>), а в заголовке — <em>Результаты проверки профиля: <профиль> → <имя снимка></em>; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
|
||||||
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте заголовок — <em>Результаты сравнения снимков: <эталон> → <проверяемый></em>, а поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -405,7 +407,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
|
||||||
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве «Файловый менеджер». В каждой строке есть кнопки <strong>«Редактировать»</strong> (загружает запись в форму — кнопка становится <strong>«Сохранить»</strong>, появляется <strong>«Отмена»</strong>) и <strong>«Удалить»</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
|
||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
|
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
|
||||||
|
|||||||
+9
-7
@@ -44,7 +44,7 @@
|
|||||||
<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>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
|
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
|
||||||
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
|
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
|
||||||
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), and <strong>Phone simulator</strong>.</li>
|
<li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
|
||||||
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens §5).</li>
|
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens §5).</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -317,11 +317,12 @@
|
|||||||
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
|
||||||
|
|
||||||
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
|
||||||
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>).</p>
|
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</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>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
|
||||||
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
|
||||||
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
|
||||||
|
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
|
||||||
@@ -375,6 +376,7 @@
|
|||||||
<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>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
<li><strong>New profile</strong> — creates an empty ruleset after prompting for a name.</li>
|
||||||
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
<li><strong>Profile from card</strong> — scans the equipped card and generates one rule per existing file (see below), then opens the editor.</li>
|
||||||
|
<li><strong>Profile from snapshot</strong> — picks a saved card snapshot and generates one rule per captured file using the same scan options (see below), without a card reader; the profile name is prefilled with the snapshot name.</li>
|
||||||
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
<li><strong>Import profile</strong> — loads a ruleset from a JSON file (the name is stored inside the JSON).</li>
|
||||||
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
<li>Each profile row shows its name and creation time, with <strong>Check card ▶</strong>, <strong>Check card snapshot</strong>, <strong>Edit</strong>, <strong>Export</strong> (download JSON), and <strong>Delete</strong> actions.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -386,9 +388,9 @@
|
|||||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||||
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
<li><strong>Check contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
<p class="text-sm mb-3"><strong>Check card</strong> runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCI preview is also shown beside the FCI hex field while editing a rule. If the FCI data is corrupt, whatever was decoded before the faulty part is shown together with an explicit decode-failure note. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match. In the report the mismatch fields and FCI comparison columns are labelled <em>expected (profile name)</em> and <em>actual (card ICCID)</em>, and the results header reads <em>Profile verification results for: <profile> → <card ICCID></em>. An <strong>Only mismatches</strong> option in the results header hides all passing files and keeps only failures and errors.</p>
|
||||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of files”</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>“Match first 4 bytes for”</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check. The same dialog and options are used by <strong>Profile from snapshot</strong>, which walks the selected snapshot’s captured files instead of the card; rules for files whose contents were not captured during the scan get no content check (they are reported as unverifiable when the profile is later checked).</p>
|
||||||
|
|
||||||
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
|
||||||
<p class="text-sm mb-2">The list view has two tabs — <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
|
<p class="text-sm mb-2">The list view has two tabs — <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
|
||||||
@@ -396,8 +398,8 @@
|
|||||||
<li><strong>New snapshot</strong> — asks for a name and scans the card, then returns to the list.</li>
|
<li><strong>New snapshot</strong> — asks for a name and scans the card, then returns to the list.</li>
|
||||||
<li><strong>Import snapshot</strong> — loads a snapshot from a JSON file.</li>
|
<li><strong>Import snapshot</strong> — loads a snapshot from a JSON file.</li>
|
||||||
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
|
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
|
||||||
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
|
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check: the actual side is labelled with the snapshot name (<em>actual (snapshot name)</em>) and the header reads <em>Profile verification results for: <profile> → <snapshot name></em>; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
|
||||||
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
|
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the header reads <em>Snapshot comparison results: <master> → <checked></em> and the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -405,7 +407,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
|
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
|
||||||
<p class="text-sm mb-3">Add files that pySim’s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
<p class="text-sm mb-3">Add files that pySim’s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste & import</strong> / <strong>Import JSON from clipboard</strong>.</p>
|
||||||
|
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
|
<h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
|
||||||
|
|||||||
+580
-253
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'otaman-v113';
|
const CACHE = 'otaman-v132';
|
||||||
const URLS = [
|
const URLS = [
|
||||||
'index.html',
|
'index.html',
|
||||||
'help.html',
|
'help.html',
|
||||||
|
|||||||
@@ -22,23 +22,29 @@ function extractFunc(src, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
|
||||||
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n';
|
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
|
||||||
|
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
|
||||||
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
|
||||||
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
|
||||||
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
code += extractFunc(html, 'pysimControlDisabled') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
|
||||||
code += '\nglobalThis.esc = s => s;\n';
|
code += '\nglobalThis.esc = s => s;\n';
|
||||||
code += 'globalThis.t = s => s;\n';
|
code += 'globalThis.t = s => s;\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const el = { textContent: 'status line', innerHTML: '' };
|
const el = { textContent: 'status line', innerHTML: '' };
|
||||||
const calls = { connected: [], resets: [], refreshStatus: [] };
|
const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
|
||||||
_pysimCardStateKey = null;
|
_pysimCardStateKey = null;
|
||||||
_pysimCardSession = null;
|
_pysimCardSession = null;
|
||||||
|
_pysimProactiveSeq = null;
|
||||||
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
|
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
|
||||||
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
globalThis.pysimSetConnected = v => calls.connected.push(v);
|
||||||
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
|
||||||
globalThis.pysimApplyAvailability = () => {};
|
globalThis.pysimApplyAvailability = () => {};
|
||||||
|
globalThis.isViewVisible = () => true;
|
||||||
|
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
|
||||||
return { el, calls };
|
return { el, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,3 +133,38 @@ test('no card with auto-equip enabled still shows the no-card message', () => {
|
|||||||
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
|
||||||
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
|
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('proactive log refreshes when the status sequence changes', () => {
|
||||||
|
const { calls } = setup();
|
||||||
|
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||||
|
pysimCardStateUpdate(status({ proactive_seq: 7 }));
|
||||||
|
assert.strictEqual(calls.proactive, 1);
|
||||||
|
pysimCardStateUpdate(status({ proactive_seq: 8 }));
|
||||||
|
assert.strictEqual(calls.proactive, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proactive log is not refreshed while the phone view is hidden', () => {
|
||||||
|
const { calls } = setup();
|
||||||
|
globalThis.isViewVisible = () => false;
|
||||||
|
pysimCardStateUpdate(status({ proactive_seq: 3 }));
|
||||||
|
assert.strictEqual(calls.proactive, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimProactiveSeqChanged tracks the last sequence', () => {
|
||||||
|
_pysimProactiveSeq = null;
|
||||||
|
assert.ok(pysimProactiveSeqChanged(4));
|
||||||
|
assert.ok(!pysimProactiveSeqChanged(4));
|
||||||
|
assert.ok(pysimProactiveSeqChanged(5));
|
||||||
|
assert.ok(!pysimProactiveSeqChanged(undefined));
|
||||||
|
assert.ok(!pysimProactiveSeqChanged(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimStkStatusChanged detects menu state transitions', () => {
|
||||||
|
_pysimStkSig = null;
|
||||||
|
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
|
||||||
|
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
|
||||||
|
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||||
|
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
|
||||||
|
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
|
||||||
|
assert.ok(!pysimStkStatusChanged(null));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\n';
|
||||||
|
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
|
||||||
|
code += extractFunc(html, fn) + '\n';
|
||||||
|
}
|
||||||
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
function fakeEl(id) {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
value: '',
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
attrs: {},
|
||||||
|
focused: 0,
|
||||||
|
classList: {
|
||||||
|
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||||
|
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
},
|
||||||
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
|
focus() { this.focused++; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(entries) {
|
||||||
|
const els = {
|
||||||
|
'pysim-cf-path': fakeEl('pysim-cf-path'),
|
||||||
|
'pysim-cf-name': fakeEl('pysim-cf-name'),
|
||||||
|
'pysim-cf-list': fakeEl('pysim-cf-list'),
|
||||||
|
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
|
||||||
|
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
|
||||||
|
};
|
||||||
|
els['pysim-cf-cancel-btn'].classList.add('hidden');
|
||||||
|
const store = {};
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: k => (k in store ? store[k] : null),
|
||||||
|
setItem: (k, v) => { store[k] = String(v); },
|
||||||
|
};
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
globalThis.alertCalls = [];
|
||||||
|
globalThis.alert = m => { globalThis.alertCalls.push(m); };
|
||||||
|
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
|
||||||
|
pysimCustomEditIndex = null;
|
||||||
|
return els;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
|
||||||
|
|
||||||
|
test('rows render Edit and Delete buttons instead of the X glyph', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomRender();
|
||||||
|
const out = els['pysim-cf-list'].innerHTML;
|
||||||
|
assert.match(out, /pysimCustomEdit\(0\)/);
|
||||||
|
assert.match(out, /pysimCustomRemove\(0\)/);
|
||||||
|
assert.match(out, />Edit</);
|
||||||
|
assert.match(out, />Delete</);
|
||||||
|
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit fills the top form and switches it to Save mode', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||||
|
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
|
||||||
|
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
|
||||||
|
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||||
|
assert.strictEqual(els['pysim-cf-path'].focused, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit in edit mode updates the entry in place', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
els['pysim-cf-path'].value = '3F00/7F20/6F46';
|
||||||
|
els['pysim-cf-name'].value = 'EF.SPNX';
|
||||||
|
pysimCustomSubmit();
|
||||||
|
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||||
|
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, null);
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||||
|
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving an edited entry with its own path is not a duplicate', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
pysimCustomSubmit();
|
||||||
|
assert.strictEqual(pysimCustomFiles.length, 1);
|
||||||
|
assert.deepStrictEqual(globalThis.alertCalls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing to another entry path is rejected as duplicate', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
els['pysim-cf-path'].value = '3F00/6F44';
|
||||||
|
pysimCustomSubmit();
|
||||||
|
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
|
||||||
|
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel restores the Add mode and clears the inputs', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
pysimCustomEditCancel();
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, null);
|
||||||
|
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||||
|
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
|
||||||
|
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the entry being edited cancels the edit', () => {
|
||||||
|
const els = setup([entry('6F46', 'EF.SPN')]);
|
||||||
|
pysimCustomEdit(0);
|
||||||
|
pysimCustomRemove(0);
|
||||||
|
assert.deepStrictEqual(pysimCustomFiles, []);
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, null);
|
||||||
|
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting before the edited row shifts the edit index', () => {
|
||||||
|
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
|
||||||
|
pysimCustomEdit(2);
|
||||||
|
pysimCustomRemove(0);
|
||||||
|
assert.strictEqual(pysimCustomEditIndex, 1);
|
||||||
|
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('submit adds a new entry when not editing', () => {
|
||||||
|
const els = setup([]);
|
||||||
|
els['pysim-cf-path'].value = '3f00/6f46';
|
||||||
|
els['pysim-cf-name'].value = 'EF.SPN';
|
||||||
|
pysimCustomSubmit();
|
||||||
|
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
|
||||||
|
assert.strictEqual(els['pysim-cf-path'].value, '');
|
||||||
|
assert.strictEqual(els['pysim-cf-name'].value, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = '';
|
||||||
|
code += extractFunc(html, 'getParentSel') + '\n';
|
||||||
|
code += extractFunc(html, 'getParentPath') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
|
||||||
|
code += 'globalThis.pysimCustomInject = () => {};\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
let calls = [];
|
||||||
|
let responses = [];
|
||||||
|
let renders = 0;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
calls = [];
|
||||||
|
responses = [];
|
||||||
|
renders = 0;
|
||||||
|
globalThis.pysimFetch = async (p, body) => {
|
||||||
|
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
|
||||||
|
const r = responses.shift();
|
||||||
|
if (r instanceof Error) throw r;
|
||||||
|
return JSON.parse(JSON.stringify(r));
|
||||||
|
};
|
||||||
|
globalThis.pysimFsRenderTree = () => { renders++; };
|
||||||
|
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tree error payload marks the directory as absent', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [
|
||||||
|
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||||
|
{ success: false, error: 'SW ... 6a82', exists: false },
|
||||||
|
];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(renders, 1);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
assert.strictEqual(calls[0].body.parent_sel, 'MF');
|
||||||
|
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error payload without exists is not treated as an empty listing', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [
|
||||||
|
{ success: false, error: 'boom' },
|
||||||
|
{ success: false, error: 'boom' },
|
||||||
|
];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(renders, 1);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 200 exists:false response marks the directory absent without retrying', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [{ exists: false }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, false);
|
||||||
|
assert.strictEqual(node.children, null);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a node with loaded children is not fetched again', async () => {
|
||||||
|
const node = setup();
|
||||||
|
node.exists = true;
|
||||||
|
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, true);
|
||||||
|
assert.strictEqual(node.children.length, 1);
|
||||||
|
assert.strictEqual(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty successful listing keeps the directory present', async () => {
|
||||||
|
const node = setup();
|
||||||
|
responses = [{ exists: true, children: [] }];
|
||||||
|
await pysimFsLoadChildren(node);
|
||||||
|
assert.strictEqual(node.exists, true);
|
||||||
|
assert.deepStrictEqual(node.children, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
|
||||||
|
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
|
||||||
|
code += extractFunc(html, fn) + '\n';
|
||||||
|
}
|
||||||
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
function fakeEl() {
|
||||||
|
const classes = new Set();
|
||||||
|
return {
|
||||||
|
textContent: '',
|
||||||
|
attrs: {},
|
||||||
|
classList: {
|
||||||
|
add: (...cs) => cs.forEach(c => classes.add(c)),
|
||||||
|
remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
|
||||||
|
},
|
||||||
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let els = {};
|
||||||
|
let calls = [];
|
||||||
|
|
||||||
|
function setup(routes) {
|
||||||
|
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
|
||||||
|
calls = [];
|
||||||
|
globalThis.document = { getElementById: id => els[id] || null };
|
||||||
|
globalThis.pysimFetch = async (p, body) => {
|
||||||
|
calls.push({ path: p, body: body || {} });
|
||||||
|
for (const r of routes) {
|
||||||
|
if (r.path !== p) continue;
|
||||||
|
if (r.name && (!body || body.name !== r.name)) continue;
|
||||||
|
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
|
||||||
|
}
|
||||||
|
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
|
||||||
|
};
|
||||||
|
globalThis.pysimFsRenderTree = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function root(children) {
|
||||||
|
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
|
||||||
|
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
|
||||||
|
return pysimFsTreeRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const df = (name, fid) => ({ name, fid, isDir: true });
|
||||||
|
const ef = (name, fid) => ({ name, fid, isDir: false });
|
||||||
|
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
|
||||||
|
|
||||||
|
test('probes dirs and files, including custom entries, and reports counts', async () => {
|
||||||
|
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||||
|
let sawStop = null;
|
||||||
|
setup([
|
||||||
|
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
|
||||||
|
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||||
|
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.strictEqual(sawStop, 'Stop');
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /4\/4 files/);
|
||||||
|
assert.match(status, /3 present/);
|
||||||
|
assert.match(status, /1 absent/);
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent directory is marked and its subtree is never fetched', async () => {
|
||||||
|
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||||
|
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /2\/2 files/);
|
||||||
|
assert.match(status, /0 present/);
|
||||||
|
assert.match(status, /2 absent/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop halts the walk and still reports a summary', async () => {
|
||||||
|
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.X']);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.ok(status.startsWith('Stopped —'), status);
|
||||||
|
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
|
||||||
|
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
|
||||||
|
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
|
||||||
|
assert.deepStrictEqual(body.parent_path, ['MF']);
|
||||||
|
assert.strictEqual(body.parent_sel, 'MF');
|
||||||
|
assert.ok(!body.allow_probe);
|
||||||
|
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
|
||||||
|
assert.deepStrictEqual(custom.parent_path, ['MF']);
|
||||||
|
assert.strictEqual(custom.allow_probe, true);
|
||||||
|
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
|
||||||
|
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('children of an absent directory are neither fetched nor selected', async () => {
|
||||||
|
const stale = Object.assign(df('DF.C', '5f03'), {
|
||||||
|
exists: false,
|
||||||
|
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
|
||||||
|
});
|
||||||
|
root([stale, ef('EF.ROOT', '2f01')]);
|
||||||
|
setup([
|
||||||
|
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
|
||||||
|
]);
|
||||||
|
await pysimFsProbeAll();
|
||||||
|
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
|
||||||
|
assert.strictEqual(stale.exists, false);
|
||||||
|
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
|
||||||
|
const status = els['pysim-fs-probe-status'].textContent;
|
||||||
|
assert.match(status, /2\/2 files/);
|
||||||
|
assert.match(status, /1 present/);
|
||||||
|
assert.match(status, /1 absent/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
function extractFunc(src, name) {
|
||||||
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
|
const m = re.exec(src);
|
||||||
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
|
let i = m.index + m[0].length - 1;
|
||||||
|
let depth = 0;
|
||||||
|
for (; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++;
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src.slice(m.index, i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 'var pysimFsSort = "fid";\n';
|
||||||
|
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||||
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
|
eval(code);
|
||||||
|
|
||||||
|
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
|
||||||
|
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
|
||||||
|
|
||||||
|
test('absent directory renders a cross and no expand toggle', () => {
|
||||||
|
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('✗'), out);
|
||||||
|
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||||
|
assert.ok(!out.includes('▶'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanded empty directory shows the (empty) placeholder', () => {
|
||||||
|
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('(empty)'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expanded directory with children renders no placeholder', () => {
|
||||||
|
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('EF.IMSI'), out);
|
||||||
|
assert.ok(!out.includes('(empty)'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapsed directory hides its children', () => {
|
||||||
|
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(!out.includes('EF.IMSI'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-existing directory hides previously loaded children', () => {
|
||||||
|
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
|
||||||
|
const out = pysimFsRenderNode(node, 0);
|
||||||
|
assert.ok(out.includes('✗'), out);
|
||||||
|
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
|
||||||
|
assert.ok(!out.includes('(empty)'), out);
|
||||||
|
assert.ok(!out.includes('pysimFsToggleDir'), out);
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ test('HTML <div> tags are balanced', () => {
|
|||||||
|
|
||||||
test('top-level tabs match the rearranged views', () => {
|
test('top-level tabs match the rearranged views', () => {
|
||||||
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
|
||||||
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'pysim', 'profiler', 'phone']);
|
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'profiler', 'pysim', 'phone']);
|
||||||
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
assert.match(html, /data-tab="c-apdu">Remote APDU</);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,3 +54,37 @@ test('file manager has FID / Name sort pills', () => {
|
|||||||
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
|
||||||
assert.ok(html.includes('pysim-fs-sort-pill'));
|
assert.ok(html.includes('pysim-fs-sort-pill'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('custom files form has add/save and cancel controls', () => {
|
||||||
|
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
|
||||||
|
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
|
||||||
|
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
|
||||||
|
assert.ok(!html.includes('pysimCustomAdd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file manager has a probe-all-files button and status line', () => {
|
||||||
|
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
|
||||||
|
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
|
||||||
|
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
|
||||||
|
assert.ok(html.includes('id="pysim-fs-probe-status"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
|
||||||
|
const detail = html.indexOf('id="pysim-fs-detail"');
|
||||||
|
const info = html.indexOf('id="pysim-fs-info"');
|
||||||
|
const content = html.indexOf('id="pysim-fs-content"');
|
||||||
|
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
|
||||||
|
assert.ok(html.includes('function pysimFsInfoHtml'));
|
||||||
|
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
|
||||||
|
assert.ok(!html.includes('pysim-fs-filename'));
|
||||||
|
assert.ok(html.includes('let pysimFsSelected = null;'));
|
||||||
|
assert.ok(html.includes('pysimFsSelected = name;'));
|
||||||
|
assert.ok(!html.includes('pysimFsSelect()'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile list has a Profile from snapshot button', () => {
|
||||||
|
assert.match(html, /data-l10n="Profile from snapshot">Profile from snapshot</);
|
||||||
|
assert.ok(html.includes('onclick="profilerFromSnapshot()"'));
|
||||||
|
assert.ok(html.includes('function profilerScanFromSnapshot(si)'));
|
||||||
|
assert.ok(html.includes('function profilerBuildFileRuleFromSnapshot('));
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,14 +21,18 @@ function extractFunc(src, name, asyncFn) {
|
|||||||
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel'];
|
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
|
||||||
let code = '';
|
let code = '';
|
||||||
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
for (const f of FNS) code += extractFunc(html, f) + '\n';
|
||||||
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
|
||||||
code += extractFunc(html, 'profilerRunRule', true) + '\n';
|
code += extractFunc(html, 'profilerRunRule', true) + '\n';
|
||||||
code += extractFunc(html, 'profilerScanCard', true) + '\n';
|
code += extractFunc(html, 'profilerScanCard', true) + '\n';
|
||||||
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
|
code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
|
||||||
code += "var _scanTarget = 'profile';\n";
|
code += extractFunc(html, 'profilerCardIccid', true) + '\n';
|
||||||
|
code += extractFunc(html, 'profilerCheck', true) + '\n';
|
||||||
|
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
|
||||||
|
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
|
||||||
|
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
|
||||||
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|
||||||
@@ -698,6 +702,33 @@ test('fcpDiffHtml highlights differing FCI parameters', () => {
|
|||||||
delete global.t;
|
delete global.t;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
|
||||||
|
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
|
||||||
|
assert.ok(out.includes('FID: 6F4F'), out);
|
||||||
|
assert.ok(out.includes('File type: linear_fixed'), out);
|
||||||
|
assert.ok(out.includes('Size: 75'), out);
|
||||||
|
assert.ok(out.includes('Record length: 15'), out);
|
||||||
|
assert.ok(out.includes('Record count: 5'), out);
|
||||||
|
assert.ok(!out.includes('Decoded FCI'), out);
|
||||||
|
assert.ok(out.includes('File descriptor'), out);
|
||||||
|
assert.ok(out.includes('75 bytes'), out);
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
|
||||||
|
assert.ok(out.includes('FID: 6F07'), out);
|
||||||
|
assert.ok(!out.includes('Size:'), out);
|
||||||
|
assert.ok(!out.includes('Record length:'), out);
|
||||||
|
assert.ok(!out.includes('Record count:'), out);
|
||||||
|
assert.ok(!out.includes('Decoded FCI'), out);
|
||||||
|
assert.strictEqual(pysimFsInfoHtml(null), '');
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
|
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
|
||||||
global.t = s => s;
|
global.t = s => s;
|
||||||
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
|
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
|
||||||
@@ -1276,3 +1307,176 @@ test('fcpDiffHtml headers use custom labels', () => {
|
|||||||
assert.ok(diff.includes('>Candidate<'), diff);
|
assert.ok(diff.includes('>Candidate<'), diff);
|
||||||
delete global.t;
|
delete global.t;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('profilerLabelText builds default, verbatim and prefixed labels', () => {
|
||||||
|
global.t = s => 't:' + s;
|
||||||
|
assert.strictEqual(profilerLabelText(null, 'expected'), 't:expected');
|
||||||
|
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'expected'), 'A');
|
||||||
|
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B' }, 'actual'), 'B');
|
||||||
|
assert.strictEqual(profilerLabelText({ expected: 'A', actual: 'B', prefix: true }, 'actual'), 't:actual (B)');
|
||||||
|
assert.strictEqual(profilerLabelText({ expected: '', prefix: true }, 'expected'), 't:expected');
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerRenderReport prefixes names in expected/actual labels', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
global.pysimCustomFiles = [];
|
||||||
|
const html = profilerRenderReport([{
|
||||||
|
path: 'MF/6F3A', status: 'fail',
|
||||||
|
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
|
||||||
|
}], { expected: 'My profile', actual: 'Snap X', prefix: true });
|
||||||
|
assert.ok(html.includes('expected (My profile)'), html);
|
||||||
|
assert.ok(html.includes('actual (Snap X)'), html);
|
||||||
|
delete global.t;
|
||||||
|
delete global.pysimCustomFiles;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fcpDiffHtml uses prefixed labels in headers and decode notes', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||||
|
assert.ok(diff.includes('expected (Prof)'), diff);
|
||||||
|
assert.ok(diff.includes('actual (Snap)'), diff);
|
||||||
|
const bad = fcpDiffHtml('not hex', FCP_TRANSPARENT, { expected: 'Prof', actual: 'Snap', prefix: true });
|
||||||
|
assert.ok(bad.includes('expected (Prof)'), bad);
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerCardIccid reads and decodes MF/2FE2, null otherwise', async () => {
|
||||||
|
let body = null;
|
||||||
|
global.pysimFetch = async (path, b) => { body = [path, b]; return { success: true, data: '98103254769810325476' }; };
|
||||||
|
assert.strictEqual(await profilerCardIccid(), '89012345678901234567');
|
||||||
|
assert.deepStrictEqual(body, ['/api/read', { path: 'MF/2FE2', mode: 'raw' }]);
|
||||||
|
global.pysimFetch = async () => ({ success: false });
|
||||||
|
assert.strictEqual(await profilerCardIccid(), null);
|
||||||
|
global.pysimFetch = async () => { throw new Error('offline'); };
|
||||||
|
assert.strictEqual(await profilerCardIccid(), null);
|
||||||
|
delete global.pysimFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerCheck titles with the card ICCID and passes prefixed labels', async () => {
|
||||||
|
global.t = s => s;
|
||||||
|
global.profiles = [{ name: 'MyProfile', rules: [] }];
|
||||||
|
global.pysimFetch = async () => ({ success: true, data: '98103254769810325476' });
|
||||||
|
let captured = null;
|
||||||
|
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||||
|
await profilerCheck(0);
|
||||||
|
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'MyProfile', to: '89012345678901234567' });
|
||||||
|
assert.deepStrictEqual(captured[4], { expected: 'MyProfile', actual: '89012345678901234567', prefix: true });
|
||||||
|
delete global.profiles; delete global.pysimFetch; delete global.profilerRunProfile; delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerCheckSnapshot passes the snapshot name as the actual label', async () => {
|
||||||
|
global.t = s => s;
|
||||||
|
global.profiles = [{ name: 'Prof', rules: [] }];
|
||||||
|
global.snapshots = [{ name: 'SnapX', files: [] }];
|
||||||
|
global.document = { getElementById: () => ({ classList: { add() {} } }) };
|
||||||
|
let captured = null;
|
||||||
|
global.profilerRunProfile = async (...args) => { captured = args; };
|
||||||
|
await profilerCheckSnapshot(0, 0);
|
||||||
|
assert.deepStrictEqual(captured[2], { kind: 'profile', from: 'Prof', to: 'SnapX' });
|
||||||
|
assert.deepStrictEqual(captured[4], { expected: 'Prof', actual: 'SnapX', prefix: true });
|
||||||
|
delete global.profiles; delete global.snapshots; delete global.profilerRunProfile; delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerResultsHeaderText builds profile and snapshot headers', () => {
|
||||||
|
global.t = s => 't:' + s;
|
||||||
|
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: '8901' }), 't:Profile verification results for: Prof \u2192 8901');
|
||||||
|
assert.strictEqual(profilerResultsHeaderText({ kind: 'snapshot', from: 'Snap1', to: 'Snap2' }), 't:Snapshot comparison results: Snap1 \u2192 Snap2');
|
||||||
|
assert.strictEqual(profilerResultsHeaderText({ kind: 'profile', from: 'Prof', to: null }), 't:Profile verification results for: Prof');
|
||||||
|
assert.strictEqual(profilerResultsHeaderText(null), '');
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerRenderResultsView writes the built header into the title', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
profilerResults = [];
|
||||||
|
profilerResultsHeader = { kind: 'profile', from: 'Prof', to: 'Snap' };
|
||||||
|
const els = {};
|
||||||
|
for (const id of ['profiler-summary', 'profiler-report', 'profiler-results-title']) els[id] = { innerHTML: '', textContent: '' };
|
||||||
|
global.document = { getElementById: id => els[id] || null };
|
||||||
|
profilerRenderResultsView();
|
||||||
|
assert.strictEqual(els['profiler-results-title'].textContent, 'Profile verification results for: Prof \u2192 Snap');
|
||||||
|
profilerResultsHeader = { kind: 'snapshot', from: 'A', to: 'B' };
|
||||||
|
profilerRenderResultsView();
|
||||||
|
assert.strictEqual(els['profiler-results-title'].textContent, 'Snapshot comparison results: A \u2192 B');
|
||||||
|
profilerResults = null;
|
||||||
|
profilerResultsHeader = null;
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
|
||||||
|
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
|
||||||
|
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
|
||||||
|
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
|
||||||
|
assert.strictEqual(rule.name, 'EF.ADN');
|
||||||
|
assert.strictEqual(rule.fileType, 'transparent');
|
||||||
|
assert.strictEqual(rule.fileSize, 4);
|
||||||
|
assert.strictEqual(rule.fciMode, 'type_size');
|
||||||
|
assert.strictEqual(rule.fciHex, '620B');
|
||||||
|
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
|
||||||
|
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
|
||||||
|
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
|
||||||
|
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
|
||||||
|
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
|
||||||
|
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
|
||||||
|
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
|
||||||
|
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
|
||||||
|
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
|
||||||
|
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
|
||||||
|
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
|
||||||
|
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
|
||||||
|
assert.strictEqual(rule.fileSize, null);
|
||||||
|
assert.strictEqual(rule.recordLen, 30);
|
||||||
|
assert.strictEqual(rule.numRecords, 2);
|
||||||
|
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerScanSnapshot walks snapshot files with progress', async () => {
|
||||||
|
const snapshot = { files: [
|
||||||
|
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
|
||||||
|
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
|
||||||
|
] };
|
||||||
|
const progress = [];
|
||||||
|
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
|
||||||
|
assert.strictEqual(rules.length, 2);
|
||||||
|
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
|
||||||
|
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
global.snapshots = [
|
||||||
|
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
|
||||||
|
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
|
||||||
|
];
|
||||||
|
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
|
||||||
|
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
|
||||||
|
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
|
||||||
|
assert.ok(html.includes('SnapA'), html);
|
||||||
|
assert.ok(html.includes('2 files'), html);
|
||||||
|
delete global.snapshots;
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
|
||||||
|
global.t = s => s;
|
||||||
|
const els = {};
|
||||||
|
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
|
||||||
|
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
|
||||||
|
}
|
||||||
|
global.document = { getElementById: id => els[id] || null };
|
||||||
|
profilerScanSetTarget('profile-snapshot');
|
||||||
|
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
|
||||||
|
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
|
||||||
|
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|||||||
+161
-2
@@ -6,7 +6,7 @@ const path = require('node:path');
|
|||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||||
|
|
||||||
function extractFunc(src, name) {
|
function extractFunc(src, name) {
|
||||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||||
const m = re.exec(src);
|
const m = re.exec(src);
|
||||||
if (!m) throw new Error('function ' + name + ' not found');
|
if (!m) throw new Error('function ' + name + ' not found');
|
||||||
let i = m.index + m[0].length - 1;
|
let i = m.index + m[0].length - 1;
|
||||||
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract chain builder functions and dependencies
|
// Extract chain builder functions and dependencies
|
||||||
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex'];
|
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
|
||||||
|
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
|
||||||
let code = '';
|
let code = '';
|
||||||
for (const f of FNS) {
|
for (const f of FNS) {
|
||||||
code += extractFunc(html, f) + '\n';
|
code += extractFunc(html, f) + '\n';
|
||||||
}
|
}
|
||||||
const m = html.match(/const _chains = \{\};/);
|
const m = html.match(/const _chains = \{\};/);
|
||||||
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
|
if (m) code += m[0].replace(/^const /, 'var ') + '\n';
|
||||||
|
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
|
||||||
|
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
|
||||||
|
eval(code);
|
||||||
|
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|
||||||
const els = {};
|
const els = {};
|
||||||
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
|
|||||||
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
|
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ramRenderExploreHtml localizes every label and button', () => {
|
||||||
|
const seen = [];
|
||||||
|
global.t = s => { seen.push(s); return 'XX' + s; };
|
||||||
|
const out = ramRenderExploreHtml(
|
||||||
|
{ appCount: 5, freeNV: 100, freeV: 50 },
|
||||||
|
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
|
||||||
|
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
|
||||||
|
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
|
||||||
|
);
|
||||||
|
delete global.t;
|
||||||
|
assert.ok(out.includes('XXDelete'), out);
|
||||||
|
assert.ok(out.includes('XXDelete All'), out);
|
||||||
|
assert.ok(out.includes('XXApplications:'), out);
|
||||||
|
assert.ok(out.includes('XXFree NV:'), out);
|
||||||
|
assert.ok(out.includes('XXFree Volatile:'), out);
|
||||||
|
assert.ok(out.includes('XXAID:'), out);
|
||||||
|
assert.ok(out.includes('XXLifecycle:'), out);
|
||||||
|
assert.ok(out.includes('XXPrivileges:'), out);
|
||||||
|
assert.ok(out.includes('XXSD AID:'), out);
|
||||||
|
assert.ok(out.includes('XXImplicit sel:'), out);
|
||||||
|
assert.ok(out.includes('XXVersion:'), out);
|
||||||
|
assert.ok(seen.includes('Application / Instance AID:'));
|
||||||
|
assert.ok(seen.includes('Load File AID / Package AID:'));
|
||||||
|
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
|
||||||
|
assert.ok(!out.includes('data-l10n'), out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
|
||||||
|
global.t = s => 'XX' + s;
|
||||||
|
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
|
||||||
|
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
|
||||||
|
delete global.t;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeClassList() {
|
||||||
|
const set = new Set();
|
||||||
|
return {
|
||||||
|
add: (...cs) => cs.forEach(c => set.add(c)),
|
||||||
|
remove: (...cs) => cs.forEach(c => set.delete(c)),
|
||||||
|
contains: c => set.has(c),
|
||||||
|
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeEl(id) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
value: '',
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
classList: fakeClassList(),
|
||||||
|
options: [],
|
||||||
|
appendChild(opt) { this.options.push(opt); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRamDocument(ids) {
|
||||||
|
const els = {};
|
||||||
|
for (const id of ids) els[id] = fakeEl(id);
|
||||||
|
const sel = els['ram-card-sel'];
|
||||||
|
if (sel) {
|
||||||
|
Object.defineProperty(sel, 'innerHTML', {
|
||||||
|
get() { return this._html || ''; },
|
||||||
|
set(v) { this._html = v; this.value = ''; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: id => els[id] || null,
|
||||||
|
createElement: () => fakeEl('option'),
|
||||||
|
};
|
||||||
|
return els;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ramOpChanged clears the executed status only on a real op change', () => {
|
||||||
|
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||||
|
_ramOpLast = null;
|
||||||
|
els['ram-op'].value = 'explore';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.ok(!els['ram-result'].classList.contains('hidden'));
|
||||||
|
els['ram-result'].classList.remove('hidden');
|
||||||
|
els['ram-steps'].classList.remove('hidden');
|
||||||
|
ramOpChanged();
|
||||||
|
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
|
||||||
|
els['ram-op'].value = 'install-cap';
|
||||||
|
ramOpChanged();
|
||||||
|
assert.ok(els['ram-result'].classList.contains('hidden'));
|
||||||
|
assert.ok(els['ram-steps'].classList.contains('hidden'));
|
||||||
|
assert.ok(els['ram-explorer'].classList.contains('hidden'));
|
||||||
|
assert.ok(els['ram-progress'].classList.contains('hidden'));
|
||||||
|
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ramRender keeps the selected card preset across rebuilds', () => {
|
||||||
|
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||||
|
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
|
||||||
|
_ramCardIdx = null;
|
||||||
|
_ramOpLast = 'explore';
|
||||||
|
els['ram-op'].value = 'explore';
|
||||||
|
ramRender();
|
||||||
|
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||||
|
els['ram-card-sel'].value = '1';
|
||||||
|
ramRender();
|
||||||
|
assert.strictEqual(els['ram-card-sel'].value, '1');
|
||||||
|
els['ram-card-sel'].value = '';
|
||||||
|
_ramCardIdx = 2;
|
||||||
|
ramRender();
|
||||||
|
assert.strictEqual(els['ram-card-sel'].value, '2');
|
||||||
|
globalThis.cards = [{ name: 'A' }];
|
||||||
|
_ramCardIdx = 2;
|
||||||
|
ramRender();
|
||||||
|
assert.strictEqual(els['ram-card-sel'].value, '');
|
||||||
|
delete globalThis.cards;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ramApplyCard remembers a valid picked preset', () => {
|
||||||
|
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
|
||||||
|
let applied = null;
|
||||||
|
globalThis.cardsApply = i => { applied = i; };
|
||||||
|
_ramCardIdx = null;
|
||||||
|
ramApplyCard('1');
|
||||||
|
assert.strictEqual(_ramCardIdx, 1);
|
||||||
|
assert.strictEqual(applied, '1');
|
||||||
|
ramApplyCard('');
|
||||||
|
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
|
||||||
|
delete globalThis.cards;
|
||||||
|
delete globalThis.cardsApply;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ramExecute commits the dropdown selection before running', async () => {
|
||||||
|
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
|
||||||
|
globalThis.cards = [{ name: 'A' }];
|
||||||
|
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
|
||||||
|
let explored = false;
|
||||||
|
globalThis.ramExplore = async () => { explored = true; };
|
||||||
|
globalThis.alert = () => {};
|
||||||
|
_ramCardIdx = null;
|
||||||
|
els['ram-card-sel'].value = '0';
|
||||||
|
els['ram-op'].value = 'explore';
|
||||||
|
await ramExecute();
|
||||||
|
assert.strictEqual(_ramCardIdx, 0);
|
||||||
|
assert.ok(explored);
|
||||||
|
delete globalThis.cards;
|
||||||
|
delete globalThis.getRamSpParams;
|
||||||
|
delete globalThis.ramExplore;
|
||||||
|
delete globalThis.alert;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
|
||||||
|
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
|
||||||
|
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
|
||||||
|
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
|
||||||
|
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pysim-otaman-server"
|
name = "pysim-otaman-server"
|
||||||
version = "2.0.0"
|
version = "2.1.2"
|
||||||
description = "HTTP REST server wrapping pysim for the OTAMan PWA"
|
description = "HTTP REST server wrapping pysim for the OTAMan 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.
|
||||||
|
|||||||
+162
-33
@@ -19,7 +19,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
|||||||
from osmocom.tlv import BER_TLV_IE
|
from osmocom.tlv import BER_TLV_IE
|
||||||
|
|
||||||
|
|
||||||
VERSION = '2.0.0'
|
VERSION = '2.1.2'
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -214,39 +214,143 @@ def _get_file_type(lchan, cur_file):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _select_with_parent(lchan, name, parent_sel, app):
|
def _fid4(sel):
|
||||||
if parent_sel:
|
"""True if sel is a 4-digit hex FID."""
|
||||||
lchan.select(parent_sel, app)
|
return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
|
||||||
fcp = lchan.select(name, app)
|
|
||||||
return fcp
|
|
||||||
|
def _file_by_sel(parent, sel):
|
||||||
|
"""Resolve sel (FID or symbolic name, case-insensitive) among parent's direct children."""
|
||||||
|
if parent is None or not sel:
|
||||||
|
return None
|
||||||
|
s = str(sel).strip().lower()
|
||||||
|
for f in (getattr(parent, 'children', None) or {}).values():
|
||||||
|
if f.fid and f.fid.lower() == s:
|
||||||
|
return f
|
||||||
|
if f.name and f.name.lower() == s:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _app_by_sel(rs, sel):
|
||||||
|
"""Resolve an ADF by AID or application name (case-insensitive)."""
|
||||||
|
if rs is None or not sel:
|
||||||
|
return None
|
||||||
|
s = str(sel).strip().lower()
|
||||||
|
for aid, adf in (rs.mf.applications or {}).items():
|
||||||
|
if aid.lower() == s or (adf.name and adf.name.lower() == s):
|
||||||
|
return adf
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_in_tree(root, sel):
|
||||||
|
"""All model files matching sel (fid or name) below root; unique-match helper."""
|
||||||
|
s = str(sel or '').strip().lower()
|
||||||
|
found = []
|
||||||
|
seen = set()
|
||||||
|
stack = [root]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
if id(cur) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(cur))
|
||||||
|
candidates = list((getattr(cur, 'children', None) or {}).values())
|
||||||
|
candidates += list((getattr(cur, 'applications', None) or {}).values())
|
||||||
|
for f in candidates:
|
||||||
|
if (f.fid and f.fid.lower() == s) or (f.name and f.name.lower() == s):
|
||||||
|
found.append(f)
|
||||||
|
stack.append(f)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _select_with_parent(lchan, name, parent_sel, app, parent_path=None, allow_probe=False):
|
||||||
|
"""Select name strictly within the requested parent.
|
||||||
|
|
||||||
|
Every model-known FID/name is resolved through the parent's children and
|
||||||
|
selected with lchan.select_file(); pySim's global selectables and its
|
||||||
|
probe_file() fallback are never used for model files, so a same-FID file
|
||||||
|
under a different parent can no longer be picked and the model is not
|
||||||
|
mutated. Model-unknown 4-hex segments (custom files) are probed only when
|
||||||
|
allow_probe is set and are detached again via the returned cleanup.
|
||||||
|
|
||||||
|
Returns (selected_file, cleanup): cleanup is None unless a probe happened;
|
||||||
|
handlers must call it in a finally block after using the selection.
|
||||||
|
"""
|
||||||
|
rs = app.rs
|
||||||
|
prev = lchan.selected_file
|
||||||
|
probes = []
|
||||||
|
parent = rs.mf
|
||||||
|
lchan.select_file(parent, app)
|
||||||
|
segs = [s for s in (parent_path or ([parent_sel] if parent_sel else [])) if s]
|
||||||
|
for seg in segs:
|
||||||
|
if str(seg).upper() in ('MF', '3F00'):
|
||||||
|
continue
|
||||||
|
f = _app_by_sel(rs, seg) or _file_by_sel(parent, seg)
|
||||||
|
if f is None and not parent_path:
|
||||||
|
matches = _find_in_tree(rs.mf, seg)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError('Ambiguous parent selector: %s' % seg)
|
||||||
|
if matches:
|
||||||
|
f = matches[0]
|
||||||
|
if f is None:
|
||||||
|
if allow_probe and _fid4(seg):
|
||||||
|
fid = str(seg).lower()
|
||||||
|
probes.append((parent, fid))
|
||||||
|
lchan.probe_file(fid, app)
|
||||||
|
parent = lchan.selected_file
|
||||||
|
continue
|
||||||
|
raise RuntimeError('File not found: %s' % seg)
|
||||||
|
lchan.select_file(f, app)
|
||||||
|
parent = f
|
||||||
|
target = None
|
||||||
|
if str(name).upper() in ('MF', '3F00'):
|
||||||
|
target = rs.mf
|
||||||
|
if target is None:
|
||||||
|
target = _file_by_sel(parent, name) or _app_by_sel(rs, name)
|
||||||
|
if target is None and not parent_path and not parent_sel:
|
||||||
|
matches = _find_in_tree(rs.mf, name)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeError('Ambiguous file selector: %s' % name)
|
||||||
|
if matches:
|
||||||
|
target = matches[0]
|
||||||
|
if target is not None:
|
||||||
|
lchan.select_file(target, app)
|
||||||
|
elif allow_probe and _fid4(name):
|
||||||
|
fid = str(name).lower()
|
||||||
|
probes.append((parent, fid))
|
||||||
|
lchan.probe_file(fid, app)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('File not found: %s' % name)
|
||||||
|
cleanup = None
|
||||||
|
if probes:
|
||||||
|
def cleanup():
|
||||||
|
for p, fid in reversed(probes):
|
||||||
|
try:
|
||||||
|
(getattr(p, 'children', None) or {}).pop(fid, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
lchan.select_file(prev, app)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
lchan.select_file(rs.mf, app)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return lchan.selected_file, cleanup
|
||||||
|
|
||||||
|
|
||||||
def _select_path(lchan, path, app):
|
def _select_path(lchan, path, app):
|
||||||
"""Select a file described by a full path.
|
"""Select a file described by a full path.
|
||||||
|
|
||||||
Path is '/' separated; the first element is either 'MF' (or the MF fid
|
Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID,
|
||||||
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names.
|
or an ADF name; remaining elements are FIDs or file names. Resolution is
|
||||||
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are
|
strictly parent-scoped (see _select_with_parent); unknown 4-hex segments
|
||||||
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications.
|
are custom files and are probed without touching the model tree.
|
||||||
"""
|
"""
|
||||||
parts = [p for p in (path or '').split('/') if p]
|
parts = [p for p in (path or '').split('/') if p]
|
||||||
if not parts:
|
if not parts:
|
||||||
raise RuntimeError('Empty path')
|
raise RuntimeError('Empty path')
|
||||||
rs = app.rs
|
return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True)
|
||||||
first = parts[0]
|
|
||||||
if first.upper() in ('MF', '3F00'):
|
|
||||||
lchan.select('MF', app)
|
|
||||||
else:
|
|
||||||
aid = first.lower()
|
|
||||||
adf = rs.mf.applications.get(aid)
|
|
||||||
if not adf:
|
|
||||||
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
|
|
||||||
if not adf:
|
|
||||||
raise RuntimeError('ADF not found: %s' % first)
|
|
||||||
lchan.select_file(adf, app)
|
|
||||||
for seg in parts[1:]:
|
|
||||||
lchan.select(seg, app)
|
|
||||||
return lchan.selected_file
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_tree_output(output):
|
def _parse_tree_output(output):
|
||||||
@@ -1717,6 +1821,7 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
'connected': connected,
|
'connected': connected,
|
||||||
'card_present': bool(getattr(self.server, 'card_present', False)),
|
'card_present': bool(getattr(self.server, 'card_present', False)),
|
||||||
'card_session': int(getattr(self.server, 'card_session', 0)),
|
'card_session': int(getattr(self.server, 'card_session', 0)),
|
||||||
|
'proactive_seq': _PROACTIVE_ENTRY_ID,
|
||||||
'equipping': bool(getattr(self.server, 'equipping', False)),
|
'equipping': bool(getattr(self.server, 'equipping', False)),
|
||||||
'auto_equip': bool(_AUTO_EQUIP),
|
'auto_equip': bool(_AUTO_EQUIP),
|
||||||
'card': card.name if card else None,
|
'card': card.name if card else None,
|
||||||
@@ -1960,22 +2065,25 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_collect_apdu_times()
|
_collect_apdu_times()
|
||||||
try:
|
try:
|
||||||
if path:
|
if path:
|
||||||
_select_path(lchan, path, app)
|
cur, cleanup = _select_path(lchan, path, app)
|
||||||
else:
|
else:
|
||||||
_select_with_parent(lchan, name, parent_sel, app)
|
cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe)
|
||||||
finally:
|
finally:
|
||||||
apdu_times = _end_apdu_time_collection()
|
apdu_times = _end_apdu_time_collection()
|
||||||
cur = lchan.selected_file
|
cur = cur or lchan.selected_file
|
||||||
data = {
|
data = {
|
||||||
'name': cur.name if cur else None,
|
'name': cur.name if cur else None,
|
||||||
'fid': cur.fid.upper() if cur and cur.fid else None,
|
'fid': cur.fid.upper() if cur and cur.fid else None,
|
||||||
@@ -1993,6 +2101,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'error': str(e), 'exists': False}
|
err = {'error': str(e), 'exists': False}
|
||||||
self._send_json(err, 404)
|
self._send_json(err, 404)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/read':
|
elif self.path == '/api/read':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2005,6 +2116,8 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
mode = body.get('mode', 'raw')
|
mode = body.get('mode', 'raw')
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
@@ -2012,14 +2125,15 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
_collect_apdu_times()
|
_collect_apdu_times()
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
if path:
|
if path:
|
||||||
_select_path(lchan, path, app)
|
_, cleanup = _select_path(lchan, path, app)
|
||||||
else:
|
else:
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
ft = _get_file_type(lchan, lchan.selected_file)
|
ft = _get_file_type(lchan, lchan.selected_file)
|
||||||
is_record = ft in ('linear_fixed', 'cyclic')
|
is_record = ft in ('linear_fixed', 'cyclic')
|
||||||
if mode == 'decoded':
|
if mode == 'decoded':
|
||||||
@@ -2073,6 +2187,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e)}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/write':
|
elif self.path == '/api/write':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2086,15 +2203,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
record_nr = body.get('record_nr')
|
record_nr = body.get('record_nr')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
ft = _get_file_type(lchan, lchan.selected_file)
|
ft = _get_file_type(lchan, lchan.selected_file)
|
||||||
is_record = ft in ('linear_fixed', 'cyclic')
|
is_record = ft in ('linear_fixed', 'cyclic')
|
||||||
if record_nr:
|
if record_nr:
|
||||||
@@ -2131,6 +2251,9 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e)}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/tree':
|
elif self.path == '/api/tree':
|
||||||
app = self.server.app
|
app = self.server.app
|
||||||
if not app:
|
if not app:
|
||||||
@@ -2143,15 +2266,18 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
name = fid if fid else body.get('name', '')
|
name = fid if fid else body.get('name', '')
|
||||||
fid = body.get('fid')
|
fid = body.get('fid')
|
||||||
parent_sel = body.get('parent_sel')
|
parent_sel = body.get('parent_sel')
|
||||||
|
parent_path = body.get('parent_path')
|
||||||
|
allow_probe = bool(body.get('allow_probe'))
|
||||||
rs = app.rs
|
rs = app.rs
|
||||||
if not rs:
|
if not rs:
|
||||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||||
self._log_resp({'error': _err('no_card_state', lang)})
|
self._log_resp({'error': _err('no_card_state', lang)})
|
||||||
return
|
return
|
||||||
lchan = rs.lchan[0]
|
lchan = rs.lchan[0]
|
||||||
|
cleanup = None
|
||||||
try:
|
try:
|
||||||
sel = fid if fid else name
|
sel = fid if fid else name
|
||||||
_select_with_parent(lchan, sel, parent_sel, app)
|
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
|
||||||
cur = lchan.selected_file
|
cur = lchan.selected_file
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
old_stdout = app.stdout
|
old_stdout = app.stdout
|
||||||
@@ -2184,9 +2310,12 @@ class PysimHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.write('Handler error: %s\n' % e)
|
sys.stderr.write('Handler error: %s\n' % e)
|
||||||
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
|
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
|
||||||
_handle_card_disconnect()
|
_handle_card_disconnect()
|
||||||
err = {'success': False, 'error': str(e)}
|
err = {'success': False, 'error': str(e), 'exists': False}
|
||||||
self._send_json(err, 500)
|
self._send_json(err, 500)
|
||||||
self._log_resp(err)
|
self._log_resp(err)
|
||||||
|
finally:
|
||||||
|
if cleanup:
|
||||||
|
cleanup()
|
||||||
elif self.path == '/api/menu-select':
|
elif self.path == '/api/menu-select':
|
||||||
scc = self.server.scc
|
scc = self.server.scc
|
||||||
if not scc:
|
if not scc:
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
|
||||||
|
|
||||||
|
The helpers must resolve every model-known file strictly within the requested
|
||||||
|
parent (no pySim global selectables, no probe_file model injection) and must
|
||||||
|
detach any model-unknown file that had to be probed for a custom file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
PROJECTS = Path(__file__).resolve().parents[2]
|
||||||
|
PY_SIM = PROJECTS / 'pysim'
|
||||||
|
if str(PY_SIM) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PY_SIM))
|
||||||
|
|
||||||
|
from pysim_otaman_server.server import (
|
||||||
|
_app_by_sel,
|
||||||
|
_fid4,
|
||||||
|
_file_by_sel,
|
||||||
|
_find_in_tree,
|
||||||
|
_select_path,
|
||||||
|
_select_with_parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFile:
|
||||||
|
def __init__(self, fid=None, name=None, parent=None, aid=None):
|
||||||
|
self.fid = fid
|
||||||
|
self.name = name
|
||||||
|
self.parent = parent
|
||||||
|
self.aid = aid
|
||||||
|
self.sfid = None
|
||||||
|
self.children = {}
|
||||||
|
|
||||||
|
def add_files(self, files):
|
||||||
|
for f in files:
|
||||||
|
f.parent = self
|
||||||
|
self.children[f.fid] = f
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMF(FakeFile):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(fid='3f00', name='MF')
|
||||||
|
self.applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLchan:
|
||||||
|
"""No .select() on purpose: any global-resolution call would fail loudly."""
|
||||||
|
|
||||||
|
def __init__(self, mf):
|
||||||
|
self.selected_file = mf
|
||||||
|
self.selects = []
|
||||||
|
self.probes = []
|
||||||
|
|
||||||
|
def select_file(self, f, app=None):
|
||||||
|
self.selected_file = f
|
||||||
|
self.selects.append(f)
|
||||||
|
|
||||||
|
def probe_file(self, fid, app=None):
|
||||||
|
self.probes.append(fid)
|
||||||
|
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
|
||||||
|
self.selected_file.add_files([f])
|
||||||
|
self.selected_file = f
|
||||||
|
|
||||||
|
|
||||||
|
def build_model():
|
||||||
|
mf = FakeMF()
|
||||||
|
gsm = FakeFile('7f20', 'DF.GSM', mf)
|
||||||
|
mf.children['7f20'] = gsm
|
||||||
|
spn = FakeFile('6f46', 'EF.SPN', gsm)
|
||||||
|
gsm.children['6f46'] = spn
|
||||||
|
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
|
||||||
|
mf.children['7f10'] = telecom
|
||||||
|
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
|
||||||
|
telecom.children['5f3a'] = tel_ph
|
||||||
|
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
|
||||||
|
mf.applications['a0000000871002'] = usim
|
||||||
|
imsi = FakeFile('6f07', 'EF.IMSI', usim)
|
||||||
|
usim.children['6f07'] = imsi
|
||||||
|
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
|
||||||
|
usim.children['5f3a'] = usim_ph
|
||||||
|
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
|
||||||
|
|
||||||
|
|
||||||
|
def setup():
|
||||||
|
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
|
||||||
|
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
|
||||||
|
lchan = FakeLchan(mf)
|
||||||
|
return app, lchan, mf, usim, usim_ph, gsm, spn
|
||||||
|
|
||||||
|
|
||||||
|
class FidHelpersTest(unittest.TestCase):
|
||||||
|
def test_fid4(self):
|
||||||
|
self.assertTrue(_fid4('6F07'))
|
||||||
|
self.assertFalse(_fid4('EF.IMSI'))
|
||||||
|
self.assertFalse(_fid4('6F0'))
|
||||||
|
|
||||||
|
def test_file_by_sel_matches_fid_and_name(self):
|
||||||
|
_, _, _, _, _, gsm, spn = setup()
|
||||||
|
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
|
||||||
|
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
|
||||||
|
self.assertIsNone(_file_by_sel(gsm, '6f07'))
|
||||||
|
|
||||||
|
def test_find_in_tree_reports_duplicates(self):
|
||||||
|
app, _, mf, _, _, _, _ = setup()
|
||||||
|
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
|
||||||
|
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class ParentScopedSelectTest(unittest.TestCase):
|
||||||
|
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
|
||||||
|
app, lchan, mf, usim, usim_ph, _, _ = setup()
|
||||||
|
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
|
||||||
|
self.assertIs(target, usim_ph)
|
||||||
|
self.assertIsNone(cleanup)
|
||||||
|
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_path_with_fids_selects_exactly(self):
|
||||||
|
app, lchan, mf, _, _, gsm, spn = setup()
|
||||||
|
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
|
||||||
|
self.assertIs(target, spn)
|
||||||
|
self.assertIsNone(cleanup)
|
||||||
|
self.assertEqual(lchan.selects, [mf, gsm, spn])
|
||||||
|
|
||||||
|
def test_path_with_aid_root_selects_application(self):
|
||||||
|
app, lchan, _, usim, _, _, _ = setup()
|
||||||
|
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
|
||||||
|
self.assertEqual(target.fid, '6f07')
|
||||||
|
self.assertEqual(lchan.selected_file.parent, usim)
|
||||||
|
|
||||||
|
def test_ambiguous_legacy_parent_selector_is_rejected(self):
|
||||||
|
app, lchan, _, _, _, _, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
|
||||||
|
_select_with_parent(lchan, '6f07', '5f3a', app)
|
||||||
|
|
||||||
|
def test_unknown_name_is_not_probed(self):
|
||||||
|
app, lchan, _, _, _, gsm, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||||
|
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
|
||||||
|
app, lchan, _, _, _, gsm, _ = setup()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'File not found'):
|
||||||
|
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
|
||||||
|
app, lchan, mf, _, _, gsm, _ = setup()
|
||||||
|
before = set(gsm.children)
|
||||||
|
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
|
||||||
|
self.assertEqual(lchan.probes, ['6f99'])
|
||||||
|
self.assertEqual(target.fid, '6f99')
|
||||||
|
self.assertIsNotNone(cleanup)
|
||||||
|
self.assertIn('6f99', gsm.children)
|
||||||
|
cleanup()
|
||||||
|
self.assertEqual(set(gsm.children), before)
|
||||||
|
self.assertIs(lchan.selected_file, mf)
|
||||||
|
|
||||||
|
def test_custom_path_segments_are_probed_and_detached(self):
|
||||||
|
app, lchan, mf, _, _, gsm, _ = setup()
|
||||||
|
before = set(gsm.children)
|
||||||
|
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
|
||||||
|
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
|
||||||
|
self.assertEqual(target.fid, '6f01')
|
||||||
|
cleanup()
|
||||||
|
self.assertEqual(set(gsm.children), before)
|
||||||
|
self.assertIs(lchan.selected_file, mf)
|
||||||
|
|
||||||
|
def test_model_known_selection_never_mutates_the_tree(self):
|
||||||
|
app, lchan, _, _, _, gsm, spn = setup()
|
||||||
|
before = {id(k): k for k in gsm.children}
|
||||||
|
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
|
||||||
|
self.assertEqual({id(k): k for k in gsm.children}, before)
|
||||||
|
self.assertEqual(lchan.probes, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user