diff --git a/frontend/index.html b/frontend/index.html
index 619ddfa..294b001 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -16240,6 +16240,48 @@ function pysimFsFilterSetEntry(entry, on) {
pysimFsRenderTree();
}
+// The cross-reference inverted to service number -> feature name, per file
+// table. Both maps are declared later in this file, so they are read on the
+// first call, never while the filter block itself is evaluated.
+let _servicesFeatureByNumber = null;
+
+function servicesFeatureByNumber() {
+ if (!_servicesFeatureByNumber) {
+ const out = { sst: {}, ust: {}, est: {}, ist: {} };
+ for (const f of SERVICES_CROSS_REF) {
+ for (const table of Object.keys(out)) {
+ const n = f[table];
+ if (n === null || n === undefined) continue;
+ out[table][n] = f.f;
+ }
+ }
+ _servicesFeatureByNumber = out;
+ }
+ return _servicesFeatureByNumber;
+}
+
+// The service row's cross-link to the file tree: a capability-only service
+// names no file, so it carries no link.
+function pysimFsFilterBtnHtml(table, n) {
+ if (!table) return '';
+ const feature = (servicesFeatureByNumber()[table] || {})[n];
+ if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return '';
+ const label = t('To EF filter');
+ return '';
+}
+
+// Select exactly that service's files in the file tree, persisting the choice
+// like any other filter entry.
+function pysimFsSetFilterToService(table, n) {
+ const feature = (servicesFeatureByNumber()[table] || {})[n];
+ if (!feature || !(SERVICES_FS_MAP[feature] || []).length) return;
+ pysimFsFilter = new Set(['svc:' + feature]);
+ localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
+ pysimFsFilterRender();
+ pysimFsRenderTree();
+}
+
// 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.
@@ -26357,20 +26399,22 @@ const EF_PNN_ITEMS = {
};
// Service-table and scalar editors (wave 2). The bits kind renders the whole
-// table as a filterable checkbox grid; its value is the decoder's list.
+// table as a filterable checkbox grid; its value is the decoder's list. The
+// service-table fields carry `table` (sst/ust/est/ist), which adds the row's
+// "To EF filter" cross-link to the file tree.
const EF_ACC_CLASSES = {
0: 'Class 0', 1: 'Class 1', 2: 'Class 2', 3: 'Class 3', 4: 'Class 4', 5: 'Class 5',
6: 'Class 6', 7: 'Class 7', 8: 'Class 8', 9: 'Class 9', 11: 'Class 11',
12: 'Class 12', 13: 'Class 13', 14: 'Class 14', 15: 'Class 15',
};
-const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES }];
-const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES }];
-const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES }];
+const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES, table: 'ust' }];
+const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES, table: 'ist' }];
+const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES, table: 'est' }];
// EF.SST is the SIM table with two bits per service: the tri-state grid
// (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid.
const EF_SST_FIELDS = [{
- key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES,
+ key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES, table: 'sst',
hint: 'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), '
+ 'off = allocated but not activated (10), on = allocated and activated (11).',
}];
@@ -26756,7 +26800,8 @@ function efDecFieldHtml(f, value) {
'' +
'' + n + '' +
- '' + esc(t(name)) + '';
+ '' + esc(t(name)) + '' +
+ pysimFsFilterBtnHtml(f.table, n) + '';
}).join('') + '';
} else if (f.kind === 'bits2') {
// the tri-state grid (EF.SST): one select per service, the decoder's
@@ -26781,7 +26826,7 @@ function efDecFieldHtml(f, value) {
'';
+ '' + pysimFsFilterBtnHtml(f.table, n) + '';
}).join('') + '';
} else if (f.kind === 'select') {
const cur = String(value === undefined || value === null ? '' : value);
@@ -27734,6 +27779,7 @@ const LANG_RU = {
'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
'Services': 'Сервисы',
'Filter services': 'Фильтр сервисов',
+ 'To EF filter': 'В фильтр EF',
'Off': 'Выключен',
'On': 'Включён',
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
diff --git a/frontend/tests/fs_dec_edit.test.js b/frontend/tests/fs_dec_edit.test.js
index 9ddf43a..63e0b6c 100644
--- a/frontend/tests/fs_dec_edit.test.js
+++ b/frontend/tests/fs_dec_edit.test.js
@@ -48,6 +48,7 @@ const FNS = [
'efEncPlmnList', 'efListRows', 'efPlmnRowOrig', 'efEncAdn', 'efEncAdnExt', 'efPnnNameBytes', 'efPnnHexBytes', 'efPnnOrigTlv', 'efEncPnn',
'efFindEncoder', 'efHasEncoder', 'efEncError',
'efDecModelValues', 'efDecModelFor', 'efDecEditModel', 'efActTechList', 'efDecFieldHtml',
+ 'servicesFeatureByNumber', 'pysimFsFilterBtnHtml',
'efDecRowHtml', 'efDecGridHtml', 'efDecItemRowHtml', 'efDecItemsHtml',
'efDecRecordsHtml', 'efDecEditHtml',
'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems',
@@ -60,10 +61,11 @@ const FNS = [
];
let code = 'var pysimFsSelected = null;\nvar pysimFsTreeRoot = null;\nvar pysimFsDecModel = null;\n'
- + 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\n';
+ + 'var pysimFsDecEditMode = false;\nvar pysimFsEditMode = false;\nvar _servicesFeatureByNumber = null;\n';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'EF_ACT_TECHS', 'EF_SPN_FIELDS',
- 'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS']) {
+ 'EF_ADN_FIELDS', 'EF_PLMN_LIST', 'EF_PNN_ITEMS', 'EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_ACC_CLASSES', 'EF_UST_FIELDS', 'EF_IST_FIELDS', 'EF_EST_FIELDS', 'EF_SST_FIELDS', 'EF_ACC_FIELDS', 'EF_PHASE_FIELDS', 'EF_HPPLMN_FIELDS', 'EF_AD_OP_MODES', 'EF_AD_FIELDS', 'EF_PLMN_ONLY_LIST', 'EF_ICCID_FIELDS', 'EF_IMSI_FIELDS', 'EF_CBMI_LIST', 'EF_CBMIR_LIST', 'EF_READONLY_NAMES', 'EF_SMSP_PARAMS', 'EF_SMSP_CUSTOM', 'EF_ENCODERS', 'EF_ADF_RIDS',
+ 'SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) {
code += extractConst(html, c);
}
eval(code);
@@ -739,6 +741,20 @@ test('the bits field renders a filtered checkbox grid and collects {n,name}', ()
] });
});
+test('the service grids link file-backed rows to the file tree filter', () => {
+ const ust = efDecEditHtml(efDecEditModel('EF.UST', '6f38', { kind: 'transparent', data: UST_ONE }), UST_ONE);
+ // service 2 = FDN (files), service 99 = URI support by UICC (capability)
+ assert.match(ust, /pysimFsSetFilterToService\('ust', 2\)/);
+ assert.ok(!/pysimFsSetFilterToService\('ust', 99\)/.test(ust));
+ const sst = efDecEditHtml(efDecEditModel('EF.SST', '6f38', { kind: 'transparent', data: '4C' }, '7F20'), '4C');
+ // service 4 = SMS (files), service 1 = CHV1 disable function (capability)
+ assert.match(sst, /pysimFsSetFilterToService\('sst', 4\)/);
+ assert.ok(!/pysimFsSetFilterToService\('sst', 1\)/.test(sst));
+ // EF.ACC's bits grid is not a service table - no cross-link at all
+ const acc = efDecEditHtml(efDecEditModel('EF.ACC', '6f78', { kind: 'transparent', data: ACC_ONE }), ACC_ONE);
+ assert.ok(!/pysimFsSetFilterToService/.test(acc));
+});
+
test('the bits filter hides the non-matching rows', () => {
const rows = [
{ textContent: '1 Local Phone Book', style: {} },
diff --git a/frontend/tests/fs_service_link.test.js b/frontend/tests/fs_service_link.test.js
new file mode 100644
index 0000000..49005c8
--- /dev/null
+++ b/frontend/tests/fs_service_link.test.js
@@ -0,0 +1,127 @@
+// The service-table rows' cross-link to the file-tree filter: a table row
+// that is backed by files gets a "To EF filter" button that narrows the tree
+// to exactly that service's files.
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ 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;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+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';
+}
+
+// module state the extracted functions close over
+let pysimFsFilter = new Set(['all']);
+let _servicesFeatureByNumber = null;
+const _store = {};
+let _renders = 0;
+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.pysimFsFilterRender = () => { _renders++; };
+globalThis.pysimFsRenderTree = () => { _treeRenders++; };
+
+let code = '';
+for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService']) {
+ code += extractFunc(html, f) + '\n';
+}
+for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);
+eval(code);
+
+test('servicesFeatureByNumber inverts the cross-reference per file table', () => {
+ const map = servicesFeatureByNumber();
+ assert.strictEqual(map.ust[2], 'Fixed Dialling Numbers (FDN)');
+ assert.strictEqual(map.sst[2], 'Local Phone Book (ADN)');
+ assert.strictEqual(map.est[1], 'Fixed Dialling Numbers (FDN)');
+ assert.strictEqual(map.ist[1], 'P-CSCF address');
+ // the same number names a different feature in another table
+ assert.strictEqual(map.ust[1], 'Local Phone Book (ADN)');
+ assert.strictEqual(map.sst[1], 'CHV1 disable function');
+ // a capability-only number maps to a feature too
+ assert.strictEqual(map.ust[99], 'URI support by UICC');
+ // the map is built once
+ assert.strictEqual(servicesFeatureByNumber(), map);
+});
+
+test('pysimFsFilterBtnHtml links a file-backed service row only', () => {
+ const out = pysimFsFilterBtnHtml('ust', 2);
+ assert.match(out, /^