service editors: "To EF filter" per-service cross-link

In the SST/UST/IST/EST decoded editors, each service row backed by files
gains a small "To EF filter" action that sets the file-tree filter to
exactly that service's option:

- The four service-table fields carry a `table` key (sst/ust/est/ist);
  servicesFeatureByNumber() inverts SERVICES_CROSS_REF into per-table
  number->feature maps (lazy, cached).
- pysimFsFilterBtnHtml(table, n) renders the button only for file-backed
  features; pysimFsSetFilterToService(table, n) sets pysimFsFilter to
  ['svc:<feature>'], persists, and re-renders the dropdown + tree.
- i18n: "To EF filter" (EN/RU).  No CSS rebuild needed.

Frontend 1152 tests green (+6).  (Version/sw bump still deferred.)
This commit is contained in:
catarrh committed 2026-10-26 14:06:06 +03:00
1 parent d1067e3e72
commit 77fbed80ce
3 files changed
+198 -9

No files matched your search

+18 -2
View File
@@ -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: {} },
+127
View File
@@ -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, /^<button type="button" class="ml-1 px-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"/);
assert.ok(out.indexOf("pysimFsSetFilterToService('ust', 2)") > 0, out);
assert.ok(out.indexOf('To EF filter') > 0, out);
// OCI is file-backed
assert.notStrictEqual(pysimFsFilterBtnHtml('ust', 8), '');
// a capability-only service, a missing table and an unmodelled number
assert.strictEqual(pysimFsFilterBtnHtml('ust', 99), '');
assert.strictEqual(pysimFsFilterBtnHtml(undefined, 1), '');
assert.strictEqual(pysimFsFilterBtnHtml('sst', 99), '');
});
test('pysimFsSetFilterToService narrows the tree to that service', () => {
delete _store.simple_fs_filter;
_renders = 0;
_treeRenders = 0;
pysimFsFilter = new Set(['all']);
pysimFsSetFilterToService('ust', 2);
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);
// 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);
});
test('the four service-table fields carry their table, EF.ACC carries none', () => {
assert.match(html, /const EF_UST_FIELDS = \[\{[^\n]*table: 'ust'/);
assert.match(html, /const EF_IST_FIELDS = \[\{[^\n]*table: 'ist'/);
assert.match(html, /const EF_EST_FIELDS = \[\{[^\n]*table: 'est'/);
assert.match(html, /const EF_SST_FIELDS = \[\{[\s\S]{0,200}?table: 'sst'/);
const acc = /const EF_ACC_FIELDS = [^\n]*/.exec(html);
assert.ok(acc, 'EF_ACC_FIELDS not found');
assert.ok(acc[0].indexOf('table') < 0, acc[0]);
});
test('both service-grid renderers append the cross-link after their control', () => {
const fn = extractFunc(html, 'efDecFieldHtml');
assert.strictEqual((fn.match(/pysimFsFilterBtnHtml\(f\.table, n\)/g) || []).length, 2, fn);
});