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).
This commit is contained in:
catarrh committed 2026-10-26 13:34:54 +03:00
1 parent 3878156646
commit 01e50d841f
3 files changed
+423 -34

No files matched your search

+134 -24
View File
@@ -16077,49 +16077,152 @@ function pysimFsSetSort(mode) {
}
// The tree filter: a multiselect of file criteria, applied when the tree
// renders. The option list is large (dozens of items) and not decided yet —
// populate FS_FILTER_OPTIONS later; the mechanism (state, persistence,
// per-node match, render wiring) is scaffolded here.
const FS_FILTER_OPTIONS = [
// { id: 'decoded-editor', label: 'Has a decoded editor' },
// { id: 'present', label: 'Present on card' },
// ... to be decided
// 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 (the SMS files plus 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'] },
// EF.PSISMSC is a standard name pySim does not model, so it has no tree
// node to match - it stays listed for completeness.
{ id: 'sms', label: 'SMS-related', group: 'Services', files: [
'EF.SMS', 'EF.SMSS', 'EF.SMSP', 'EF.SMSR', 'EF.PSISMSC'] },
{ id: 'cb', label: 'CB-related', group: 'Services', files: [
'EF.CBMI', 'EF.CBMID', 'EF.CBMIR'] },
];
const FS_FILTER_GROUP_ORDER = ['Identifiers', 'PLMN', 'Services'];
let pysimFsFilter = new Set();
try {
const saved = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
if (Array.isArray(saved)) pysimFsFilter = new Set(saved);
} catch (e) { /* ignore */ }
// 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;
}
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]);
}
pysimFsFilterRestore();
function pysimFsFilterRender() {
const el = document.getElementById('pysim-fs-filter');
if (!el) return;
if (!FS_FILTER_OPTIONS.length) {
const services = fsFilterServiceOptions();
if (!FS_FILTER_GROUPS.length && !services.length) {
el.innerHTML = '<option disabled>' + esc(t('(no filters yet)')) + '</option>';
return;
}
el.innerHTML = FS_FILTER_OPTIONS.map(o =>
'<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>').join('');
const opt = o => '<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>';
let out = '<option value="' + esc(FS_FILTER_ALL) + '">' + esc(t('All')) + '</option>' +
'<option value="' + esc(FS_FILTER_CUSTOM) + '">' + esc(t('Custom files')) + '</option>';
for (const group of FS_FILTER_GROUP_ORDER) {
const items = FS_FILTER_GROUPS.filter(g => g.group === group).map(opt);
if (group === 'Services') items.push(...services.map(opt));
if (items.length) out += '<optgroup label="' + esc(t(group)) + '">' + items.join('') + '</optgroup>';
}
el.innerHTML = out;
el.querySelectorAll('option').forEach(o => { o.selected = pysimFsFilter.has(o.value); });
}
function pysimFsSetFilter() {
const el = document.getElementById('pysim-fs-filter');
if (!el) return;
pysimFsFilter = new Set(Array.from(el.selectedOptions).map(o => o.value));
const prev = pysimFsFilter;
const next = new Set(Array.from(el.selectedOptions).map(o => o.value).filter(v => v));
// `all` is exclusive: choosing it clears the category selections, choosing
// a category clears it. A native multiselect's onchange reports only the
// new selection, so the previous one tells the two gestures apart: `all`
// just appearing means it was clicked (it clears the rest); it staying
// next to a new category means the category was clicked (it drops `all`).
if (next.has(FS_FILTER_ALL)) {
if (!prev.has(FS_FILTER_ALL)) {
next.clear();
next.add(FS_FILTER_ALL);
} else {
next.delete(FS_FILTER_ALL);
}
}
if (!next.size) next.add(FS_FILTER_ALL);
pysimFsFilter = next;
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
pysimFsFilterRender();
pysimFsRenderTree();
}
// Whether a node passes the active filter. With no filter active the tree
// shows everything. When FS_FILTER_OPTIONS is populated, this maps each
// active id to a per-node test (kind, name/fid, presence, decoded-editor
// availability, ...). Note for later: a directory should stay visible when
// it contains any matching descendant, not only when it matches itself.
// 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) return true;
return true;
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. A matching file shows; a
// directory shows when it matches itself, has a matching loaded descendant,
// or was never expanded (its children are unknown, so it cannot be pruned).
function pysimFsFilterShow(node) {
if (pysimFsFilterMatch(node)) return true;
if (!node || !node.isDir) return false;
if (!node.children) return true;
return node.children.some(c => pysimFsFilterShow(c));
}
async function pysimFsRefresh() {
@@ -16320,7 +16423,7 @@ function pysimFsRenderNode(node, depth) {
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + '&nbsp;&nbsp;&nbsp;&nbsp;' + esc(t('(empty)')) + '</div>';
} else {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
if (!pysimFsFilterMatch(child)) continue;
if (!pysimFsFilterShow(child)) continue;
html += pysimFsRenderNode(child, depth + 1);
}
}
@@ -27765,6 +27868,13 @@ const LANG_RU = {
'Name': 'Имя',
'Filter:': 'Фильтр:',
'(no filters yet)': '(фильтров пока нет)',
'Identifiers': 'Идентификаторы',
'PLMN selection': 'Выбор PLMN',
'Provider names': 'Имена операторов',
'WLAN / I-WLAN': 'WLAN / I-WLAN',
'SMS-related': 'Связанное с SMS',
'CB-related': 'Связанное с CB',
'Custom files': 'Пользовательские файлы',
'Probe all files': 'Проверить все файлы',
'Feature': 'Функция',
'SST': 'SST',
+288 -10
View File
@@ -21,21 +21,297 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1);
}
// module state the extracted match function closes over
let pysimFsFilter = new Set();
eval(extractFunc(html, 'pysimFsFilterMatch'));
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;
const _store = {};
let _treeRenders = 0;
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++; };
let _select = null;
globalThis.document = { getElementById: id => (id === 'pysim-fs-filter' ? _select : null) };
let code = '';
for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex',
'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterRender', 'pysimFsSetFilter',
'pysimFsFilterRestore']) {
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; }
}
function fakeSelect(selected) {
const options = (selected || []).map(v => ({ value: v, selected: true }));
return {
_html: '',
get innerHTML() { return this._html; },
set innerHTML(v) {
this._html = v;
options.length = 0;
for (const m of v.matchAll(/<option value="([^"]*)"[^>]*>/g)) options.push({ value: m[1], selected: false });
},
querySelectorAll() { return options; },
get selectedOptions() { return options.filter(o => o.selected); },
options,
};
}
const fileBackedFeatures = () => Object.keys(SERVICES_FS_MAP).filter(n => SERVICES_FS_MAP[n].length);
test('the file tree has a multiselect filter next to the sort pills', () => {
assert.match(html, /id="pysim-fs-filter"[^>]*multiple[^>]*onchange="pysimFsSetFilter\(\)"/);
assert.match(html, /data-l10n="Filter:"/);
});
test('the filter options list is scaffolded and empty for now', () => {
assert.match(html, /const FS_FILTER_OPTIONS = \[/);
test('the six category groups name their files', () => {
assert.deepStrictEqual(FS_FILTER_GROUPS.map(g => g.id),
['identifiers', 'plmn-selection', 'provider-names', 'wlan', 'sms', 'cb']);
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.deepStrictEqual([...files('sms')], ['EF.SMS', 'EF.SMSS', 'EF.SMSP', 'EF.SMSR', 'EF.PSISMSC']);
assert.deepStrictEqual([...files('cb')], ['EF.CBMI', 'EF.CBMID', 'EF.CBMIR']);
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)'));
assert.ok(ids('EF.SMS').includes('sms'));
assert.ok(ids('EF.SMS').includes('svc:Short Message Storage (SMS)'));
assert.ok(ids('EF.CBMI').includes('cb'));
// EF.PSISMSC is only listed by the SMS category - pySim models no such
// file, so it never matches a tree node (no service lists it either)
assert.deepStrictEqual([...idx.get('EF.PSISMSC')], ['sms']);
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', 'sms'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.IMSI' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SMSR' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.SPN' }), false);
});
withFilter(['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);
});
});
test('a directory stays while it has a matching descendant', () => {
const df = children => ({ name: 'DF.GSM', fid: '7F20', isDir: true, children });
withFilter(['provider-names'], () => {
// a matching file shows, a non-matching one hides
assert.strictEqual(pysimFsFilterShow({ name: 'EF.SPN', isDir: false }), true);
assert.strictEqual(pysimFsFilterShow({ name: 'EF.ECC', isDir: false }), false);
// a DF with a matching loaded child shows, one without hides
assert.strictEqual(pysimFsFilterShow(df([{ name: 'EF.ECC', isDir: false }, { name: 'EF.SPN', isDir: false }])), true);
assert.strictEqual(pysimFsFilterShow(df([{ name: 'EF.ECC', isDir: false }])), false);
assert.strictEqual(pysimFsFilterShow(df([])), false);
// a collapsed DF whose children were never loaded is kept
assert.strictEqual(pysimFsFilterShow({ name: 'DF.GSM', isDir: true, children: null, expanded: false }), true);
// ... and a matching descendant nested one level deeper counts too
assert.strictEqual(pysimFsFilterShow(df([
{ name: 'DF.X', isDir: true, children: [{ name: 'EF.ECC', isDir: false }, { name: 'EF.PNN', isDir: false }] },
])), true);
assert.strictEqual(pysimFsFilterShow(df([{ name: 'DF.X', isDir: true, children: null }])), true);
});
// a DF that is itself a per-service file (the GPRS feature names DF.GSM)
withFilter(['svc:GPRS'], () => {
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', isDir: true }), true);
});
// everything shows with the default
withFilter(['all'], () => {
assert.strictEqual(pysimFsFilterShow(df([{ name: 'EF.ECC', isDir: false }])), true);
});
});
test('selecting "all" clears the categories', () => {
pysimFsFilter = new Set(['identifiers']);
_select = fakeSelect(['all', 'identifiers']);
_treeRenders = 0;
pysimFsSetFilter();
assert.deepStrictEqual([...pysimFsFilter], ['all']);
assert.strictEqual(_store.simple_fs_filter, '["all"]');
assert.strictEqual(_treeRenders, 1);
});
test('selecting a category drops "all"', () => {
pysimFsFilter = new Set(['all']);
_select = fakeSelect(['all', 'sms']);
pysimFsSetFilter();
assert.deepStrictEqual([...pysimFsFilter], ['sms']);
assert.strictEqual(_store.simple_fs_filter, '["sms"]');
});
test('several categories stay selected and an empty selection means all', () => {
pysimFsFilter = new Set(['all']);
_select = fakeSelect(['identifiers', 'sms']);
pysimFsSetFilter();
assert.deepStrictEqual([...pysimFsFilter], ['identifiers', 'sms']);
pysimFsFilter = new Set(['identifiers', 'sms']);
_select = fakeSelect([]);
pysimFsSetFilter();
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', 'sms']);
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 dropdown renders all, the groups and the services', () => {
pysimFsFilter = new Set(['all']);
const el = fakeSelect(['all']);
_select = el;
pysimFsFilterRender();
assert.match(el.innerHTML, /^<option value="all">All<\/option><option value="custom">Custom files<\/option>/);
assert.match(el.innerHTML, /<optgroup label="Identifiers"><option value="identifiers">Identifiers<\/option><option value="provider-names">Provider names<\/option><\/optgroup>/);
assert.match(el.innerHTML, /<optgroup label="PLMN"><option value="plmn-selection">PLMN selection<\/option><option value="wlan">WLAN \/ I-WLAN<\/option><\/optgroup>/);
assert.match(el.innerHTML, /<optgroup label="Services"><option value="sms">SMS-related<\/option><option value="cb">CB-related<\/option><option value="svc:Local Phone Book \(ADN\)">/);
const values = [...el.innerHTML.matchAll(/<option value="([^"]*)"/g)].map(m => m[1]);
assert.strictEqual(values.length, 2 + FS_FILTER_GROUPS.length + fsFilterServiceOptions().length);
assert.ok(values.includes('svc:Service Provider Name (SPN)'));
assert.ok(values.includes('custom'));
assert.ok(!values.includes('svc:Proactive SIM'));
assert.strictEqual(el.options.find(o => o.value === 'all').selected, true);
});
test('the dropdown marks the saved selection', () => {
pysimFsFilter = new Set(['svc:Short Message Storage (SMS)']);
const el = fakeSelect();
_select = el;
pysimFsFilterRender();
const selected = el.options.filter(o => o.selected).map(o => o.value);
assert.deepStrictEqual(selected, ['svc:Short Message Storage (SMS)']);
});
test('the render consults the filter when walking children', () => {
assert.match(html, /pysimFsFilterMatch\(child\)/);
assert.match(html, /if \(!pysimFsFilterShow\(child\)\) continue;/);
assert.ok(!/pysimFsFilterMatch\(child\)/.test(html));
});
test('the filter state is persisted under simple_fs_filter', () => {
@@ -49,8 +325,10 @@ test('the dropdown is re-rendered on language switch', () => {
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
});
test('an empty filter matches everything (scaffold no-op until criteria land)', () => {
pysimFsFilter = new Set();
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', fid: '6F3A' }), true);
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), true);
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', 'SMS-related', 'CB-related']) {
assert.ok(dict.includes("'" + key + "':"), 'missing Russian entry: ' + key);
}
});
+1
View File
@@ -26,6 +26,7 @@ code += 'var pysimFsFilter = new Set();\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsNodePath') + '\n';
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
code += extractFunc(html, 'pysimFsFilterShow') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);