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:
1 parent
3878156646
commit
01e50d841f
3 files changed
+422
-33
No files matched your search
+133
-23
@@ -16077,49 +16077,152 @@ function pysimFsSetSort(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The tree filter: a multiselect of file criteria, applied when the tree
|
// 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 —
|
// renders. The criteria name standard files by their symbolic name - the
|
||||||
// populate FS_FILTER_OPTIONS later; the mechanism (state, persistence,
|
// same value in the tree nodes and in frontend/uicc_files.json - so a file
|
||||||
// per-node match, render wiring) is scaffolded here.
|
// can belong to several criteria (EF.SPN is a provider name and the file of
|
||||||
const FS_FILTER_OPTIONS = [
|
// the "Service Provider Name" service). `all` is the exclusive default.
|
||||||
// { id: 'decoded-editor', label: 'Has a decoded editor' },
|
// The categories are shown under three headings (`group`): the subscriber
|
||||||
// { id: 'present', label: 'Present on card' },
|
// data, the network-selection data, and services (the SMS files plus one
|
||||||
// ... to be decided
|
// 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();
|
// 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 {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
|
const raw = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
|
||||||
if (Array.isArray(saved)) pysimFsFilter = new Set(saved);
|
if (Array.isArray(raw)) saved = raw.filter(v => typeof v === 'string' && v);
|
||||||
} catch (e) { /* ignore */ }
|
} 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() {
|
function pysimFsFilterRender() {
|
||||||
const el = document.getElementById('pysim-fs-filter');
|
const el = document.getElementById('pysim-fs-filter');
|
||||||
if (!el) return;
|
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>';
|
el.innerHTML = '<option disabled>' + esc(t('(no filters yet)')) + '</option>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = FS_FILTER_OPTIONS.map(o =>
|
const opt = o => '<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>';
|
||||||
'<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>').join('');
|
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); });
|
el.querySelectorAll('option').forEach(o => { o.selected = pysimFsFilter.has(o.value); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function pysimFsSetFilter() {
|
function pysimFsSetFilter() {
|
||||||
const el = document.getElementById('pysim-fs-filter');
|
const el = document.getElementById('pysim-fs-filter');
|
||||||
if (!el) return;
|
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)));
|
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
|
||||||
|
pysimFsFilterRender();
|
||||||
pysimFsRenderTree();
|
pysimFsRenderTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a node passes the active filter. With no filter active the tree
|
// Whether a file node passes the active filter. An empty selection and
|
||||||
// shows everything. When FS_FILTER_OPTIONS is populated, this maps each
|
// `all` both show everything; otherwise the node's name must be listed under
|
||||||
// active id to a per-node test (kind, name/fid, presence, decoded-editor
|
// one of the selected criteria.
|
||||||
// availability, ...). Note for later: a directory should stay visible when
|
|
||||||
// it contains any matching descendant, not only when it matches itself.
|
|
||||||
function pysimFsFilterMatch(node) {
|
function pysimFsFilterMatch(node) {
|
||||||
if (!pysimFsFilter.size) return true;
|
if (!pysimFsFilter.size || pysimFsFilter.has(FS_FILTER_ALL)) return true;
|
||||||
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() {
|
async function pysimFsRefresh() {
|
||||||
@@ -16320,7 +16423,7 @@ function pysimFsRenderNode(node, depth) {
|
|||||||
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||||
} else {
|
} else {
|
||||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||||
if (!pysimFsFilterMatch(child)) continue;
|
if (!pysimFsFilterShow(child)) continue;
|
||||||
html += pysimFsRenderNode(child, depth + 1);
|
html += pysimFsRenderNode(child, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27765,6 +27868,13 @@ const LANG_RU = {
|
|||||||
'Name': 'Имя',
|
'Name': 'Имя',
|
||||||
'Filter:': 'Фильтр:',
|
'Filter:': 'Фильтр:',
|
||||||
'(no filters yet)': '(фильтров пока нет)',
|
'(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': 'Проверить все файлы',
|
'Probe all files': 'Проверить все файлы',
|
||||||
'Feature': 'Функция',
|
'Feature': 'Функция',
|
||||||
'SST': 'SST',
|
'SST': 'SST',
|
||||||
|
|||||||
@@ -21,21 +21,297 @@ function extractFunc(src, name) {
|
|||||||
return src.slice(m.index, i + 1);
|
return src.slice(m.index, i + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// module state the extracted match function closes over
|
function extractConst(src, name) {
|
||||||
let pysimFsFilter = new Set();
|
const m = new RegExp('const ' + name + ' = ').exec(src);
|
||||||
eval(extractFunc(html, 'pysimFsFilterMatch'));
|
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', () => {
|
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, /id="pysim-fs-filter"[^>]*multiple[^>]*onchange="pysimFsSetFilter\(\)"/);
|
||||||
assert.match(html, /data-l10n="Filter:"/);
|
assert.match(html, /data-l10n="Filter:"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the filter options list is scaffolded and empty for now', () => {
|
test('the six category groups name their files', () => {
|
||||||
assert.match(html, /const FS_FILTER_OPTIONS = \[/);
|
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', () => {
|
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', () => {
|
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);
|
assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an empty filter matches everything (scaffold no-op until criteria land)', () => {
|
test('the filter labels have Russian entries', () => {
|
||||||
pysimFsFilter = new Set();
|
const dict = html.match(/const LANG_RU = \{([\s\S]*?)\n\};/)[1];
|
||||||
assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', fid: '6F3A' }), true);
|
for (const key of ['All', 'Custom files', 'Identifiers', 'PLMN', 'Services', 'PLMN selection',
|
||||||
assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), true);
|
'Provider names', 'WLAN / I-WLAN', 'SMS-related', 'CB-related']) {
|
||||||
|
assert.ok(dict.includes("'" + key + "':"), 'missing Russian entry: ' + key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -26,6 +26,7 @@ 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, '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);
|
||||||
|
|||||||
Reference in new issue
Block a user