diff --git a/frontend/index.html b/frontend/index.html index e11aef1..b7a4bec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1117,6 +1117,9 @@ Sort: FID Name + · + +
Filter: @@ -16339,6 +16342,7 @@ function pysimFsFilterSetEntry(entry, on) { localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter))); pysimFsFilterRender(); pysimFsRenderTree(); + pysimFsAutoExpandForFilter(); } // The cross-reference inverted to service number -> feature name, per file @@ -16382,6 +16386,7 @@ function pysimFsSetFilterToService(table, n) { 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 @@ -16684,6 +16689,42 @@ async function pysimFsToggleDir(path) { 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; function pysimFsInfoHtml(sel, name, opts) { @@ -28074,6 +28115,8 @@ const LANG_RU = { 'CB-related': 'Связанное с CB', 'Custom files': 'Пользовательские файлы', 'Selected: {n}': 'Выбрано: {n}', + 'Expand all': 'Развернуть все', + 'Collapse all': 'Свернуть все', 'Probe all files': 'Проверить все файлы', 'Feature': 'Функция', 'SST': 'SST', diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js index 2a447f5..b6bc490 100644 --- a/frontend/tests/fs_filter.test.js +++ b/frontend/tests/fs_filter.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); function extractFunc(src, name) { - const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const 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; @@ -59,6 +59,8 @@ 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), @@ -121,7 +123,8 @@ for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex' 'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths', 'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel', 'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle', - 'pysimFsFilterSetEntry', 'pysimFsFilterRestore', 'pysimFsNodePath']) { + '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); @@ -454,6 +457,46 @@ test('the button turns blue when a filter is active and back for all', () => { 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(); @@ -510,7 +553,7 @@ test('the checkbox rows keep "all" exclusive and persist the selection', () => { pysimFsFilterSetEntry('sms', true); assert.deepStrictEqual([...pysimFsFilter], ['sms']); assert.strictEqual(_store.simple_fs_filter, '["sms"]'); - assert.strictEqual(_treeRenders, 2); + assert.strictEqual(_treeRenders, 3); // render + the auto-expand's render assert.strictEqual(box('sms').checked, true); assert.strictEqual(box('all').checked, false); assert.strictEqual(dom.btn.textContent, 'SMS-related'); diff --git a/frontend/tests/fs_service_link.test.js b/frontend/tests/fs_service_link.test.js index b1e51ba..47393ad 100644 --- a/frontend/tests/fs_service_link.test.js +++ b/frontend/tests/fs_service_link.test.js @@ -51,6 +51,7 @@ 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), @@ -64,10 +65,12 @@ globalThis.pysimFsRenderTree = () => { _treeRenders++; }; let code = ''; for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService', - 'pysimFsFilterPathsInvalidate']) { + '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', () => { @@ -107,12 +110,12 @@ test('pysimFsSetFilterToService narrows the tree to that service', () => { 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, 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, 1); + assert.strictEqual(_treeRenders, 2); }); test('the four service-table fields carry their table, EF.ACC carries none', () => {