From 8e0a97f025919d7b51f232fd490b34a1a6cf4cb4 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: Sun, 25 Oct 2026 12:40:51 +0300 Subject: [PATCH] EF resolver: resolve by (DF, FID); split EF.ECC and EF.AD per DF The decoder/encoder lookup now takes an optional DF context and resolves DF-first, then name, then FID (the spec keys files by FID within their DF; the name is a display label). efDfFromPath derives the parent DF segment of a canonical path. EF.ECC is split into its two DF-specific decoders: DF.GSM (7F20) transparent 3n (existing) and ADF.USIM linear-fixed X+4 - the new efDecEccUsim reads the 3-byte Emergency Call Code, the Annex A alpha and the Emergency Service Category (decoded per TS 24.008 Table 10.5.135d). EF.AD is registered as DF-keyed entries (7F20 / ADF.USIM / ADF.ISIM), reusing the existing encoder. The df is threaded through the file-manager decoded-view/edit/save and the snapshot/compare decode. No ECC editor yet (read-only). Frontend 1081 + Python 1090 tests green. --- frontend/index.html | 144 ++++++++++++++++++++++------- frontend/tests/ef_decode.test.js | 91 +++++++++++++++++- frontend/tests/ef_encode.test.js | 30 ++++++ frontend/tests/fs_dec_edit.test.js | 15 ++- frontend/tests/fs_edit.test.js | 3 +- 5 files changed, 247 insertions(+), 36 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 6d43d55..ed23f02 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16260,13 +16260,15 @@ function pysimFsFindNode(name, root) { } // Decoder availability for the selected file, resolved exactly like the -// decoded view does (name from the tree node, FID fallback) — the content is -// not needed, so the Read decoded pill can be gated before any read. +// decoded view does (name from the tree node, FID fallback, and the parent DF +// when the node's path is known — EF.ECC/EF.AD differ between DFs) — the +// content is not needed, so the Read decoded pill can be gated before any read. function pysimFsHasDecoder() { if (!pysimFsSelected) return false; const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot); const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null); - return !!efFindDecoder(node && node.name, fid); + const df = node ? efDfFromPath(pysimFsNodePath(node)) : ''; + return !!efFindDecoder(node && node.name, fid, df); } // Encoder availability for the selected file (Edit decoded), resolved the @@ -16275,7 +16277,8 @@ function pysimFsHasEncoder() { if (!pysimFsSelected) return false; const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot); const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null); - return !!efFindEncoder(node && node.name, fid); + const df = node ? efDfFromPath(pysimFsNodePath(node)) : ''; + return !!efFindEncoder(node && node.name, fid, df); } // Find a node by its canonical path ("MF/7F20/6F46", "ADF.USIM/6F07"), so @@ -16431,7 +16434,8 @@ function pysimFsDecodedHtml(node, data) { const name = (node && node.name) || null; const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected || '') ? pysimFsSelected : null); const content = data.records ? { kind: 'record', records: data.records } : { kind: 'transparent', data: data.data }; - const dec = efDecodeFile(node && node.name, fid, content); + const df = node ? efDfFromPath(pysimFsNodePath(node)) : ''; + const dec = efDecodeFile(node && node.name, fid, content, df); let html = ''; if (!dec || !dec.fn) { html += '
' + esc(t('No decoder for this file')) + ' — ' + esc(t('showing raw data')) + '
'; @@ -16531,7 +16535,8 @@ async function pysimFsDecEdit() { const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : null); const content = data.records ? { kind: 'record', records: data.records } : { kind: 'transparent', data: data.data }; - const model = efDecEditModel(node && node.name, fid, content); + const model = efDecEditModel(node && node.name, fid, content, + node ? efDfFromPath(pysimFsNodePath(node)) : ''); if (!model) { statusEl.textContent = t('No decoded editor for this file'); pysimFsPillsEnabled(true); @@ -16656,7 +16661,8 @@ async function pysimFsDecSave(node, rawName, statusEl, out, arrTouched) { return; } const entry = model.entry || efFindEncoder(node && node.name, - (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : null)); + (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(rawName || '') ? rawName : null), + node ? efDfFromPath(pysimFsNodePath(node)) : ''); if (!entry) { statusEl.textContent = t('No decoded editor for this file'); return; } const root = out.querySelector('.pysim-dec-edit'); try { @@ -21161,7 +21167,7 @@ function profilerRenderSnapshotFiles(s) { '
' + profilerFciPreviewItems(f.fciHex, { arr: snapArr, path: f.path }) + '
'; html += ''; } - const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content) : null; + const dec = f.content ? efDecodeFile(f.name, efFidFromPath(f.path), f.content, efDfFromPath(f.path)) : null; html += '
' + esc(t('Contents')) + '
'; if (!f.content) { html += '
' + esc(t('Not captured')) + '
'; @@ -23009,7 +23015,8 @@ function profilerRenderReport(results, labels) { } else if (profilerRawDataCheck(c)) { const isContent = c.label === 'content' || /^content\.rec\d+$/.test(c.label); const dd = isContent && typeof efContentDiff === 'function' - ? efContentDiff(r.name, efFidFromPath(r.path), String(c.expected), String(c.actual)) : null; + ? efContentDiff(r.name, efFidFromPath(r.path), String(c.expected), String(c.actual), + efDfFromPath(r.path)) : null; const inputCls = 'flex-1 min-w-0 font-mono text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-gray-100 dark:bg-slate-800'; html += '
'; html += '
' + esc(c.label) + '
'; @@ -23106,6 +23113,7 @@ const EF_FIELD_LABELS = { update_status: 'Update status', access_tech: 'Access technology', p_tmsi_signature: 'P-TMSI signature', master: 'Master EF', iap: 'IAP (EF.IAP)', links: 'Links', files: 'Files', note: 'Note', ext1: 'Extension record', cmi: 'Comparison method pointer', + code: 'Emergency call code', category: 'Emergency service category', }; const EF_GSM7_EXT = { 0x0A: '\n', 0x14: '^', 0x28: '{', 0x29: '}', 0x2F: '\\', 0x3C: '[', 0x3D: '~', 0x3E: ']', 0x40: '|', 0x65: '\u20ac' }; @@ -23548,6 +23556,10 @@ function efDecCbmir(b) { return ranges.length ? { ranges: ranges } : { raw: efHex(b) }; } +// TS 51.011 10.3.27 EF.ECC (DF.GSM): transparent, 3-byte records of the +// emergency call code (2 bytes BCD) + the emergency category. The ADF.USIM +// file of the same name/FID is a different format - efDecEccUsim below - so +// the registry keys the two by their DF, not by name/FID. function efDecEcc(b) { if (efAllFf(b)) return { empty: true }; const recs = []; @@ -23561,6 +23573,35 @@ function efDecEcc(b) { return recs.length === 1 ? recs[0] : { numbers: recs }; } +// TS 31.102 4.2.21 EF.ECC (ADF.USIM): linear fixed, one record = X+4 bytes - +// the Emergency Call Code (3 bytes, 4 bits per digit with the digit's LSB +// first, unused nibbles 'F'), the optional Alpha Identifier (X bytes, SMS +// 7-bit or a UCS-2 option - efAnnexA) and the Emergency Service Category +// (byte X+4, coded per TS 24.008's Service Category value). +const EF_ECC_CATEGORIES = [ + [0x01, 'police'], [0x02, 'ambulance'], [0x04, 'fire brigade'], + [0x08, 'marine guard'], [0x10, 'mountain rescue'], + [0x20, 'manual eCall'], [0x40, 'automatic eCall'], +]; + +function efDecEccUsim(b) { + if (efAllFf(b)) return { empty: true }; + if (b.length < 4) return { raw: efHex(b) }; + // the code is mandatory: an all-FF code marks an unused record (pySim's + // EF_ECC decodes such a record as None too) + if (efAllFf(b.subarray(0, 3))) return { empty: true }; + const cat = b[b.length - 1]; + const kinds = EF_ECC_CATEGORIES.filter(c => cat & c[0]).map(c => c[1]); + const out = { + code: efSwapNibbles(efHex(b.subarray(0, 3))).replace(/[fF]+$/, ''), + category: kinds.length ? kinds.join(', ') + : '0x' + cat.toString(16).toUpperCase().padStart(2, '0'), + }; + const alpha = efAnnexA(efRstripFf(b.subarray(3, b.length - 1))); + if (alpha) out.alpha = alpha; + return out; +} + function efDecOpl(b) { if (efAllFf(b)) return { empty: true }; const recs = []; @@ -23966,8 +24007,17 @@ const EF_DECODERS = [ { name: 'EF.CBMI', fid: '6f45', fn: efDecCbmi }, { name: 'EF.CBMID', fid: '6f48', fn: efDecCbmi }, { name: 'EF.CBMIR', fid: '6f50', fn: efDecCbmir }, - { name: 'EF.ECC', fid: '6fb7', fn: efDecEcc }, - { name: 'EF.AD', fid: '6fad', fn: efDecAd }, + // EF.ECC and EF.AD collide in BOTH name and FID: DF.GSM's (TS 51.011) + // and ADF.USIM's (TS 31.102) files of that name are different formats, + // so their entries carry the parent DF - the resolver checks a known DF + // first (without one the first entry, the SIM form, still answers). + { name: 'EF.ECC', df: '7F20', fid: '6fb7', fn: efDecEcc }, + { name: 'EF.ECC', df: 'ADF.USIM', fid: '6fb7', fn: efDecEccUsim }, + // efDecAd reads the 3+X SIM, 4+X USIM and 3+X ISIM forms by length, so + // the three EF.AD entries share one decoder and differ only in their DF. + { name: 'EF.AD', df: '7F20', fid: '6fad', fn: efDecAd }, + { name: 'EF.AD', df: 'ADF.USIM', fid: '6fad', fn: efDecAd }, + { name: 'EF.AD', df: 'ADF.ISIM', fid: '6fad', fn: efDecAd }, { name: 'EF.ACL', fid: '6f57', fn: efDecAcl }, { name: 'EF.SMSP', fid: '6f42', fn: efDecSmsp }, { name: 'EF.SMS', fid: '6f3c', fn: efDecSms }, @@ -23992,12 +24042,22 @@ const EF_DECODERS = [ { name: 'EF.PBR', fid: '4f30', fn: efDecPbr }, ]; -function efFindDecoder(name, fid) { +// Resolution is DF-aware: a file's spec identity is (parent DF, FID) - the +// symbolic name is only a display label, and EF.ECC/EF.AD carry the same name +// AND FID in DF.GSM and ADF.USIM. With a known `df` the entry carrying it +// wins; otherwise (and for every other file) the historical name-then-FID +// lookup applies unchanged. +function efFindDecoder(name, fid, df) { let n = (name || '').toUpperCase(); if (n.includes('/')) n = n.split('/').pop(); - for (const d of EF_DECODERS) if (d.name.toUpperCase() === n) return d; const f = (fid || '').toLowerCase(); - if (f) for (const d of EF_DECODERS) if (d.fid === f) return d; + const d = (df || '').toUpperCase(); + if (d) { + for (const e of EF_DECODERS) if (e.df && e.df === d && e.name.toUpperCase() === n) return e; + if (f) for (const e of EF_DECODERS) if (e.df && e.df === d && e.fid === f) return e; + } + for (const e of EF_DECODERS) if (e.name.toUpperCase() === n) return e; + if (f) for (const e of EF_DECODERS) if (e.fid === f) return e; return null; } @@ -24006,6 +24066,15 @@ function efFidFromPath(path) { return /^[0-9a-fA-F]{4}$/.test(seg) ? seg.toUpperCase() : null; } +// The parent DF of a canonical path: "MF/7F20/6FAD" -> "7F20", +// "ADF.USIM/6FAD" -> "ADF.USIM", "MF/ADF.USIM/6FAD" -> "ADF.USIM"; a bare FID +// (no parent segment) has no DF context and yields ''. +function efDfFromPath(path) { + const segs = String(path || '').split('/').filter(Boolean); + if (segs.length < 2) return ''; + return segs[segs.length - 2].toUpperCase(); +} + // ---- content encoders (the decoded editor) ---- // The inverse of the decoders for the files the file manager can edit as // values: the edited field values are re-encoded here and written through @@ -25099,7 +25168,12 @@ const EF_ENCODERS = [ { name: 'EF.ACC', fid: '6f78', enc: efEncAcc, fields: EF_ACC_FIELDS }, { name: 'EF.PHASE', fid: '6fae', enc: efEncPhase, model: efPhaseModel, fields: EF_PHASE_FIELDS }, { name: 'EF.HPPLMN', fid: '6f31', enc: efEncHpplmn, fields: EF_HPPLMN_FIELDS }, - { name: 'EF.AD', fid: '6fad', enc: efEncAd, model: efAdModel, fields: EF_AD_FIELDS }, + // EF.AD collides in name and FID across DF.GSM / ADF.USIM / ADF.ISIM: + // efEncAd keeps the record length it was given (3+X or 4+X), so the + // entries differ only in their DF. + { name: 'EF.AD', df: '7F20', fid: '6fad', enc: efEncAd, model: efAdModel, fields: EF_AD_FIELDS }, + { name: 'EF.AD', df: 'ADF.USIM', fid: '6fad', enc: efEncAd, model: efAdModel, fields: EF_AD_FIELDS }, + { name: 'EF.AD', df: 'ADF.ISIM', fid: '6fad', enc: efEncAd, model: efAdModel, fields: EF_AD_FIELDS }, { name: 'EF.SPN', fid: '6f46', enc: efEncSpn, fields: EF_SPN_FIELDS }, { name: 'EF.PLMNsel', fid: '6f30', enc: efEncPlmnOnly, list: EF_PLMN_ONLY_LIST }, { name: 'EF.PLMNwAcT', fid: '6f60', enc: efEncPlmnList, list: EF_PLMN_LIST }, @@ -25125,19 +25199,25 @@ const EF_ENCODERS = [ // SIM layout has no editor yet). const EF_READONLY_NAMES = ['EF.SST']; -function efFindEncoder(name, fid) { +function efFindEncoder(name, fid, df) { let n = (name || '').toUpperCase(); if (n.includes('/')) n = n.split('/').pop(); - for (const e of EF_ENCODERS) if (e.name.toUpperCase() === n) return e; const f = (fid || '').toLowerCase(); + const d = (df || '').toUpperCase(); + // DF-first, like efFindDecoder + if (d) { + for (const e of EF_ENCODERS) if (e.df && e.df === d && e.name.toUpperCase() === n) return e; + if (f) for (const e of EF_ENCODERS) if (e.df && e.df === d && e.fid === f) return e; + } + for (const e of EF_ENCODERS) if (e.name.toUpperCase() === n) return e; if (f && EF_READONLY_NAMES.indexOf(n) === -1) { for (const e of EF_ENCODERS) if (e.fid === f) return e; } return null; } -function efHasEncoder(name, fid) { - return !!efFindEncoder(name, fid); +function efHasEncoder(name, fid, df) { + return !!efFindEncoder(name, fid, df); } // ---- content decoding, flattening and diffs ---- @@ -25147,8 +25227,8 @@ function efDecodeBytes(fn, bytes) { } // content: {kind:'transparent', data:'HEX'} | {kind:'record', records:[{num,data}]} -function efDecodeFile(name, fid, content) { - const dec = efFindDecoder(name, fid); +function efDecodeFile(name, fid, content, df) { + const dec = efFindDecoder(name, fid, df); if (!dec) return null; const out = { ef: dec.name, fn: dec.fn, data: null, records: null, empty: false }; if (!content) return out; @@ -25164,8 +25244,8 @@ function efDecodeFile(name, fid, content) { return out; } -function efDecodeHex(name, fid, hex) { - const dec = efFindDecoder(name, fid); +function efDecodeHex(name, fid, hex, df) { + const dec = efFindDecoder(name, fid, df); if (!dec || !dec.fn) return null; return efDecodeBytes(dec.fn, efBytes(hex || '')); } @@ -25279,9 +25359,9 @@ function efRenderFieldsHtml(rows) { // Mismatching decoded fields for a content check; null when the file has no // decoder or either side fails to decode. -function efContentDiff(name, fid, expectedHex, actualHex) { - const exp = efDecodeHex(name, fid, expectedHex); - const act = efDecodeHex(name, fid, actualHex); +function efContentDiff(name, fid, expectedHex, actualHex, df) { + const exp = efDecodeHex(name, fid, expectedHex, df); + const act = efDecodeHex(name, fid, actualHex, df); if (exp === null || act === null || efIsRawOnly(exp) || efIsRawOnly(act)) return null; const rows = efDiffData(exp, act); return { rows: rows, sameDecoded: rows.length === 0 }; @@ -25337,10 +25417,10 @@ function efDecItemsFromHex(entry, hex) { } // content: {kind:'transparent', data:'HEX'} | {kind:'record', records:[{num,data}]} -function efDecEditModel(name, fid, content) { - const entry = efFindEncoder(name, fid); +function efDecEditModel(name, fid, content, df) { + const entry = efFindEncoder(name, fid, df); if (!entry) return null; - const dec = efFindDecoder(name, fid); + const dec = efFindDecoder(name, fid, df); const modelValues = hex => entry.custom ? entry.custom.model(hex) : efDecModelFor(entry, dec, hex); @@ -25592,7 +25672,8 @@ function pysimFsDecAddRow() { if (!pysimFsSelected) return; const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot); const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null); - const entry = efFindEncoder(node && node.name, fid); + const entry = efFindEncoder(node && node.name, fid, + node ? efDfFromPath(pysimFsNodePath(node)) : ''); if (!entry || !entry.list) return; const out = document.getElementById('pysim-fs-content'); const rows = out && out.querySelector('.pysim-dec-rows'); @@ -25623,7 +25704,8 @@ function pysimFsDecAddItem(btn) { if (!box || !pysimFsSelected) return; const node = pysimFsFindNode(pysimFsSelected, pysimFsTreeRoot); const fid = (node && node.fid) || (/^[0-9a-fA-F]{4}$/.test(pysimFsSelected) ? pysimFsSelected : null); - const entry = efFindEncoder(node && node.name, fid); + const entry = efFindEncoder(node && node.name, fid, + node ? efDfFromPath(pysimFsNodePath(node)) : ''); if (!entry || !entry.items) return; const first = (entry.items.tags || [])[0]; box.insertAdjacentHTML('beforeend', efDecItemRowHtml(entry, { tag: first ? first.tag : '43' })); diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js index fd67d64..f6f1b65 100644 --- a/frontend/tests/ef_decode.test.js +++ b/frontend/tests/ef_decode.test.js @@ -26,13 +26,13 @@ const FNS = [ 'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText', 'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecSst', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact', 'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc', - 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl', + 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecHpplmn', 'efDecEcc', 'efDecEccUsim', 'efDecOpl', 'efDecAd', 'efDecAcl', 'efSmspDaNumber', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn', 'pinKeyRefName', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', - 'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDecodeBytes', + 'efDecExt1', 'efDecSms', 'efDecSume', 'efPbrHex', 'efDecPbr', 'efFindDecoder', 'efFidFromPath', 'efDfFromPath', 'efDecodeBytes', 'efDecodeFile', 'efDecodeHex', 'efIsRawOnly', 'efFieldLabel', 'efPrimitive', 'efFlatten', 'efDiffData', 'efDataSummary', 'efContentDiff', 'gsm7Decode', 'efRenderFieldsHtml', - 'pysimFsDecodedHtml', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects', + 'pysimFsDecodedHtml', 'pysimFsNodePath', 'profilerRenderReport', 'profilerLabelText', 'profilerResultAspects', 'profilerAspectSummary', 'profilerMatchedRecordsText', 'profilerRawDataCheck', 'profilerCustomNameForPath', 'pysimCustomNormPath', ]; @@ -45,6 +45,7 @@ for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) { for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_EST_SERVICES', 'EF_PHASE_NAMES', 'EF_PBR_KINDS']) { code += html.match(new RegExp('const ' + c + ' = \\{.*\\};'))[0].replace('const ', 'var ') + '\n'; } +code += html.match(/const EF_ECC_CATEGORIES = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n'; code += html.match(/const EF_FIELD_LABELS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n'; code += html.match(/const EF_DECODERS = \[[\s\S]*?\n\];/)[0].replace('const ', 'var ') + '\n'; code += 'var pysimCustomFiles = [];\nvar pysimFsSelected = \'EF.IMSI\';\n'; @@ -357,6 +358,70 @@ test('decoder registry resolves by name first, then FID; unknown returns null', assert.strictEqual(efFidFromPath('ADF.USIM'), null); }); +test('efDfFromPath reads the parent DF of a canonical path', () => { + assert.strictEqual(efDfFromPath('MF/7F20/6FAD'), '7F20'); + assert.strictEqual(efDfFromPath('ADF.USIM/6FAD'), 'ADF.USIM'); + assert.strictEqual(efDfFromPath('MF/ADF.USIM/6FAD'), 'ADF.USIM'); + assert.strictEqual(efDfFromPath('mf/7f20/6fad'), '7F20'); + assert.strictEqual(efDfFromPath('MF/7F10/5F3A/4F30'), '5F3A'); + // a bare FID (and a bare root) carries no DF context + assert.strictEqual(efDfFromPath('6FAD'), ''); + assert.strictEqual(efDfFromPath('MF'), ''); + assert.strictEqual(efDfFromPath('ADF.USIM'), ''); + assert.strictEqual(efDfFromPath(''), ''); + assert.strictEqual(efDfFromPath(null), ''); + // the two halves of the spec identity come from the same path + assert.strictEqual(efFidFromPath('MF/7F20/6FAD'), '6FAD'); +}); + +test('the decoder resolver is DF-first: EF.ECC is the SIM or the USIM file', () => { + // EF.ECC collides in name AND FID: only the parent DF separates the + // TS 51.011 transparent form from the TS 31.102 linear-fixed one + assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', '7F20').fn, efDecEcc); + assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'ADF.USIM').fn, efDecEccUsim); + assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'adf.usim').fn, efDecEccUsim); + assert.strictEqual(efFindDecoder(null, '6FB7', 'ADF.USIM').fn, efDecEccUsim); + // without a DF the historical name-then-FID lookup answers (the first + // entry, the DF.GSM form, is unchanged behaviour) + assert.strictEqual(efFindDecoder('EF.ECC', '6fb7').fn, efDecEcc); + assert.strictEqual(efFindDecoder('EF.ECC', null).fn, efDecEcc); + // a DF carrying no entry of its own falls back to the generic lookup + assert.strictEqual(efFindDecoder('EF.ECC', '6fb7', 'ADF.ISIM').fn, efDecEcc); + // the FID fallback stays inside the DF + assert.strictEqual(efFindDecoder('EF.NOPE', '6FB7', 'ADF.USIM').fn, efDecEccUsim); + assert.strictEqual(efFindDecoder('EF.NOPE', 'abcd', 'ADF.USIM'), null); + // every EF.AD form has its own DF-keyed entry over one decoder + assert.strictEqual(efFindDecoder('EF.AD', '6fad', '7F20').fn, efDecAd); + assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.USIM').fn, efDecAd); + assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.ISIM').fn, efDecAd); + assert.strictEqual(efFindDecoder('EF.AD', '6fad', 'ADF.ISIM').df, 'ADF.ISIM'); + // files without a collision are unaffected by the DF context + assert.strictEqual(efFindDecoder('EF.IMSI', '6f07', 'ADF.USIM').name, 'EF.IMSI'); + assert.strictEqual(efFindDecoder(null, '2fe2', 'MF').name, 'EF.ICCID'); +}); + +test('EF.ECC (USIM) decodes a X+4 record: code, alpha and service category', () => { + // pySim's EF_ECC vectors (TS 31.102 4.2.21): 3 BCD code bytes, the + // optional annex A alpha, then the category byte + assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF01')), { code: '911', category: 'police' }); + assert.deepStrictEqual(efDecEccUsim(efBytes('19F3FF02')), { code: '913', category: 'ambulance' }); + // code 112 = 11 F2 FF (odd digit count, F padded) + assert.deepStrictEqual(efDecEccUsim(efBytes('11F2FF40')), { code: '112', category: 'automatic eCall' }); + // with the optional alpha identifier (X = 3, GSM 7-bit octets) + assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF424F4204')), + { code: '911', category: 'fire brigade', alpha: 'BOB' }); + // a UCS-2 (0x80-prefixed) alpha, and two category bits at once + assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF800042004F004205')), + { code: '911', category: 'police, fire brigade', alpha: 'BOB' }); + // an unmodelled category byte stays visible as its hex value + assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF80')), { code: '911', category: '0x80' }); + // unused records: all FF, or the mandatory code all FF + assert.deepStrictEqual(efDecEccUsim(efBytes('FFFFFFFFFFFF')), { empty: true }); + assert.deepStrictEqual(efDecEccUsim(efBytes('FFFFFF02')), { empty: true }); + // shorter than the mandatory fields: raw fallback, never a throw + assert.deepStrictEqual(efDecEccUsim(efBytes('19F1FF')), { raw: '19F1FF' }); +}); + test('efDecodeFile handles transparent and record content', () => { const t = efDecodeFile('EF.IMSI', null, { kind: 'transparent', data: '082982608200002080' }); assert.strictEqual(typeof t.fn, 'function'); @@ -398,6 +463,26 @@ test('efFlatten skips the all-FF empty marker', () => { assert.deepStrictEqual(efFlatten({ empty: true }), []); }); +test('pysimFsDecodedHtml decodes EF.ECC by the selected node\'s parent DF', () => { + // ADF.USIM's EF.ECC is the linear-fixed X+4 form (code/alpha/category) + const usim = { name: 'ADF.USIM', children: [] }; + const usimEcc = { name: 'EF.ECC', fid: '6FB7', parent: usim }; + usim.children.push(usimEcc); + const usimHtml = pysimFsDecodedHtml(usimEcc, { success: true, records: [{ num: 1, data: '19F1FF01' }] }); + assert.ok(usimHtml.includes('Emergency call code')); + assert.ok(usimHtml.includes('911')); + assert.ok(usimHtml.includes('police')); + // DF.GSM's EF.ECC keeps the transparent code + category decode + const gsm = { name: 'DF.GSM', fid: '7F20', children: [] }; + const gsmEcc = { name: 'EF.ECC', fid: '6FB7', parent: gsm }; + gsm.children.push(gsmEcc); + const gsmHtml = pysimFsDecodedHtml(gsmEcc, { success: true, data: '19F101' }); + assert.ok(gsmHtml.includes('Number')); + assert.ok(gsmHtml.includes('911')); + assert.ok(gsmHtml.includes('Emergency category')); + assert.ok(!gsmHtml.includes('Emergency call code')); +}); + test('efDiffData names an all-FF side Empty (all FF)', () => { assert.deepStrictEqual(efDiffData({ empty: true }, { full: 'Miran' }), [['Full name', 'Empty (all FF)', 'Miran']]); diff --git a/frontend/tests/ef_encode.test.js b/frontend/tests/ef_encode.test.js index 3ab5791..683c037 100644 --- a/frontend/tests/ef_encode.test.js +++ b/frontend/tests/ef_encode.test.js @@ -218,6 +218,36 @@ test('EF.SPN and the ADN family share the encoder registry with the PLMN list', assert.strictEqual(efFindEncoder('EF.PLMNwAcT', null).list, efFindEncoder('EF.HPLMNwAcT', null).list); }); +test('the encoder resolver is DF-first: every EF.AD form shares one encoder', () => { + // EF.AD collides in name AND FID: one entry per DF (DF.GSM 3+X, + // ADF.USIM 4+X, ADF.ISIM 3+X), all over the same length-preserving codec + const sim = efFindEncoder('EF.AD', '6fad', '7F20'); + const usim = efFindEncoder('EF.AD', '6fad', 'ADF.USIM'); + const isim = efFindEncoder('EF.AD', '6fad', 'ADF.ISIM'); + for (const e of [sim, usim, isim]) { + assert.strictEqual(e.enc, efEncAd); + assert.strictEqual(e.model, efAdModel); + assert.strictEqual(e.fields, EF_AD_FIELDS); + } + assert.notStrictEqual(sim, isim); + // without a DF the historical name-first lookup answers (DF.GSM's entry) + assert.strictEqual(efFindEncoder('EF.AD', '6fad'), sim); + assert.strictEqual(efFindEncoder('EF.AD', '6fad', 'ADF.NOPE'), sim); + // EF.ECC stays read-only in either DF, EF.SST in the SIM layout, and the + // shared 6F38 FID must not hand SST the UST encoder + assert.strictEqual(efHasEncoder('EF.ECC', '6fb7', '7F20'), false); + assert.strictEqual(efHasEncoder('EF.ECC', '6fb7', 'ADF.USIM'), false); + assert.strictEqual(efHasEncoder('EF.SST', '6f38', '7F20'), false); + assert.strictEqual(efHasEncoder('EF.UST', '6f38', 'ADF.USIM'), true); + assert.strictEqual(efFindEncoder('EF.NOPE', 'abcd', 'ADF.USIM'), null); + // the SIM 3+X and USIM 4+X forms keep their length on an untouched save + for (const hex of ['00' + '0000', '00' + '0000' + '02' + 'AABB', AD_HEX, 'FF'.repeat(6)]) { + for (const e of [sim, usim]) { + assert.strictEqual(e.enc(e.model(efDecAd(efBytes(hex))), hex), hex.toUpperCase(), hex); + } + } +}); + test('encoder validation errors go through t() when the runtime provides it', () => { globalThis.t = k => (k === 'Select at least one access technology' ? 'Выберите технологию' : k); try { diff --git a/frontend/tests/fs_dec_edit.test.js b/frontend/tests/fs_dec_edit.test.js index e1eba21..26600a1 100644 --- a/frontend/tests/fs_dec_edit.test.js +++ b/frontend/tests/fs_dec_edit.test.js @@ -52,7 +52,7 @@ const FNS = [ 'efDecRecordsHtml', 'efDecEditHtml', 'efDecCollectValues', 'efDecCollectRows', 'efDecCollectItems', 'efBitsSelected', 'pysimFsDecBitsFilter', 'pysimFsDecRowCheck', 'pysimFsDecItemTag', 'pysimFsDecAddItem', - 'pysimFsDecRemoveItem', 'pysimFsFindNode', + 'pysimFsDecRemoveItem', 'pysimFsFindNode', 'pysimFsNodePath', 'efDfFromPath', 'pysimFsHasDecoder', 'pysimFsHasEncoder', 'pysimFsDecSave', 'pysimFsDecEdit', 'pysimFsPillsEnabled', 'pysimFsSyncContentVisibility', 'gsm7Decode', 'gsm7TextToSeptets', 'gsm7Encode', @@ -271,6 +271,19 @@ test('pysimFsHasEncoder resolves the selected file by name or FID', () => { assert.strictEqual(pysimFsHasEncoder(), false); }); +test('the file-manager resolver call sites pass the node\'s parent DF', () => { + // the DF context comes from the node's canonical path; these five sites + // gate/read/edit a selected file and must resolve the same entry + assert.ok(html.includes('return !!efFindDecoder(node && node.name, fid, df);')); + assert.ok(html.includes('return !!efFindEncoder(node && node.name, fid, df);')); + assert.ok(html.includes("const df = node ? efDfFromPath(pysimFsNodePath(node)) : '';")); + assert.ok(html.includes('const model = efDecEditModel(node && node.name, fid, content,')); + for (const fn of ['pysimFsDecSave', 'pysimFsDecAddRow', 'pysimFsDecAddItem']) { + assert.ok(/efFindEncoder\([^;]*efDfFromPath\(pysimFsNodePath\(node\)\)/s.test(extractFunc(html, fn)), + fn + ' resolves the entry with the node DF'); + } +}); + test('the Edit decoded button is encoder-gated next to Edit raw', () => { const btn = /]*id="pysim-fs-edit-dec-btn"[^>]*>/.exec(html); assert.ok(btn, 'Edit decoded button'); diff --git a/frontend/tests/fs_edit.test.js b/frontend/tests/fs_edit.test.js index c6068df..d7084b6 100644 --- a/frontend/tests/fs_edit.test.js +++ b/frontend/tests/fs_edit.test.js @@ -29,7 +29,8 @@ let code = 'var pysimFsDecodedMode = false;\n' + 'var pysimFsSelected = null;\n' + 'var pysimFsTreeRoot = null;\n'; for (const fn of ['pysimFsSetMode', 'pysimFsPillsEnabled', 'pysimFsEdit', - 'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsHasDecoder', + 'pysimFsCancel', 'pysimFsResetEdit', 'pysimFsFindNode', 'pysimFsNodePath', + 'efDfFromPath', 'pysimFsHasDecoder', 'pysimFsSyncContentVisibility']) { code += extractFunc(html, fn) + '\n'; }