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', () => {