From 77fbed80cecca0c6c52f4234f78b5f5487a078d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 26 Oct 2026 14:06:06 +0300 Subject: [PATCH] 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:'], 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.) --- frontend/index.html | 60 ++++++++++-- frontend/tests/fs_dec_edit.test.js | 20 +++- frontend/tests/fs_service_link.test.js | 127 +++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/fs_service_link.test.js 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, /^