12 Commits
Author SHA1 Message Date
catarrh 1f8fa54c49 release: 3.40.1 — filter uses per-spec service names
release / release (push) Waiting to run
Drop the invented "SMS-related" / "CB-related" filter categories: those
files are service-declared and are already reached through their
per-service options (per-spec service names).  Bump VERSION/pyproject/
PWA header to 3.40.1 and the SW cache to simple-v486.
2026-10-26 19:41:32 +03:00
catarrh 98926833f7 release: 3.40.0 — file-tree filter
release / release (push) Canceled after 0s
Bump VERSION/pyproject/PWA header to 3.40.0 and the SW cache to
simple-v485 (index.html changed).  CHANGELOG [3.40.0] documents the
file-tree filter (checkbox multiselect, path-exact DF hiding, expand/
collapse all, auto-expand, and the "To EF filter" cross-link).
2026-10-26 15:20:08 +03:00
catarrh 4fd2efe77c file manager: style expand/collapse as buttons like the All filter
Give Expand all / Collapse all the same bordered button style as the
filter button in its default 'All' state.  Frontend 1163 tests green.
2026-10-26 15:10:22 +03:00
catarrh 4a4232de5c file manager: expand/collapse all + auto-expand on filter
- "Expand all" / "Collapse all" links in the sort row: expand-all walks
  the directory tree loading children on demand (skipping DFs the probe
  found absent), collapse-all resets every directory.
- Selecting any filter but "All" auto-expands the tree so the matched
  files are visible (pysimFsAutoExpandForFilter, wired into the two
  filter setters).
- i18n EN/RU for the two links.

Frontend 1163 tests green (+2).  (Version/sw bump still deferred.)
2026-10-26 15:05:49 +03:00
catarrh cc7c23a9c7 file manager: blue filter button when a filter is active
A non-'All' selection turns the collapsed filter button blue (like the
active sort pill) so it stands out; 'All' keeps the default style.
Frontend 1161 tests green.
2026-10-26 14:53:36 +03:00
catarrh 8fdf1ddff9 file manager: hide directories outside the filter's file paths
When a filter is active (not "All"), only the matching files, their
ancestor directories and the MF root stay; every other directory is
hidden.  The ancestors come from the standard file list (name -> paths):
pysimFsFilterMatchingPaths unions the registry paths of every matching
name, and pysimFsFilterShow keeps a directory iff it matches itself
(e.g. DF.GSM for svc:GPRS) or its canonical path prefixes a matching
path.  Custom files contribute their own paths; the not-loaded window
keeps directories rather than hiding everything.

Frontend 1160 tests green (+8).  (Version/sw bump still deferred.)
2026-10-26 14:49:44 +03:00
catarrh e2d5d720f7 file manager: move the filter to its own row under the sort pills
Filter names (the per-service options) are long, so the label + dropdown
now live on a dedicated row below Sort.  Frontend 1152 tests green.
2026-10-26 14:37:37 +03:00
catarrh 627198e57b service editors: compact 'To EF filter' glyph with tooltip
The per-service cross-link is now a single compact glyph (U+25BE) with
'To EF filter' as the title/aria-label, instead of a text button that
shifted the grid.  Frontend 1152 tests green.
2026-10-26 14:17:18 +03:00
catarrh 77fbed80ce service editors: "To EF filter" per-service cross-link
In the SST/UST/IST/EST decoded editors, each service row backed by files
gains a small "To EF filter" action that sets the file-tree filter to
exactly that service's option:

- The four service-table fields carry a `table` key (sst/ust/est/ist);
  servicesFeatureByNumber() inverts SERVICES_CROSS_REF into per-table
  number->feature maps (lazy, cached).
- pysimFsFilterBtnHtml(table, n) renders the button only for file-backed
  features; pysimFsSetFilterToService(table, n) sets pysimFsFilter to
  ['svc:<feature>'], persists, and re-renders the dropdown + tree.
- i18n: "To EF filter" (EN/RU).  No CSS rebuild needed.

Frontend 1152 tests green (+6).  (Version/sw bump still deferred.)
2026-10-26 14:06:06 +03:00
catarrh d1067e3e72 file manager: filter as a collapsed checkbox dropdown
Replace the <select multiple size=4> filter with a single-row button that
expands into a scrollable checkbox menu (All + Custom files, then the
Identifiers/PLMN/Services headings with the categories and per-service
options), mirroring simtrace-analyser's command filter.

- pysimFsFilterSummary/Label, pysimFsFilterToggle, pysimFsFilterSetEntry
  (all-exclusive, empty -> all) and a rewritten pysimFsFilterRender;
  pysimFsSetFilter is gone.
- Closes on outside click and Escape.  i18n: "Selected: {n}".
- No CSS rebuild needed (swapped to utility classes already in the
  prebuilt style.css).

Frontend 1146 tests green.  (Version/sw bump still deferred.)
2026-10-26 13:55:48 +03:00
catarrh 01e50d841f file manager: implement the tree filter (categories + per-service)
Fill in the scaffolded file-tree filter:

- FS_FILTER_GROUPS: Identifiers, PLMN selection, Provider names,
  WLAN/I-WLAN, SMS-related and CB-related (cell broadcast), each with
  its exact file list; plus one option per file-backed feature of
  SERVICES_FS_MAP (svc:<feature>).
- "All" is the default and exclusive; a top-level "Custom files"
  option matches the injected custom-file nodes by their flag.
- fsFilterIndexBuild inverts name -> criterion set; pysimFsFilterMatch
  matches by name (and custom by flag); pysimFsFilterShow keeps a
  directory while it has a matching (loaded) descendant, and keeps
  never-expanded directories.  The dropdown renders All / Custom files
  and three optgroups (Identifiers / PLMN / Services).
- i18n EN/RU for the new labels.

Frontend 1145 tests green (fs_filter.test.js 19).
2026-10-26 13:34:54 +03:00
catarrh 3878156646 file manager: scaffold the tree filter (multiselect)
Add a Filter multiselect next to the sort pills in the file tree toolbar.
The option list is large and undecided, so FS_FILTER_OPTIONS is empty and
the per-node match is a no-op for now; the mechanism is wired end to end:

- FS_FILTER_OPTIONS const (empty), pysimFsFilter state persisted to
  simple_fs_filter, pysimFsSetFilter / pysimFsFilterMatch /
  pysimFsFilterRender, and the render loop consults pysimFsFilterMatch.
- The dropdown re-renders on language switch; "Filter:" and
  "(no filters yet)" are translated (EN/RU).

Frontend 1132 tests green (6 new scaffold pins).
2026-10-26 13:03:22 +03:00
9 changed files with 1246 additions and 13 deletions

No files matched your search

+31
View File
@@ -9,6 +9,37 @@ every change carries a descriptive commit message.
## [Unreleased] ## [Unreleased]
## [3.40.1] — 2026-10-26
### Changed
- **File filter: drop the "SMS-related" / "CB-related" categories.** Those
files are service-declared and already reachable through their per-service
filter options, which carry the per-spec service names ("Short Message
Storage (SMS)", "SMS Parameters (SMSP)", "Short Message Status Reports
(SMSR)", "Cell Broadcast Message Identifier (CBMI)", "Cell Broadcast
Message Identifier Ranges (CBMIR)").
## [3.40.0] — 2026-10-26
### Added
- **File-tree filter** (the file manager gains a **Filter** dropdown, next to
Sort). A collapsed checkbox multiselect with: **All** (default), **Custom
files**, **Identifiers** (ICCID/IMSI/MSISDN/IMPI/IMPU/SUPI_NAI/GID1/GID2),
**PLMN selection** / **Provider names** / **WLAN/I-WLAN** / **SMS-related** /
**CB-related** (cell broadcast), and one option per file-backed service of
the cross-reference. When a filter is active, directories that are not on
the path of the selected files are hidden; the button turns blue to stand
out.
- **Expand all / Collapse all** buttons beside the sort pills; **Expand all**
walks the tree loading children on demand and skips directories the probe
found absent, and selecting any filter other than "All" auto-expands the
tree so the matched files are visible.
- **"To EF filter"** cross-link: each file-backed service row in the
SST/UST/IST/EST decoded editors carries a compact glyph that narrows the
file-tree filter to exactly that service's files.
## [3.39.0] — 2026-10-26 ## [3.39.0] — 2026-10-26
### Added ### Added
+418 -8
View File
@@ -1117,6 +1117,16 @@
<span data-l10n="Sort:">Sort:</span> <span data-l10n="Sort:">Sort:</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span> <span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span> <span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
<span class="mx-1 text-gray-400 dark:text-slate-600">·</span>
<button type="button" onclick="pysimFsExpandAll()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Expand all">Expand all</button>
<button type="button" onclick="pysimFsCollapseAll()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Collapse all">Collapse all</button>
</div>
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
<span data-l10n="Filter:">Filter:</span>
<div class="relative">
<button type="button" id="pysim-fs-filter-btn" aria-haspopup="true" aria-expanded="false" onclick="pysimFsFilterToggle()" class="px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 max-w-xs truncate">All</button>
<div id="pysim-fs-filter-menu" role="group" aria-label="Filter" class="hidden absolute left-0 mt-1 z-50 w-64 max-h-64 overflow-y-auto rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 p-2 shadow-lg"></div>
</div>
</div> </div>
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button> <button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div> <div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
@@ -1979,7 +1989,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.39.0'; const SIMPLE_VERSION = '3.40.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) ===== // ===== view history (the browser Back/Forward buttons) =====
@@ -4410,6 +4420,11 @@ function uiccStdFilesLoad() {
Object.keys(_chains).forEach(id => { Object.keys(_chains).forEach(id => {
if (document.getElementById(id + '-rows')) chainRender(id); if (document.getElementById(id + '-rows')) chainRender(id);
}); });
// The tree filter's directory rule needs the files' paths: a
// tree drawn while the list was still loading kept every
// directory, so redraw it now under an active filter.
if (pysimFsTreeRoot && pysimFsFilter.size && !pysimFsFilter.has(FS_FILTER_ALL) &&
document.getElementById('pysim-fs-tree')) pysimFsRenderTree();
return _uiccStdFiles; return _uiccStdFiles;
}); });
} }
@@ -16073,6 +16088,336 @@ function pysimFsSetSort(mode) {
pysimFsRenderTree(); pysimFsRenderTree();
} }
// The tree filter: a multiselect of file criteria, applied when the tree
// renders. The criteria name standard files by their symbolic name - the
// same value in the tree nodes and in frontend/uicc_files.json - so a file
// can belong to several criteria (EF.SPN is a provider name and the file of
// the "Service Provider Name" service). `all` is the exclusive default.
// The categories are shown under three headings (`group`): the subscriber
// data, the network-selection data, and services (one option per file-backed
// feature of the service cross-reference).
const FS_FILTER_ALL = 'all';
// "Custom files" is not a name-based criterion: it matches the injected
// custom-file nodes by their `custom` flag (user-chosen names).
const FS_FILTER_CUSTOM = 'custom';
const FS_FILTER_GROUPS = [
{ id: 'identifiers', label: 'Identifiers', group: 'Identifiers', files: [
'EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2'] },
{ id: 'plmn-selection', label: 'PLMN selection', group: 'PLMN', files: [
'EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN'] },
{ id: 'provider-names', label: 'Provider names', group: 'Identifiers', files: [
'EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL'] },
{ id: 'wlan', label: 'WLAN / I-WLAN', group: 'PLMN', files: [
'EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
'EF.WLRPLMN', 'EF.HPLMNDAI'] },
];
const FS_FILTER_GROUP_ORDER = ['Identifiers', 'PLMN', 'Services'];
// One option per file-backed feature of the service cross-reference: a
// capability/RFU row has no files, so there is nothing to filter to. The
// map is declared later in this file, so it is read when this is called,
// never while the filter block itself is evaluated.
function fsFilterServiceOptions() {
return Object.keys(SERVICES_FS_MAP)
.filter(name => (SERVICES_FS_MAP[name] || []).length)
.map(name => ({ id: 'svc:' + name, label: name, files: SERVICES_FS_MAP[name] }));
}
// The inverted index: uppercased file name -> Set(criterion ids). It is
// static (the file lists and the service map do not change), so it is built
// once on first use.
let _fsFilterIndex = null;
function fsFilterIndexBuild(groups, services) {
const idx = new Map();
for (const o of (groups || []).concat(services || [])) {
for (const f of (o.files || [])) {
const key = String(f).toUpperCase();
if (!idx.has(key)) idx.set(key, new Set());
idx.get(key).add(o.id);
}
}
return idx;
}
function fsFilterIndex() {
if (!_fsFilterIndex) _fsFilterIndex = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
return _fsFilterIndex;
}
// --- the directory rule: the canonical paths of the selected files ---
// A directory stays in a filtered tree only when it lies on the path of a
// file the filter selects, so the ancestors come from the standard file list
// (frontend/uicc_files.json, the same source the SELECT picker and the
// services editor use). A name can live in several trees (EF.SPN is in
// DF.GSM and in ADF.USIM), so the index keeps every path of a name.
// The list is fetched once at startup (`_uiccStdFiles`, filled by
// uiccStdFilesLoad); until it arrives the ancestors are unknown and the
// directories are kept - never hide what cannot be judged.
let _fsFilterPathIndex = null;
let _fsFilterPathIndexSrc = null;
let _fsFilterPaths = null; // { key, paths } of the current selection
let _fsFilterPathsKey = null;
function fsFilterPathIndexBuild(files) {
const idx = new Map();
for (const e of (files || [])) {
if (!e || !e.name || !e.path) continue;
const name = String(e.name).toUpperCase();
const p = String(e.path).toUpperCase();
if (!idx.has(name)) idx.set(name, []);
if (idx.get(name).indexOf(p) < 0) idx.get(name).push(p);
}
return idx;
}
function fsFilterPathIndex() {
if (_fsFilterPathIndex && _fsFilterPathIndexSrc === _uiccStdFiles) return _fsFilterPathIndex;
_fsFilterPathIndexSrc = _uiccStdFiles;
_fsFilterPathIndex = fsFilterPathIndexBuild(_uiccStdFiles);
return _fsFilterPathIndex;
}
// The custom files' canonical paths the selection picks. Custom files sit
// outside the standard list (user-chosen names and DFs), so their own paths
// are the only ancestry the filter has for them - without this the "Custom
// files" filter would hide the DFs holding them.
function fsFilterCustomPaths() {
const list = (typeof pysimCustomFiles !== 'undefined' && pysimCustomFiles) || [];
const out = [];
for (const c of list) {
const p = String((c && c.path) || '').toUpperCase();
if (!p) continue;
if (!pysimFsFilterMatch({ name: c.name, custom: true })) continue;
out.push(p);
}
return out;
}
// The selection's signature: the entries plus the custom files (that set
// changes when the user adds or removes one). The cache is keyed by it, so a
// direct `pysimFsFilter` assignment recomputes as well as the setters'
// explicit invalidation does.
function fsFilterPathsKeyOf() {
return Array.from(pysimFsFilter).sort().join('|') + '#' + fsFilterCustomPaths().sort().join('|');
}
function pysimFsFilterPathsInvalidate() {
_fsFilterPaths = null;
_fsFilterPathsKey = null;
}
// The uppercased canonical paths of every file the current selection picks,
// or null while the list is unavailable (not fetched yet, or an empty/failed
// load): the caller keeps the directories then instead of hiding them all.
function pysimFsFilterMatchingPaths() {
if (!Array.isArray(_uiccStdFiles) || !_uiccStdFiles.length) return null;
const key = fsFilterPathsKeyOf();
if (_fsFilterPaths && _fsFilterPathsKey === key) return _fsFilterPaths;
const out = new Set();
const idx = fsFilterIndex();
for (const [name, ids] of idx) {
let hit = false;
for (const id of pysimFsFilter) if (ids.has(id)) { hit = true; break; }
if (!hit) continue;
for (const p of (fsFilterPathIndex().get(name) || [])) out.add(p);
}
for (const p of fsFilterCustomPaths()) out.add(p);
_fsFilterPaths = out;
_fsFilterPathsKey = key;
return out;
}
let pysimFsFilter = new Set([FS_FILTER_ALL]);
// The persisted selection, normalised: `all` is exclusive, and a missing or
// unusable entry falls back to `all` (show everything).
function pysimFsFilterRestore() {
let saved = [];
try {
const raw = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
if (Array.isArray(raw)) saved = raw.filter(v => typeof v === 'string' && v);
} catch (e) { /* ignore a broken entry */ }
pysimFsFilter = new Set(saved);
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) pysimFsFilter = new Set([FS_FILTER_ALL]);
pysimFsFilterPathsInvalidate();
}
pysimFsFilterRestore();
// The label of one filter entry: the custom flag, a category's own name or
// the feature name of a per-service entry.
function pysimFsFilterLabel(id) {
if (id === FS_FILTER_CUSTOM) return t('Custom files');
const category = FS_FILTER_GROUPS.find(g => g.id === id);
if (category) return t(category.label);
if (id.indexOf('svc:') === 0) return id.slice(4);
return id;
}
// The row's caption: the default or a single choice by name, otherwise the
// number of selected entries.
function pysimFsFilterSummary() {
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return t('All');
if (pysimFsFilter.size === 1) return pysimFsFilterLabel(Array.from(pysimFsFilter)[0]);
return t('Selected: {n}').replace('{n}', pysimFsFilter.size);
}
// The collapsed filter: a single row that expands into a checkbox list -
// "All"/"Custom files", then a heading per group with its categories (the
// Services heading carries one checkbox per file-backed feature).
function pysimFsFilterRender() {
const menu = document.getElementById('pysim-fs-filter-menu');
const btn = document.getElementById('pysim-fs-filter-btn');
if (!menu) return;
const services = fsFilterServiceOptions();
const item = (id, label) =>
'<label class="flex items-center gap-2 px-2 py-0.5 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 text-xs text-gray-700 dark:text-slate-200">' +
'<input type="checkbox" data-filter="' + esc(id) + '"' +
(pysimFsFilter.has(id) ? ' checked' : '') +
' onchange="pysimFsFilterSetEntry(this.dataset.filter, this.checked)">' +
'<span>' + esc(label) + '</span></label>';
const heading = group =>
'<div class="px-2 mt-1 uppercase text-gray-500 dark:text-slate-400">' + esc(t(group)) + '</div>';
if (!FS_FILTER_GROUPS.length && !services.length) {
menu.innerHTML = '<div class="px-2 text-gray-500 dark:text-slate-400">' + esc(t('(no filters yet)')) + '</div>';
} else {
let out = item(FS_FILTER_ALL, t('All')) + item(FS_FILTER_CUSTOM, t('Custom files'));
for (const group of FS_FILTER_GROUP_ORDER) {
const categories = FS_FILTER_GROUPS.filter(g => g.group === group);
const extra = group === 'Services' ? services : [];
if (!categories.length && !extra.length) continue;
out += heading(group);
out += categories.map(g => item(g.id, t(g.label))).join('');
out += extra.map(o => item(o.id, o.label)).join('');
}
menu.innerHTML = out;
}
if (btn) {
btn.textContent = pysimFsFilterSummary();
// a non-"All" selection stands out (blue, like the active sort pill)
const active = pysimFsFilter.size > 0 && !pysimFsFilter.has(FS_FILTER_ALL);
btn.classList.toggle('bg-blue-600', active);
btn.classList.toggle('text-white', active);
btn.classList.toggle('text-gray-700', !active);
btn.classList.toggle('dark:text-slate-300', !active);
btn.classList.toggle('hover:bg-gray-200', !active);
btn.classList.toggle('dark:hover:bg-slate-700', !active);
}
}
// Expand or collapse the menu (`force` overrides the toggle; the outside
// click and Escape handlers pass `false`).
function pysimFsFilterToggle(force) {
const menu = document.getElementById('pysim-fs-filter-menu');
const btn = document.getElementById('pysim-fs-filter-btn');
if (!menu) return;
const open = force === undefined ? menu.classList.contains('hidden') : !!force;
if (open) pysimFsFilterRender();
menu.classList.toggle('hidden', !open);
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
// One checkbox: `all` is exclusive, and an emptied selection means "all".
function pysimFsFilterSetEntry(entry, on) {
const next = new Set(pysimFsFilter);
if (entry === FS_FILTER_ALL) {
next.clear();
next.add(FS_FILTER_ALL);
} else if (on) {
next.delete(FS_FILTER_ALL);
next.add(entry);
} else {
next.delete(entry);
if (!next.size) next.add(FS_FILTER_ALL);
}
pysimFsFilter = next;
pysimFsFilterPathsInvalidate();
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
pysimFsFilterRender();
pysimFsRenderTree();
pysimFsAutoExpandForFilter();
}
// The cross-reference inverted to service number -> feature name, per file
// table. Both maps are declared later in this file, so they are read on the
// first call, never while the filter block itself is evaluated.
let _servicesFeatureByNumber = null;
function servicesFeatureByNumber() {
if (!_servicesFeatureByNumber) {
const out = { sst: {}, ust: {}, est: {}, ist: {} };
for (const f of SERVICES_CROSS_REF) {
for (const table of Object.keys(out)) {
const n = f[table];
if (n === null || n === undefined) continue;
out[table][n] = f.f;
}
}
_servicesFeatureByNumber = out;
}
return _servicesFeatureByNumber;
}
// The service row's cross-link to the file tree: a capability-only service
// names no file, so it carries no link.
function pysimFsFilterBtnHtml(table, n) {
if (!table) return '';
const feature = (servicesFeatureByNumber()[table] || {})[n];
if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return '';
const label = t('To EF filter');
return '<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline" onclick="pysimFsSetFilterToService(\'' +
esc(table) + '\', ' + Number(n) + ')" title="' + esc(label) + '" aria-label="' + esc(label) + '">▾</button>';
}
// Select exactly that service's files in the file tree, persisting the choice
// like any other filter entry.
function pysimFsSetFilterToService(table, n) {
const feature = (servicesFeatureByNumber()[table] || {})[n];
if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return;
pysimFsFilter = new Set(['svc:' + feature]);
pysimFsFilterPathsInvalidate();
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
pysimFsFilterRender();
pysimFsRenderTree();
pysimFsAutoExpandForFilter();
}
// Whether a file node passes the active filter. An empty selection and
// `all` both show everything; otherwise the node's name must be listed under
// one of the selected criteria.
function pysimFsFilterMatch(node) {
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
// "Custom files" matches by the node's custom flag, not its name (custom
// files carry user-chosen names outside the standard list).
if (pysimFsFilter.has(FS_FILTER_CUSTOM) && node && node.custom) return true;
const name = String((node && node.name) || '').toUpperCase();
const ids = name ? fsFilterIndex().get(name) : null;
if (!ids) return false;
for (const id of pysimFsFilter) if (ids.has(id)) return true;
return false;
}
// Whether a node stays in the filtered tree. An empty selection and `all`
// show everything; otherwise a file shows when it matches, and a directory
// only when it matches itself (a service may name a DF - the GPRS feature is
// DF.GSM) or lies on the path of a matching file (its canonical path is a
// prefix of that file's registry path). The MF root always stays, and while
// the standard list is unavailable the ancestors cannot be computed, so every
// directory is kept.
function pysimFsFilterShow(node) {
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
if (!node) return false;
if (!node.isDir) return pysimFsFilterMatch(node);
if (pysimFsFilterMatch(node)) return true;
const path = String(pysimFsNodePath(node) || '').toUpperCase();
if (!path || path === 'MF') return true;
const paths = pysimFsFilterMatchingPaths();
if (!paths) return true;
for (const p of paths) if (p === path || p.indexOf(path + '/') === 0) return true;
return false;
}
async function pysimFsRefresh() { async function pysimFsRefresh() {
const detailEl = document.getElementById('pysim-fs-detail'); const detailEl = document.getElementById('pysim-fs-detail');
detailEl.classList.add('hidden'); detailEl.classList.add('hidden');
@@ -16271,6 +16616,7 @@ function pysimFsRenderNode(node, depth) {
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + '&nbsp;&nbsp;&nbsp;&nbsp;' + esc(t('(empty)')) + '</div>'; html += '<div class="text-gray-400 dark:text-slate-500">' + pad + '&nbsp;&nbsp;&nbsp;&nbsp;' + esc(t('(empty)')) + '</div>';
} else { } else {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) { for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
if (!pysimFsFilterShow(child)) continue;
html += pysimFsRenderNode(child, depth + 1); html += pysimFsRenderNode(child, depth + 1);
} }
} }
@@ -16337,6 +16683,42 @@ async function pysimFsToggleDir(path) {
if (node.expanded) pysimRefresh(); if (node.expanded) pysimRefresh();
} }
// Expand every directory that exists (skipping ones the probe already found
// absent), loading children on demand. Also the auto-action after a filter
// narrows to a specific category, so the matched files are visible.
async function pysimFsExpandAll() {
const stack = [pysimFsTreeRoot];
while (stack.length) {
const node = stack.pop();
if (!node || !node.isDir) continue;
if (node.exists === false) continue;
if (!node.children) {
await pysimFsLoadChildren(node);
if (node.exists === false) continue;
}
node.expanded = true;
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
}
pysimFsRenderTree();
}
function pysimFsCollapseAll() {
const stack = [pysimFsTreeRoot];
while (stack.length) {
const node = stack.pop();
if (!node || !node.isDir) continue;
node.expanded = false;
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
}
pysimFsRenderTree();
}
// A filter that narrows to a specific category expands the tree so the
// matched files are shown; "All"/empty leaves the tree as-is.
function pysimFsAutoExpandForFilter() {
if (pysimFsFilter.size > 0 && !pysimFsFilter.has(FS_FILTER_ALL)) pysimFsExpandAll();
}
let pysimFsSelected = null; let pysimFsSelected = null;
function pysimFsInfoHtml(sel, name, opts) { function pysimFsInfoHtml(sel, name, opts) {
@@ -26164,20 +26546,22 @@ const EF_PNN_ITEMS = {
}; };
// Service-table and scalar editors (wave 2). The bits kind renders the whole // Service-table and scalar editors (wave 2). The bits kind renders the whole
// table as a filterable checkbox grid; its value is the decoder's list. // table as a filterable checkbox grid; its value is the decoder's list. The
// service-table fields carry `table` (sst/ust/est/ist), which adds the row's
// "To EF filter" cross-link to the file tree.
const EF_ACC_CLASSES = { const EF_ACC_CLASSES = {
0: 'Class 0', 1: 'Class 1', 2: 'Class 2', 3: 'Class 3', 4: 'Class 4', 5: 'Class 5', 0: 'Class 0', 1: 'Class 1', 2: 'Class 2', 3: 'Class 3', 4: 'Class 4', 5: 'Class 5',
6: 'Class 6', 7: 'Class 7', 8: 'Class 8', 9: 'Class 9', 11: 'Class 11', 6: 'Class 6', 7: 'Class 7', 8: 'Class 8', 9: 'Class 9', 11: 'Class 11',
12: 'Class 12', 13: 'Class 13', 14: 'Class 14', 15: 'Class 15', 12: 'Class 12', 13: 'Class 13', 14: 'Class 14', 15: 'Class 15',
}; };
const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES }]; const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES, table: 'ust' }];
const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES }]; const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES, table: 'ist' }];
const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES }]; const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES, table: 'est' }];
// EF.SST is the SIM table with two bits per service: the tri-state grid // EF.SST is the SIM table with two bits per service: the tri-state grid
// (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid. // (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid.
const EF_SST_FIELDS = [{ const EF_SST_FIELDS = [{
key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES, key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES, table: 'sst',
hint: 'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), ' hint: 'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), '
+ 'off = allocated but not activated (10), on = allocated and activated (11).', + 'off = allocated but not activated (10), on = allocated and activated (11).',
}]; }];
@@ -26563,7 +26947,8 @@ function efDecFieldHtml(f, value) {
'<input type="checkbox" data-bit="' + n + '" data-bit-name="' + esc(name) + '"' + '<input type="checkbox" data-bit="' + n + '" data-bit-name="' + esc(name) + '"' +
(selected.has(String(n)) ? ' checked' : '') + ' onchange="pysimFsDecRowCheck(this)">' + (selected.has(String(n)) ? ' checked' : '') + ' onchange="pysimFsDecRowCheck(this)">' +
'<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' + '<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' +
'<span class="break-all">' + esc(t(name)) + '</span></label>'; '<span class="break-all">' + esc(t(name)) + '</span>' +
pysimFsFilterBtnHtml(f.table, n) + '</label>';
}).join('') + '</div></div>'; }).join('') + '</div></div>';
} else if (f.kind === 'bits2') { } else if (f.kind === 'bits2') {
// the tri-state grid (EF.SST): one select per service, the decoder's // the tri-state grid (EF.SST): one select per service, the decoder's
@@ -26588,7 +26973,7 @@ function efDecFieldHtml(f, value) {
'<select data-bit2="' + n + '" data-bit2-name="' + esc(name) + '" onchange="pysimFsDecRowCheck(this)" class="border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200">' + '<select data-bit2="' + n + '" data-bit2-name="' + esc(name) + '" onchange="pysimFsDecRowCheck(this)" class="border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200">' +
states3.map(o => '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' + states3.map(o => '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' +
esc(o[1]) + '</option>').join('') + esc(o[1]) + '</option>').join('') +
'</select></label>'; '</select>' + pysimFsFilterBtnHtml(f.table, n) + '</label>';
}).join('') + '</div></div>'; }).join('') + '</div></div>';
} else if (f.kind === 'select') { } else if (f.kind === 'select') {
const cur = String(value === undefined || value === null ? '' : value); const cur = String(value === undefined || value === null ? '' : value);
@@ -27541,6 +27926,7 @@ const LANG_RU = {
'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается', 'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
'Services': 'Сервисы', 'Services': 'Сервисы',
'Filter services': 'Фильтр сервисов', 'Filter services': 'Фильтр сервисов',
'To EF filter': 'В фильтр EF',
'Off': 'Выключен', 'Off': 'Выключен',
'On': 'Включён', 'On': 'Включён',
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён', 'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
@@ -27713,6 +28099,16 @@ const LANG_RU = {
'No entries yet.': 'Записей пока нет.', 'No entries yet.': 'Записей пока нет.',
'Sort:': 'Сортировка:', 'Sort:': 'Сортировка:',
'Name': 'Имя', 'Name': 'Имя',
'Filter:': 'Фильтр:',
'(no filters yet)': '(фильтров пока нет)',
'Identifiers': 'Идентификаторы',
'PLMN selection': 'Выбор PLMN',
'Provider names': 'Имена операторов',
'WLAN / I-WLAN': 'WLAN / I-WLAN',
'Custom files': 'Пользовательские файлы',
'Selected: {n}': 'Выбрано: {n}',
'Expand all': 'Развернуть все',
'Collapse all': 'Свернуть все',
'Probe all files': 'Проверить все файлы', 'Probe all files': 'Проверить все файлы',
'Feature': 'Функция', 'Feature': 'Функция',
'SST': 'SST', 'SST': 'SST',
@@ -28745,6 +29141,7 @@ function isViewVisible(id) {
// Dynamic views build their labels with t() at render time; re-render the // Dynamic views build their labels with t() at render time; re-render the
// visible ones after a language switch so they don't stay in the old language. // visible ones after a language switch so they don't stay in the old language.
function refreshDynamicI18n() { function refreshDynamicI18n() {
pysimFsFilterRender();
if (isViewVisible('tab-profiler')) { if (isViewVisible('tab-profiler')) {
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); } if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
else if (profilerView === 'editor') profilerRenderEditor(); else if (profilerView === 'editor') profilerRenderEditor();
@@ -28820,6 +29217,19 @@ document.getElementById('theme-btn').textContent = document.documentElement.clas
updateConvFields(); updateConvFields();
updateSp(); updateSp();
uiccStdFilesLoad(); uiccStdFilesLoad();
pysimFsFilterRender();
// the file-tree filter's dropdown closes on an outside click or Escape (the
// button toggles it, so a click on the button itself is left to its handler)
document.addEventListener('click', e => {
const target = e.target;
if (!target || !target.closest) return;
if (!target.closest('#pysim-fs-filter-btn') && !target.closest('#pysim-fs-filter-menu')) {
pysimFsFilterToggle(false);
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') pysimFsFilterToggle(false);
});
updateSpKic(); updateSpKic();
updateSpKid(); updateSpKid();
chainInit('chain-sim'); chainInit('chain-sim');
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v484'; const CACHE = 'simple-v486';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+18 -2
View File
@@ -48,6 +48,7 @@ const FNS = [
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn', 'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'efEncError', 'efFindEncoder', 'efHasEncoder', 'efEncError',
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml', 'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
'servicesFeatureByNumber', 'pysimFsFilterBtnHtml',
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml', 'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
'efDecRecordsHtml', 'efDecEditHtml', 'efDecRecordsHtml', 'efDecEditHtml',
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems', 'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
@@ -60,10 +61,11 @@ const FNS = [
]; ];
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n' let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
+ 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n'; + 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\nvar _servicesFeatureByNumber = null;\n';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS', for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS']) { 'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS',
'SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) {
code += extractConst(html, c); code += extractConst(html, c);
} }
eval(code); eval(code);
@@ -739,6 +741,20 @@ test('the bits field renders a filtered checkbox grid and collects {n,name}', ()
] }); ] });
}); });
test('the service grids link file-backed rows to the file tree filter', () => {
const ust = efDecEditHtml(efDecEditModel('EF.UST', '6f38', { kind: 'transparent', data: UST_ONE }), UST_ONE);
// service 2 = FDN (files), service 99 = URI support by UICC (capability)
assert.match(ust, /pysimFsSetFilterToService\('ust', 2\)/);
assert.ok(!/pysimFsSetFilterToService\('ust', 99\)/.test(ust));
const sst = efDecEditHtml(efDecEditModel('EF.SST', '6f38', { kind: 'transparent', data: '4C' }, '7F20'), '4C');
// service 4 = SMS (files), service 1 = CHV1 disable function (capability)
assert.match(sst, /pysimFsSetFilterToService\('sst', 4\)/);
assert.ok(!/pysimFsSetFilterToService\('sst', 1\)/.test(sst));
// EF.ACC's bits grid is not a service table - no cross-link at all
const acc = efDecEditHtml(efDecEditModel('EF.ACC', '6f78', { kind: 'transparent', data: ACC_ONE }), ACC_ONE);
assert.ok(!/pysimFsSetFilterToService/.test(acc));
});
test('the bits filter hides the non-matching rows', () => { test('the bits filter hides the non-matching rows', () => {
const rows = [ const rows = [
{ textContent: '1 Local Phone Book', style: {} }, { textContent: '1 Local Phone Book', style: {} },
+639
View File
@@ -0,0 +1,639 @@
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);
}
function extractConst(src, name) {
const m = new RegExp('const ' + name + ' = ').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
const begin = m.index + m[0].length;
const opener = src[begin];
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
if (!closer) throw new Error('unsupported const ' + name);
let depth = 0, i = begin;
for (; i < src.length; i++) {
if (src[i] === opener) depth++;
else if (src[i] === closer) {
depth--;
if (depth === 0) { i++; break; }
}
}
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
}
function extractStrConst(src, name) {
const m = new RegExp("const " + name + " = '([^']*)';").exec(src);
if (!m) throw new Error('const ' + name + ' not found');
return 'var ' + name + " = '" + m[1] + "';\n";
}
// module state the extracted functions close over
let pysimFsFilter = new Set(['all']);
let _fsFilterIndex = null;
// The shipped standard list stands in for the fetched one (uicc_files.json is
// what uiccStdFilesLoad caches; the filter reads its entries synchronously).
const stdAsset = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'uicc_files.json'), 'utf8'));
let _uiccStdFiles = stdAsset.files;
let _fsFilterPathIndex = null;
let _fsFilterPathIndexSrc = null;
let _fsFilterPaths = null;
let _fsFilterPathsKey = null;
let pysimCustomFiles = [];
const _store = {};
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
let pysimFsLoadChildren = async node => { if (!node.children) node.children = []; };
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
setItem: (k, v) => { _store[k] = String(v); },
removeItem: k => { delete _store[k]; },
};
globalThis.esc = s => String(s);
globalThis.t = k => k;
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
// The filter's two DOM elements: the collapsed row's button and the hidden
// menu it expands into. The menu only needs a settable innerHTML (the
// render never reads the DOM back) and a classList for the open/closed state.
function fakeBtn() {
const classes = new Set();
return {
textContent: '',
attrs: {},
classes,
classList: {
contains: c => classes.has(c),
toggle(c, force) {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
setAttribute(k, v) { this.attrs[k] = String(v); },
getAttribute(k) { return this.attrs[k]; },
};
}
function fakeMenu() {
const classes = new Set(['hidden']);
return {
_html: '',
classes,
classList: {
contains: c => classes.has(c),
toggle(c, force) {
const on = force === undefined ? !classes.has(c) : !!force;
if (on) classes.add(c); else classes.delete(c);
return on;
},
},
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = v; },
};
}
const dom = { menu: null, btn: null };
globalThis.document = {
getElementById: id => (id === 'pysim-fs-filter-menu' ? dom.menu
: (id === 'pysim-fs-filter-btn' ? dom.btn : null)),
};
let code = '';
for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex',
'fsFilterPathIndexBuild', 'fsFilterPathIndex', 'fsFilterCustomPaths',
'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths',
'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel',
'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle',
'pysimFsFilterSetEntry', 'pysimFsFilterRestore', 'pysimFsNodePath',
'pysimFsExpandAll', 'pysimFsCollapseAll', 'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c);
code += extractStrConst(html, 'FS_FILTER_ALL');
code += extractStrConst(html, 'FS_FILTER_CUSTOM');
eval(code);
function withFilter(ids, fn) {
const saved = pysimFsFilter;
pysimFsFilter = new Set(ids);
try { return fn(); } finally { pysimFsFilter = saved; }
}
// The checkbox rows the render wrote: [{id, checked}] in document order.
function renderedBoxes() {
const out = [];
for (const m of (dom.menu ? dom.menu.innerHTML : '').matchAll(
/<input type="checkbox" data-filter="([^"]*)"( checked)?/g)) {
out.push({ id: m[1], checked: !!m[2] });
}
return out;
}
const box = id => renderedBoxes().find(b => b.id === id);
const fileBackedFeatures = () => Object.keys(SERVICES_FS_MAP).filter(n => SERVICES_FS_MAP[n].length);
test('the file tree has a collapsed filter dropdown next to the sort pills', () => {
assert.match(html, /id="pysim-fs-filter-btn"[^>]*aria-haspopup="true"[^>]*aria-expanded="false"[^>]*onclick="pysimFsFilterToggle\(\)"/);
assert.match(html, /id="pysim-fs-filter-menu"[^>]*role="group"[^>]*class="hidden absolute/);
assert.match(html, /data-l10n="Filter:"/);
// the multiselect (and its onchange handler) is gone
assert.ok(!/id="pysim-fs-filter"/.test(html));
assert.ok(!/pysimFsSetFilter\(/.test(html));
});
test('an outside click or Escape closes the menu', () => {
const click = html.match(/document\.addEventListener\('click', e => \{([\s\S]{0,600}?)\n\}\);/);
assert.ok(click, 'no document click listener');
assert.match(click[1], /#pysim-fs-filter-btn/);
assert.match(click[1], /#pysim-fs-filter-menu/);
assert.match(click[1], /pysimFsFilterToggle\(false\)/);
const key = html.match(/document\.addEventListener\('keydown', e => \{([\s\S]{0,300}?)\n\}\);/);
assert.ok(key, 'no document keydown listener');
assert.match(key[1], /Escape/);
assert.match(key[1], /pysimFsFilterToggle\(false\)/);
});
test('the four category groups name their files', () => {
assert.deepStrictEqual(FS_FILTER_GROUPS.map(g => g.id),
['identifiers', 'plmn-selection', 'provider-names', 'wlan']);
const files = id => new Set(FS_FILTER_GROUPS.find(g => g.id === id).files);
assert.deepStrictEqual([...files('identifiers')],
['EF.ICCID', 'EF.IMSI', 'EF.MSISDN', 'EF.IMPI', 'EF.IMPU', 'EF.SUPI_NAI', 'EF.GID1', 'EF.GID2']);
assert.deepStrictEqual([...files('plmn-selection')],
['EF.PLMNsel', 'EF.PLMNwAcT', 'EF.OPLMNwAcT', 'EF.HPLMNwAcT', 'EF.EHPLMN', 'EF.FPLMN',
'EF.HPPLMN', 'EF.LRPLMNSI', 'EF.EHPLMNPI', 'EF.MCHPPLMN']);
assert.deepStrictEqual([...files('provider-names')],
['EF.SPN', 'EF.PNN', 'EF.OPL', 'EF.OPL5G', 'EF.SPDI', 'EF.SPNI', 'EF.PNNI', 'EF.CNL']);
assert.deepStrictEqual([...files('wlan')],
['EF.UPLMNWLAN', 'EF.OPLMNWLAN', 'EF.WRI', 'EF.HWSIDL', 'EF.WEHPLMNPI', 'EF.WHPI',
'EF.WLRPLMN', 'EF.HPLMNDAI']);
assert.strictEqual(FS_FILTER_ALL, 'all');
assert.strictEqual(FS_FILTER_CUSTOM, 'custom');
});
test('the per-service options come from the file-backed features only', () => {
const services = fsFilterServiceOptions();
const features = fileBackedFeatures();
assert.ok(features.length > 50, 'expected the cross-reference to be available');
assert.strictEqual(services.length, features.length);
assert.ok(services.every(o => o.id === 'svc:' + o.label));
const spn = services.find(o => o.id === 'svc:Service Provider Name (SPN)');
assert.deepStrictEqual(spn.files, ['EF.SPN']);
// capability/RFU rows carry no files, so they are not offered
assert.ok(!services.some(o => o.id === 'svc:Proactive SIM' || o.id === 'svc:CHV1 disable function'));
});
test('the inverted index keys the uppercased names and merges memberships', () => {
const idx = fsFilterIndexBuild(FS_FILTER_GROUPS, fsFilterServiceOptions());
const ids = name => Array.from(idx.get(name.toUpperCase()) || []);
assert.ok(ids('EF.ICCID').includes('identifiers'));
assert.ok(ids('EF.PLMNwAcT').includes('plmn-selection'));
assert.ok(ids('EF.SPN').includes('provider-names'));
assert.ok(ids('EF.SPN').includes('svc:Service Provider Name (SPN)'));
// SMS/CB files are covered by their per-service options, not a category
assert.ok(ids('EF.SMS').includes('svc:Short Message Storage (SMS)'));
assert.ok(ids('EF.CBMI').includes('svc:Cell Broadcast Message Identifier (CBMI)'));
// EF.PSISMSC is neither a category nor a file-backed service, so nothing
// maps it (pySim models no such file, and no service lists it either)
assert.ok(!idx.has('EF.PSISMSC'));
assert.ok(!fileBackedFeatures().some(n => (SERVICES_FS_MAP[n] || []).includes('EF.PSISMSC')));
});
test('a file matches the selected categories, case-insensitively', () => {
withFilter(['identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', fid: '2FE2' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'ef.iccid', fid: '2FE2' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', fid: '6F46' }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), false);
});
withFilter(['svc:Service Provider Name (SPN)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.PNN' }), false);
});
withFilter(['identifiers', 'svc:Short Message Status Reports (SMSR)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.IMSI' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SMSR' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), false);
});
withFilter(['svc:Short Message Storage (SMS)'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), false);
});
});
test('an empty filter and "all" show everything', () => {
withFilter([], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', isDir: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: undefined }), true);
});
withFilter(['all'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ZZZ' }), true);
});
// a stale multi-selection that still names all also shows everything
withFilter(['all', 'identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ECC' }), true);
});
});
test('a capability-only service selection matches nothing', () => {
withFilter(['svc:Proactive SIM'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN' }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.UST' }), false);
});
});
test('"custom files" matches only the injected custom nodes', () => {
withFilter(['custom'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', custom: false }), false);
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: true }), true);
});
withFilter(['custom', 'identifiers'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ICCID', custom: false }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'MyCustomEF', custom: true }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN', custom: false }), false);
});
});
// A small tree with real parent links (pysimFsNodePath walks them): MF with
// DF.TELECOM (EF.MSISDN), DF.GSM (EF.SPN, EF.ECC) and ADF.USIM (DF.WLAN).
function fsTree() {
const root = { name: 'MF', fid: '3F00', isDir: true, children: [], parent: null, expanded: true };
const add = (parent, name, fid, isDir) => {
const n = { name, fid, isDir: !!isDir, children: isDir ? [] : null, parent, expanded: true };
parent.children.push(n);
return n;
};
const telecom = add(root, 'DF.TELECOM', '7F10', true);
const msisdn = add(telecom, 'EF.MSISDN', '6F40', false);
const gsm = add(root, 'DF.GSM', '7F20', true);
const spn = add(gsm, 'EF.SPN', '6F46', false);
const ecc = add(gsm, 'EF.ECC', '6FB7', false);
const usim = add(root, 'ADF.USIM', '7FFF', true);
const wlan = add(usim, 'DF.WLAN', '5F40', true);
return { root, telecom, msisdn, gsm, spn, ecc, usim, wlan };
}
test('a directory shows only on the path of a file the filter selects', () => {
const t = fsTree();
withFilter(['provider-names'], () => {
// the root always stays; DF.GSM holds EF.SPN (MF/7F20/6F46)
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
// ... and ADF.USIM holds its own EF.SPN copy
assert.strictEqual(pysimFsFilterShow(t.usim), true);
// neither DF.TELECOM nor DF.WLAN holds a provider-name file
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
// files keep their name rule
assert.strictEqual(pysimFsFilterShow(t.spn), true);
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
});
withFilter(['identifiers'], () => {
// EF.MSISDN is in DF.TELECOM, EF.IMSI/EF.GID1/2 in DF.GSM
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.msisdn), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.usim), true); // EF.IMSI/EF.MSISDN copies
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
});
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
assert.strictEqual(pysimFsFilterShow(t.gsm), true); // EF.CBMI
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
assert.strictEqual(pysimFsFilterShow(t.wlan), false);
});
});
test('a directory named by the selection itself shows', () => {
const t = fsTree();
// the GPRS feature names DF.GSM (its SST bit covers the DF, not a file)
withFilter(['svc:GPRS'], () => {
assert.strictEqual(pysimFsFilterMatch(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
});
});
test('"all" and an empty selection show every node', () => {
const t = fsTree();
for (const ids of [['all'], []]) {
withFilter(ids, () => {
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
assert.strictEqual(pysimFsFilterShow(t.ecc), true);
});
}
});
test('directories are kept until the standard list is available', () => {
const t = fsTree();
const saved = _uiccStdFiles;
_uiccStdFiles = null;
try {
withFilter(['provider-names'], () => {
assert.strictEqual(pysimFsFilterMatchingPaths(), null);
// the ancestors cannot be judged, so nothing vanishes
assert.strictEqual(pysimFsFilterShow(t.telecom), true);
assert.strictEqual(pysimFsFilterShow(t.wlan), true);
// files still obey the name rule
assert.strictEqual(pysimFsFilterShow(t.ecc), false);
});
} finally {
_uiccStdFiles = saved;
}
});
test('the standard list gives a name every tree it lives in', () => {
const idx = fsFilterPathIndexBuild(_uiccStdFiles);
assert.deepStrictEqual(idx.get('EF.SPN').slice().sort(),
['ADF.USIM-NON-IMSI/6F46', 'ADF.USIM/6F46', 'MF/7F20/6F46']);
assert.deepStrictEqual(idx.get('EF.ICCID'), ['MF/2FE2']);
// the index is cached for the loaded list
assert.strictEqual(fsFilterPathIndex(), fsFilterPathIndex());
});
test('the selection resolves to the paths of its files', () => {
withFilter(['svc:Cell Broadcast Message Identifier (CBMI)'], () => {
const paths = pysimFsFilterMatchingPaths();
assert.ok(paths instanceof Set, 'expected a Set');
assert.deepStrictEqual(Array.from(paths).sort(), [
'ADF.USIM-NON-IMSI/6F45', 'ADF.USIM/6F45', 'MF/7F20/6F45',
]);
// cached for the same selection
assert.strictEqual(pysimFsFilterMatchingPaths(), paths);
});
withFilter(['custom'], () => {
assert.deepStrictEqual(Array.from(pysimFsFilterMatchingPaths()), []);
});
});
test('"custom files" keeps the directories that hold them', () => {
const t = fsTree();
const custom = { name: 'EF.MYFILE', fid: '6F99', isDir: false, children: null, parent: t.gsm, custom: true };
t.gsm.children.push(custom);
pysimCustomFiles = [{ path: 'MF/7F20/6F99', name: 'EF.MYFILE', kind: 'ef' }];
try {
withFilter(['custom'], () => {
// custom files are outside the standard list: their own paths are
// the ancestry, so the holding DF stays
assert.strictEqual(pysimFsFilterShow(custom), true);
assert.strictEqual(pysimFsFilterShow(t.gsm), true);
assert.strictEqual(pysimFsFilterShow(t.root), true);
assert.strictEqual(pysimFsFilterShow(t.telecom), false);
});
} finally {
pysimCustomFiles = [];
}
});
test('the menu lists all, custom, the three headings and the services', () => {
pysimFsFilter = new Set(['all']);
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilterRender();
const out = dom.menu.innerHTML;
assert.match(out, /^<label[^>]*><input type="checkbox" data-filter="all" checked/);
assert.match(out, /data-filter="custom"/);
assert.match(out, /<div class="[^"]*">Identifiers<\/div>/);
assert.match(out, /<div class="[^"]*">PLMN<\/div>/);
assert.match(out, /<div class="[^"]*">Services<\/div>/);
// the headings come in the declared order
assert.ok(out.indexOf('>Identifiers</div>') < out.indexOf('>PLMN</div>'));
assert.ok(out.indexOf('>PLMN</div>') < out.indexOf('>Services</div>'));
// the categories sit under their headings
assert.ok(out.indexOf('data-filter="provider-names"') < out.indexOf('>PLMN</div>'));
assert.ok(out.indexOf('data-filter="plmn-selection"') > out.indexOf('>PLMN</div>'));
// the per-service options sit under the Services heading
assert.ok(out.indexOf('data-filter="svc:') > out.indexOf('>Services</div>'));
const boxes = renderedBoxes();
assert.strictEqual(boxes.length, 2 + FS_FILTER_GROUPS.length + fsFilterServiceOptions().length);
assert.strictEqual(boxes[0].id, 'all');
assert.strictEqual(boxes[0].checked, true);
assert.strictEqual(box('custom').checked, false);
assert.ok(boxes.some(b => b.id === 'svc:Service Provider Name (SPN)'));
assert.ok(!boxes.some(b => b.id === 'svc:Proactive SIM'));
// the row shows the summary, and the checkbox calls the setter directly
assert.strictEqual(dom.btn.textContent, 'All');
assert.match(out, /onchange="pysimFsFilterSetEntry\(this\.dataset\.filter, this\.checked\)"/);
});
test('the button turns blue when a filter is active and back for all', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilter = new Set(['identifiers']);
pysimFsFilterRender();
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), true);
assert.strictEqual(dom.btn.classes.has('text-white'), true);
assert.strictEqual(dom.btn.classes.has('text-gray-700'), false);
pysimFsFilter = new Set(['all']);
pysimFsFilterRender();
assert.strictEqual(dom.btn.classes.has('bg-blue-600'), false);
assert.strictEqual(dom.btn.classes.has('text-white'), false);
assert.strictEqual(dom.btn.classes.has('text-gray-700'), true);
});
test('expand all and collapse all walk the directory tree, skipping absent DFs', async () => {
const telecom = { name: 'DF.TELECOM', fid: '7F10', isDir: true, exists: true, expanded: false,
parent: null, children: [
{ name: 'DF.PHONEBOOK', fid: '5F3A', isDir: true, exists: true, expanded: false, parent: null, children: null },
] };
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, exists: false, expanded: false, parent: null, children: null };
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, exists: true, expanded: true, parent: null,
children: [gsm, telecom] };
let loads = 0;
pysimFsLoadChildren = async node => { if (!node.children) { node.children = []; } loads++; };
try {
_treeRenders = 0;
await pysimFsExpandAll();
assert.strictEqual(pysimFsTreeRoot.expanded, true);
assert.strictEqual(gsm.expanded, false, 'an absent DF is not expanded');
assert.strictEqual(telecom.expanded, true);
assert.strictEqual(telecom.children[0].expanded, true);
assert.ok(loads > 0, 'lazy children are loaded on demand');
assert.strictEqual(_treeRenders, 1);
pysimFsCollapseAll();
assert.strictEqual(pysimFsTreeRoot.expanded, false);
assert.strictEqual(telecom.expanded, false);
assert.strictEqual(telecom.children[0].expanded, false);
} finally {
pysimFsTreeRoot = null;
}
});
test('auto-expand fires only for a non-"all" filter', () => {
// expand-all (with a null tree) still re-renders once; pin the branch
pysimFsFilter = new Set(['all']);
_treeRenders = 0;
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 0, '"all" does not expand');
pysimFsFilter = new Set(['identifiers']);
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 1, 'a category expands the tree');
});
test('the menu marks the saved selection', () => {
pysimFsFilter = new Set(['svc:Short Message Storage (SMS)', 'identifiers']);
dom.menu = fakeMenu();
dom.btn = fakeBtn();
pysimFsFilterRender();
assert.deepStrictEqual(renderedBoxes().filter(b => b.checked).map(b => b.id),
['identifiers', 'svc:Short Message Storage (SMS)']);
assert.strictEqual(box('all').checked, false);
assert.strictEqual(dom.btn.textContent, 'Selected: 2');
});
test('the button summary names the single entry or counts the selection', () => {
const summary = ids => withFilter(ids, () => pysimFsFilterSummary());
assert.strictEqual(summary(['all']), 'All');
// an empty selection also means everything
assert.strictEqual(summary([]), 'All');
assert.strictEqual(summary(['all', 'plmn-selection']), 'All');
assert.strictEqual(summary(['custom']), 'Custom files');
assert.strictEqual(summary(['plmn-selection']), 'PLMN selection');
assert.strictEqual(summary(['svc:Service Provider Name (SPN)']), 'Service Provider Name (SPN)');
assert.strictEqual(summary(['plmn-selection', 'wlan']), 'Selected: 2');
// an unknown id falls back to itself
assert.strictEqual(summary(['providers']), 'providers');
});
test('the menu toggles open and closed', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
pysimFsFilterToggle();
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'true');
pysimFsFilterToggle();
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
assert.strictEqual(dom.btn.getAttribute('aria-expanded'), 'false');
// the forced forms (the outside click / Escape handlers pass false)
pysimFsFilterToggle(true);
assert.strictEqual(dom.menu.classList.contains('hidden'), false);
pysimFsFilterToggle(false);
assert.strictEqual(dom.menu.classList.contains('hidden'), true);
});
test('the checkbox rows keep "all" exclusive and persist the selection', () => {
dom.menu = fakeMenu();
dom.btn = fakeBtn();
delete _store.simple_fs_filter;
_treeRenders = 0;
pysimFsFilter = new Set(['all']);
pysimFsFilterSetEntry('all', true);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
assert.strictEqual(_store.simple_fs_filter, '["all"]');
assert.strictEqual(_treeRenders, 1);
// choosing a category drops "all"
pysimFsFilterSetEntry('provider-names', true);
assert.deepStrictEqual([...pysimFsFilter], ['provider-names']);
assert.strictEqual(_store.simple_fs_filter, '["provider-names"]');
assert.strictEqual(_treeRenders, 3); // render + the auto-expand's render
assert.strictEqual(box('provider-names').checked, true);
assert.strictEqual(box('all').checked, false);
assert.strictEqual(dom.btn.textContent, 'Provider names');
// several categories stay selected
pysimFsFilterSetEntry('identifiers', true);
assert.deepStrictEqual([...pysimFsFilter], ['provider-names', 'identifiers']);
// unchecking one keeps the other
pysimFsFilterSetEntry('provider-names', false);
assert.deepStrictEqual([...pysimFsFilter], ['identifiers']);
// unchecking the last one restores the default
pysimFsFilterSetEntry('identifiers', false);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
assert.strictEqual(_store.simple_fs_filter, '["all"]');
assert.strictEqual(box('all').checked, true);
// "all" is exclusive even when another entry is selected
pysimFsFilter = new Set(['provider-names']);
pysimFsFilterSetEntry('all', true);
assert.deepStrictEqual([...pysimFsFilter], ['all']);
});
test('the saved selection is restored, defaulting to "all"', () => {
delete _store.simple_fs_filter;
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = JSON.stringify(['providers', 'svc:Service Provider Name (SPN)']);
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['providers', 'svc:Service Provider Name (SPN)']);
// `all` is exclusive; an empty or broken save falls back to it
_store.simple_fs_filter = JSON.stringify(['all', 'plmn-selection']);
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = '[]';
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
_store.simple_fs_filter = '{not json';
pysimFsFilterRestore();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
});
test('the render consults the filter when walking children', () => {
assert.match(html, /if \(!pysimFsFilterShow\(child\)\) continue;/);
assert.ok(!/pysimFsFilterMatch\(child\)/.test(html));
});
test('the directory rule uses the registry paths, not the loaded children', () => {
const fn = extractFunc(html, 'pysimFsFilterShow');
assert.ok(!fn.includes('node.children'), fn);
assert.ok(fn.indexOf('pysimFsFilterMatchingPaths()') > 0, fn);
assert.ok(fn.indexOf("path === 'MF'") > 0, fn);
// the standard list is read synchronously from the cached array
assert.match(html, /let _uiccStdFiles = null;/);
assert.ok(extractFunc(html, 'pysimFsFilterMatchingPaths').indexOf('_uiccStdFiles') > 0);
});
test('every filter change drops the path cache before the tree redraws', () => {
for (const name of ['pysimFsFilterSetEntry', 'pysimFsSetFilterToService', 'pysimFsFilterRestore']) {
const fn = extractFunc(html, name);
const at = fn.indexOf('pysimFsFilterPathsInvalidate()');
assert.ok(at > 0, name + ' does not invalidate the path cache');
const render = fn.indexOf('pysimFsRenderTree()');
if (render > 0) assert.ok(at < render, name + ': the cache is dropped before the redraw');
}
// the list arriving redraws a tree that was filtered while it loaded
const load = extractFunc(html, 'uiccStdFilesLoad');
assert.ok(load.indexOf('pysimFsRenderTree()') > 0, load);
});
test('the filter state is persisted under simple_fs_filter', () => {
const fn = extractFunc(html, 'pysimFsFilterSetEntry');
assert.ok(fn.indexOf("localStorage.setItem('simple_fs_filter'") > 0, fn);
assert.ok(fn.indexOf('pysimFsRenderTree()') > 0, fn);
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
});
test('the dropdown is re-rendered on language switch', () => {
const fn = extractFunc(html, 'refreshDynamicI18n');
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
});
test('the filter labels have Russian entries', () => {
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
for (const key of ['All', 'Custom files', 'Identifiers', 'PLMN', 'Services', 'PLMN selection',
'Provider names', 'WLAN / I-WLAN', 'Selected: {n}']) {
assert.ok(dict.includes("'" + key + "':"), 'missing Russian entry: ' + key);
}
});
+3
View File
@@ -22,8 +22,11 @@ function extractFunc(src, name) {
} }
let code = 'var pysimFsSort = "fid";\n'; let code = 'var pysimFsSort = "fid";\n';
code += 'var pysimFsFilter = new Set();\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n'; code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsNodePath') + '\n'; code += extractFunc(html, 'pysimFsNodePath') + '\n';
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
code += extractFunc(html, 'pysimFsFilterShow') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n'; code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n'; code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code); eval(code);
+134
View File
@@ -0,0 +1,134 @@
// The service-table rows' cross-link to the file-tree filter: a table row
// that is backed by files gets a "To EF filter" button that narrows the tree
// to exactly that service's files.
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);
}
function extractConst(src, name) {
const m = new RegExp('const ' + name + ' = ').exec(src);
if (!m) throw new Error('const ' + name + ' not found');
const begin = m.index + m[0].length;
const opener = src[begin];
const closer = opener === '[' ? ']' : (opener === '{' ? '}' : null);
if (!closer) throw new Error('unsupported const ' + name);
let depth = 0, i = begin;
for (; i < src.length; i++) {
if (src[i] === opener) depth++;
else if (src[i] === closer) {
depth--;
if (depth === 0) { i++; break; }
}
}
return 'var ' + name + ' = ' + src.slice(begin, i) + ';\n';
}
// module state the extracted functions close over
let pysimFsFilter = new Set(['all']);
let _servicesFeatureByNumber = null;
// the tree filter's matching-path cache (pysimFsSetFilterToService drops it)
let _fsFilterPaths = null;
let _fsFilterPathsKey = null;
const _store = {};
let _renders = 0;
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
setItem: (k, v) => { _store[k] = String(v); },
removeItem: k => { delete _store[k]; },
};
globalThis.esc = s => String(s);
globalThis.t = k => k;
globalThis.pysimFsFilterRender = () => { _renders++; };
globalThis.pysimFsRenderTree = () => { _treeRenders++; };
let code = '';
for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService',
'pysimFsFilterPathsInvalidate', 'pysimFsExpandAll', 'pysimFsCollapseAll',
'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);
code += "var FS_FILTER_ALL = 'all';\n";
eval(code);
test('servicesFeatureByNumber inverts the cross-reference per file table', () => {
const map = servicesFeatureByNumber();
assert.strictEqual(map.ust[2], 'Fixed Dialling Numbers (FDN)');
assert.strictEqual(map.sst[2], 'Local Phone Book (ADN)');
assert.strictEqual(map.est[1], 'Fixed Dialling Numbers (FDN)');
assert.strictEqual(map.ist[1], 'P-CSCF address');
// the same number names a different feature in another table
assert.strictEqual(map.ust[1], 'Local Phone Book (ADN)');
assert.strictEqual(map.sst[1], 'CHV1 disable function');
// a capability-only number maps to a feature too
assert.strictEqual(map.ust[99], 'URI support by UICC');
// the map is built once
assert.strictEqual(servicesFeatureByNumber(), map);
});
test('pysimFsFilterBtnHtml links a file-backed service row only', () => {
const out = pysimFsFilterBtnHtml('ust', 2);
assert.match(out, /^<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"/);
assert.ok(out.indexOf("pysimFsSetFilterToService('ust', 2)") > 0, out);
assert.ok(out.indexOf('To EF filter') > 0, out);
// OCI is file-backed
assert.notStrictEqual(pysimFsFilterBtnHtml('ust', 8), '');
// a capability-only service, a missing table and an unmodelled number
assert.strictEqual(pysimFsFilterBtnHtml('ust', 99), '');
assert.strictEqual(pysimFsFilterBtnHtml(undefined, 1), '');
assert.strictEqual(pysimFsFilterBtnHtml('sst', 99), '');
});
test('pysimFsSetFilterToService narrows the tree to that service', () => {
delete _store.simple_fs_filter;
_renders = 0;
_treeRenders = 0;
pysimFsFilter = new Set(['all']);
pysimFsSetFilterToService('ust', 2);
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_store.simple_fs_filter, JSON.stringify(['svc:Fixed Dialling Numbers (FDN)']));
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 2); // render + the auto-expand's render
// a capability-only service changes nothing
pysimFsSetFilterToService('ust', 99);
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 2);
});
test('the four service-table fields carry their table, EF.ACC carries none', () => {
assert.match(html, /const EF_UST_FIELDS = \[\{[^\n]*table: 'ust'/);
assert.match(html, /const EF_IST_FIELDS = \[\{[^\n]*table: 'ist'/);
assert.match(html, /const EF_EST_FIELDS = \[\{[^\n]*table: 'est'/);
assert.match(html, /const EF_SST_FIELDS = \[\{[\s\S]{0,200}?table: 'sst'/);
const acc = /const EF_ACC_FIELDS = [^\n]*/.exec(html);
assert.ok(acc, 'EF_ACC_FIELDS not found');
assert.ok(acc[0].indexOf('table') < 0, acc[0]);
});
test('both service-grid renderers append the cross-link after their control', () => {
const fn = extractFunc(html, 'efDecFieldHtml');
assert.strictEqual((fn.match(/pysimFsFilterBtnHtml\(f\.table, n\)/g) || []).length, 2, fn);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.39.0" version = "3.40.1"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
license = { text = "GPL-2.0-or-later" } license = { text = "GPL-2.0-or-later" }
requires-python = ">=3.9" requires-python = ">=3.9"
+1 -1
View File
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.39.0' VERSION = '3.40.1'
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