diff --git a/frontend/index.html b/frontend/index.html index cbfd4f5..08d43c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4417,6 +4417,11 @@ function uiccStdFilesLoad() { Object.keys(_chains).forEach(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; }); } @@ -16144,6 +16149,89 @@ function fsFilterIndex() { 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 @@ -16156,6 +16244,7 @@ function pysimFsFilterRestore() { } 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(); @@ -16236,6 +16325,7 @@ function pysimFsFilterSetEntry(entry, on) { if (!next.size) next.add(FS_FILTER_ALL); } pysimFsFilter = next; + pysimFsFilterPathsInvalidate(); localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter))); pysimFsFilterRender(); pysimFsRenderTree(); @@ -16278,6 +16368,7 @@ 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(); @@ -16298,14 +16389,24 @@ function pysimFsFilterMatch(node) { 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). +// 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; - if (!node || !node.isDir) return false; - if (!node.children) return true; - return node.children.some(c => pysimFsFilterShow(c)); + 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() { diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js index 814a198..b2c0928 100644 --- a/frontend/tests/fs_filter.test.js +++ b/frontend/tests/fs_filter.test.js @@ -48,6 +48,15 @@ function extractStrConst(src, name) { // 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; @@ -98,9 +107,11 @@ globalThis.document = { let code = ''; for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex', + 'fsFilterPathIndexBuild', 'fsFilterPathIndex', 'fsFilterCustomPaths', + 'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths', 'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel', 'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle', - 'pysimFsFilterSetEntry', 'pysimFsFilterRestore']) { + 'pysimFsFilterSetEntry', 'pysimFsFilterRestore', 'pysimFsNodePath']) { code += extractFunc(html, f) + '\n'; } for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c); @@ -254,32 +265,138 @@ test('"custom files" matches only the injected custom nodes', () => { }); }); -test('a directory stays while it has a matching descendant', () => { - const df = children => ({ name: 'DF.GSM', fid: '7F20', isDir: true, children }); +// 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'], () => { - // 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); + // 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); }); - // a DF that is itself a per-service file (the GPRS feature names DF.GSM) + 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(['cb'], () => { + assert.strictEqual(pysimFsFilterShow(t.gsm), true); // EF.CBMI/CBMID/CBMIR + 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({ name: 'DF.GSM', isDir: true }), true); + assert.strictEqual(pysimFsFilterMatch(t.gsm), true); + assert.strictEqual(pysimFsFilterShow(t.gsm), true); + assert.strictEqual(pysimFsFilterShow(t.telecom), false); }); - // everything shows with the default - withFilter(['all'], () => { - assert.strictEqual(pysimFsFilterShow(df([{ name: 'EF.ECC', isDir: false }])), true); +}); + +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(['cb'], () => { + const paths = pysimFsFilterMatchingPaths(); + assert.ok(paths instanceof Set, 'expected a Set'); + assert.deepStrictEqual(Array.from(paths).sort(), [ + 'ADF.USIM-NON-IMSI/6F45', 'ADF.USIM-NON-IMSI/6F48', 'ADF.USIM-NON-IMSI/6F50', + 'ADF.USIM/6F45', 'ADF.USIM/6F48', 'ADF.USIM/6F50', + 'MF/7F20/6F45', 'MF/7F20/6F48', 'MF/7F20/6F50', + ]); + // 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', () => { @@ -413,6 +530,29 @@ test('the render consults the filter when walking children', () => { 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); diff --git a/frontend/tests/fs_service_link.test.js b/frontend/tests/fs_service_link.test.js index 49005c8..b1e51ba 100644 --- a/frontend/tests/fs_service_link.test.js +++ b/frontend/tests/fs_service_link.test.js @@ -45,6 +45,9 @@ function extractConst(src, name) { // 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; @@ -60,7 +63,8 @@ globalThis.pysimFsFilterRender = () => { _renders++; }; globalThis.pysimFsRenderTree = () => { _treeRenders++; }; let code = ''; -for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService']) { +for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService', + 'pysimFsFilterPathsInvalidate']) { code += extractFunc(html, f) + '\n'; } for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);