diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 20623d7..8a4c7e9 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -381,7 +381,7 @@
  • Атрибуты файла — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).
  • Проверить содержимое (опционально) — Точное (точное совпадение hex) или Маска, где ? — шаблон на один полубайт (маска без ? — совпадение префикса, например 0891 для MCC/MNC из IMSI). Для record-файлов хранится список по записям.
  • -

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали.

    +

    Проверить карту выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, тип файла и размер, содержимое или полный FCI); если часть проверок прошла, а часть нет — каждый аспект помечается (тип файла ✓, размер ✗, содержимое ✓), а расхождения расписываются ниже. Несовпавшие сырые данные (FCI, содержимое, данные записей) показываются как поля только для чтения с моноширинным шрифтом — ожидаемое над фактическим, в одной и той же колонке — для удобного сравнения; для расхождений FCI дополнительно показывается декодированное сравнение по параметрам (размер файла, дескриптор/структура, жизненный цикл, FID, SFI, проприетарные параметры…). Декодированный просмотр FCP также отображается под полем FCI hex при редактировании правила. Для record-файлов при расхождении содержимого добавляется пометка совпадающие записи: 1-5, 7-10 со списком записей, которые совпали.

    Опции сканирования «Профиль с карты»

    Диалог сканирования запрашивает имя профиля и предлагает селектор «Проверка FCP/FCI» (те же три режима, по умолчанию Тип файла + размер (FCP)), применяемый ко всем создаваемым правилам, а также список «Игнорировать содержимое файлов» (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Ещё две отмеченные по умолчанию опции «Сравнивать первые 4 байта для» EF.IMSI и EF.ICCID захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает N / всего файлов с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки «Пользовательские файлы» включаются с той же проверкой существования.

    diff --git a/frontend/help.html b/frontend/help.html index 4d86147..c19ea41 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -381,7 +381,7 @@
  • File attributes — file type, size, record length and record count, taken from the FCP template (any may be left unset).
  • Check contents (optional) — Exact hex equality, or Mask where ? is a per-nibble wildcard (a mask with no ? is a prefix match, e.g. 0891 for the IMSI MCC/MNC). Record files store a per-record list.
  • -

    Check card runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison. For record files with a contents mismatch, a matching records: 1-5, 7-10 note lists the records that did match.

    +

    Check card runs every rule against the equipped card and shows a live progress line plus a pass/fail report. Each row states exactly what was verified next to the file path (e.g. filetype and size, contents or exact FCI); when some checks pass and others fail, each aspect is marked (filetype ✓, size ✗, contents ✓) with the mismatches detailed below. Mismatched raw data (FCI, contents, record data) is shown as read-only monospace fields — expected above actual, aligned in the same column — for easy comparison; FCI mismatches additionally show a decoded per-parameter comparison (file size, file descriptor/structure, life cycle, FID, SFI, proprietary parameters…). A decoded FCP preview is also shown under the FCI hex field while editing a rule. For record files with a contents mismatch, a matching records: 1-5, 7-10 note lists the records that did match.

    “Profile from card” scan options

    The scan dialog asks for a profile name and offers a “FCP/FCI check” selector (the same three modes above, default Filetype + size) applied to every generated rule, plus an “Ignore contents of files” checklist (all checked by default) of frequently-overwritten files whose contents are skipped: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Two further checked-by-default options “Match first 4 bytes for” EF.IMSI and EF.ICCID capture those files’ contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows N / total files with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the Custom files sub-tab are included under the same existence check.

    diff --git a/frontend/index.html b/frontend/index.html index f074f7c..c9d2461 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7411,7 +7411,8 @@ function profilerRenderRule(i, r) { html += ''; if (fciMode === 'exact') { html += '
    ' + - '
    '; + '' + + '
    ' + profilerFciPreviewHtml(r.fciHex || '') + '
    '; } if (r.fileType !== 'df') { html += profilerRenderContentEditor(i, r); @@ -7745,6 +7746,207 @@ function profilerRawDataCheck(c) { return false; } +// ===== FCP/FCI decoding (ISO 7816-4 5.3.3 tables 12-14, TS 102 221 11.1.1.4) ===== + +function fcpInt(hex) { return hex ? parseInt(hex, 16) : 0; } + +function fcpFileDescriptor(v) { + if (!v) return ''; + const fd = parseInt(v.substr(0, 2), 16); + const parts = [(fd & 0x80) ? 'shareable' : 'not shareable']; + const cat = (fd >> 3) & 0x07; + const st = fd & 0x07; + if (cat === 0x07) { + parts.push(st === 0x01 ? 'BER-TLV EF' : st === 0x02 ? 'SIMPLE-TLV EF' : st === 0x00 ? 'DF/ADF' : 'proprietary file (' + v.substr(0, 2) + ')'); + } else { + parts.push(cat === 0 ? 'working EF' : cat === 1 ? 'internal EF' : 'proprietary EF (' + cat + ')'); + parts.push(['no info', 'transparent', 'linear fixed', 'linear fixed TLV', 'linear variable', 'linear variable TLV', 'cyclic', 'cyclic TLV'][st] || ('structure ' + st)); + } + if (v.length >= 4) parts.push('data coding ' + v.substr(2, 2)); + if (v.length >= 10) parts.push('record length ' + fcpInt(v.substr(4, 4)) + ', ' + fcpInt(v.substr(8, 2)) + ' records'); + else if (v.length === 8) parts.push('record length ' + fcpInt(v.substr(4, 4))); + else if (v.length === 6) parts.push('record length ' + fcpInt(v.substr(4, 2))); + return parts.join(', '); +} + +// ISO 7816-4 table 13 / TS 102 221 table 11.7b. +function fcpLifeCycle(v) { + if (v.length !== 2) return v; + const b = parseInt(v, 16); + if (b === 0x00) return 'no information'; + if (b === 0x01) return 'creation'; + if (b === 0x03) return 'initialization'; + if ((b & 0x0C) === 0x0C) return 'termination'; + if ((b & 0x05) === 0x05) return 'operational, activated'; + if ((b & 0x05) === 0x04) return 'operational, deactivated'; + return 'proprietary (' + v + ')'; +} + +// TS 102 221 11.1.1.4.8: SFI in b8-b4, b3-b1 zero; length 0 = no short id. +function fcpSfi(v) { + if (!v) return 'not supported'; + if (v.length === 2 && (parseInt(v, 16) & 0x07) === 0) return String(parseInt(v, 16) >> 3); + return v; +} + +function fcpDo(key, value) { + if (/^[0-9A-F]+$/.test(value) === false) value = value || ''; + switch (key) { + case '80': return { name: 'File size', decoded: fcpInt(value) + ' bytes' }; + case '81': return { name: 'Total file size', decoded: fcpInt(value) + ' bytes' }; + case '82': return { name: 'File descriptor', decoded: fcpFileDescriptor(value) }; + case '83': return { name: 'File identifier', decoded: value }; + case '84': return { name: 'DF name (AID)', decoded: value }; + case '85': return { name: 'Proprietary information', decoded: value }; + case '86': return { name: 'Security attribute (proprietary)', decoded: value }; + case '87': return { name: 'FCI extension EF', decoded: value }; + case '88': return { name: 'Short file identifier', decoded: fcpSfi(value) }; + case '8A': return { name: 'Life cycle status', decoded: fcpLifeCycle(value) }; + case '8B': return { name: 'Security attributes (referenced)', decoded: value.length >= 6 ? 'EF_ARR ' + value.substr(0, 4) + ', record ' + fcpInt(value.substr(4, 2)) : value }; + case '8C': return { name: 'Security attributes (compact)', decoded: value }; + case '8D': return { name: 'Security env. template EF', decoded: value }; + case '8E': return { name: 'Channel security attribute', decoded: value }; + case 'A0': return { name: 'Security attributes (data objects)', decoded: value }; + case 'A5': return { name: 'Proprietary information', decoded: null }; + case 'AB': return { name: 'Security attributes (expanded)', decoded: value }; + case 'C6': return { name: 'PIN status template DO', decoded: null }; + case 'A5/80': { + const b = fcpInt(value); + const parts = [(b & 0x01) ? 'clock stop allowed' : 'clock stop not allowed']; + const cls = []; + if (b & 0x10) cls.push('A'); + if (b & 0x20) cls.push('B'); + if (b & 0x40) cls.push('C'); + if (b & 0x80) cls.push('D'); + if (cls.length) parts.push('class ' + cls.join('/')); + return { name: 'UICC characteristics', decoded: parts.join(', ') }; + } + case 'A5/81': { + if (value.length < 6) return { name: 'Application power consumption', decoded: value }; + const vc = fcpInt(value.substr(0, 2)); + const cls = []; + if (vc & 0x01) cls.push('A'); + if (vc & 0x02) cls.push('B'); + if (vc & 0x04) cls.push('C'); + if (vc & 0x08) cls.push('D'); + if (vc & 0x10) cls.push('E'); + const ma = fcpInt(value.substr(2, 2)) & 0x3F; + const f = fcpInt(value.substr(4, 2)); + const parts = []; + if (cls.length) parts.push('class ' + cls.join('/')); + if (ma) parts.push(ma + ' mA'); + if (f === 0xFF) parts.push('no reference frequency'); + else if (f) parts.push((f / 10) + ' MHz'); + return { name: 'Application power consumption', decoded: parts.join(', ') }; + } + case 'A5/82': { + const f = fcpInt(value); + return { name: 'Minimum application clock frequency', decoded: f === 0xFF ? 'none' : (f / 10) + ' MHz' }; + } + case 'A5/83': return { name: 'Available memory', decoded: fcpInt(value) + ' bytes' }; + case 'A5/84': return { name: 'File details', decoded: (fcpInt(value) & 0x01) ? 'DER coding only' : 'no details' }; + case 'A5/85': return { name: 'Reserved file size', decoded: fcpInt(value) + ' bytes' }; + case 'A5/86': return { name: 'Maximum file size', decoded: fcpInt(value) + ' bytes' }; + case 'A5/87': return { name: 'Supported system commands', decoded: (fcpInt(value) & 0x01) ? 'TERMINAL CAPABILITY' : 'none' }; + case 'A5/88': { + const b = fcpInt(value); + const tc = ['standard temperature range', 'temperature class A', 'temperature class B', 'temperature class C'][b & 0x07] || 'RFU'; + return { name: 'Specific UICC environmental conditions', decoded: tc + ((b & 0x08) ? ', high humidity' : '') }; + } + case 'A5/89': return { name: 'Platform-to-platform CAT secured APDU', decoded: value }; + case 'A5/8A': return { name: 'Test configuration state', decoded: (fcpInt(value) & 0x01) ? 'active' : 'inactive' }; + case 'A5/8B': return { name: 'Test configuration criterion type', decoded: value }; + case 'A5/C0': return { name: 'Special file info', decoded: value }; + case 'A5/C1': return { name: 'Filling pattern', decoded: value }; + case 'A5/C2': return { name: 'Repeat pattern', decoded: value }; + case 'C6/90': return { name: 'PS_DO', decoded: value }; + case 'C6/95': return { name: 'Usage qualifier', decoded: value }; + case 'C6/83': return { name: 'Key reference', decoded: value }; + default: return { name: 'Unknown TLV ' + key, decoded: value }; + } +} + +// Decode a SELECT response (FCI): accepts the FCP template '62', an FCI template +// '6F' wrapping FCP/FMD, or bare FCP content. Returns { ok, template, items }. +function fcpDecode(hex) { + const h = (hex || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (!h) return { ok: false, template: null, items: [] }; + let template = null; + let body = h; + const top = parseTlvList(h); + if (top.length === 1 && top[0].raw.length === h.length && (top[0].tag === '62' || top[0].tag === '6F' || top[0].tag === '64')) { + template = top[0].tag; + body = top[0].value; + if (template === '6F') { + const fcp = parseTlvList(body).find(t => t.tag === '62'); + if (fcp) { template = '62'; body = fcp.value; } + } + } + const tlvs = parseTlvList(body); + const consumed = tlvs.reduce((n, t) => n + t.raw.length, 0); + const ok = tlvs.length > 0 && consumed === body.length; + const items = []; + for (const t of tlvs) { + const d = fcpDo(t.tag, t.value); + items.push({ key: t.tag, tag: t.tag, name: d.name, value: t.value, decoded: d.decoded }); + if (t.tag === 'A5' || t.tag === 'C6') { + for (const sub of parseTlvList(t.value)) { + const sd = fcpDo(t.tag + '/' + sub.tag, sub.value); + items.push({ key: t.tag + '/' + sub.tag, tag: sub.tag, name: sd.name, value: sub.value, decoded: sd.decoded }); + } + } + } + return { ok, template, items }; +} + +function fcpDiffHtml(expectedHex, actualHex) { + const e = fcpDecode(expectedHex); + const a = fcpDecode(actualHex); + if (!e.ok || !a.ok || !e.items.length || !a.items.length) return ''; + const eMap = {}, aMap = {}; + e.items.forEach(it => { eMap[it.key] = it; }); + a.items.forEach(it => { aMap[it.key] = it; }); + const keys = e.items.map(it => it.key); + for (const it of a.items) if (!eMap[it.key]) keys.push(it.key); + let html = '
    ' + esc(t('FCP parameters')) + '
    '; + html += ''; + html += '' + + '' + + '' + + ''; + for (const k of keys) { + const ev = eMap[k], av = aMap[k]; + const ed = ev ? (ev.decoded === null ? '' : (ev.decoded || ev.value)) : '—'; + const ad = av ? (av.decoded === null ? '' : (av.decoded || av.value)) : '—'; + const eq = !!ev && !!av && ev.decoded === av.decoded && ev.value === av.value; + const color = eq ? 'text-gray-500 dark:text-slate-400' : 'text-red-600 font-medium'; + html += '' + + '' + + '' + + ''; + } + html += '
    ' + esc(t('Parameter')) + '' + esc(t('expected')) + '' + esc(t('actual')) + '
    ' + esc((ev || av).name) + '' + esc(ed) + '' + esc(ad) + '
    '; + return html; +} + +function profilerFciPreviewHtml(hex) { + const d = fcpDecode(hex); + if (!d.ok || !d.items.length) return ''; + let html = '
    ' + esc(t('Decoded FCP')) + '
    '; + html += '
    '; + for (const it of d.items) { + if (it.decoded === null) continue; + html += '
    ' + esc(it.name) + ': ' + esc(it.decoded || it.value) + '
    '; + } + html += '
    '; + return html; +} + +function profilerUpdateFciPreview(i, value) { + const el = document.getElementById('profiler-fci-preview-' + i); + if (el) el.innerHTML = profilerFciPreviewHtml(value); +} + function profilerRenderReport(results) { let html = ''; for (const r of results) { @@ -7772,6 +7974,7 @@ function profilerRenderReport(results) { html += '
    ' + '' + esc(t('actual')) + '' + '
    '; + if (c.label === 'fci') html += fcpDiffHtml(String(c.expected), String(c.actual)); html += ''; } else { html += '
    ' + esc(c.label) + ': ' + esc(t('expected')) + ' ' + esc(String(c.expected)) + ', ' + esc(t('actual')) + ' ' + esc(String(c.actual)) + '
    '; @@ -8000,6 +8203,9 @@ const LANG_RU = { 'Filetype + size (FCP)': 'Тип файла + размер (FCP)', 'Exact FCI': 'Полный FCI', 'FCI hex (raw SELECT response)': 'FCI hex (сырой ответ SELECT)', + 'FCP parameters': 'Параметры FCP', + 'Parameter': 'Параметр', + 'Decoded FCP': 'Декодированный FCP', 'No profiles defined.': 'Профили не заданы.', 'rules': 'правил', 'Check card': 'Проверить карту', diff --git a/frontend/sw.js b/frontend/sw.js index 6b5580f..536b596 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v71'; +const CACHE = 'otaman-v72'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index 42a5384..8b47047 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) { return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); } -const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport']; +const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewHtml']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; @@ -576,3 +576,103 @@ test('profilerRenderReport renders raw-data mismatches as aligned readonly field assert.ok(!fciBlock.includes(': expected')); delete global.t; }); + +// --- FCP/FCI decoding (ISO 7816-4 5.3.3, TS 102 221 11.1.1.4) --- + +const FCP_TRANSPARENT = '6212800200098202412183026F078A0105880110'; +const FCP_LINFIX = '620E82054221006E0583026F3A8A0105'; +const FCP_DF = '620B8202782183027F208A0105'; +const FCP_BERTLV = '620B8202792183026F018A0105'; + +test('fcpDecode decodes a transparent EF FCP template', () => { + const d = fcpDecode(FCP_TRANSPARENT); + assert.strictEqual(d.ok, true); + assert.strictEqual(d.template, '62'); + const by = {}; + d.items.forEach(it => { by[it.key] = it; }); + assert.strictEqual(by['80'].decoded, '9 bytes'); + assert.strictEqual(by['82'].decoded, 'not shareable, working EF, transparent, data coding 21'); + assert.strictEqual(by['83'].decoded, '6F07'); + assert.strictEqual(by['8A'].decoded, 'operational, activated'); + assert.strictEqual(by['88'].decoded, '2'); +}); + +test('fcpDecode decodes linear fixed file descriptor (record length/count)', () => { + const d = fcpDecode(FCP_LINFIX); + assert.strictEqual(d.ok, true); + const fd = d.items.find(it => it.key === '82'); + assert.strictEqual(fd.decoded, 'not shareable, working EF, linear fixed, data coding 21, record length 110, 5 records'); +}); + +test('fcpDecode decodes DF and BER-TLV structures', () => { + assert.strictEqual(fcpDecode(FCP_DF).items.find(it => it.key === '82').decoded, 'not shareable, DF/ADF, data coding 21'); + assert.strictEqual(fcpDecode(FCP_BERTLV).items.find(it => it.key === '82').decoded, 'not shareable, BER-TLV EF, data coding 21'); +}); + +test('fcpDecode maps life cycle status per table 11.7b', () => { + const lcs = v => fcpDecode('6207820278218A01' + v).items.find(it => it.key === '8A').decoded; + assert.strictEqual(lcs('00'), 'no information'); + assert.strictEqual(lcs('01'), 'creation'); + assert.strictEqual(lcs('03'), 'initialization'); + assert.strictEqual(lcs('05'), 'operational, activated'); + assert.strictEqual(lcs('04'), 'operational, deactivated'); + assert.strictEqual(lcs('0C'), 'termination'); + assert.strictEqual(lcs('0D'), 'termination'); + assert.strictEqual(lcs('80'), 'proprietary (80)'); +}); + +test('fcpDecode decodes A5 proprietary sub-TLVs', () => { + const d = fcpDecode('62128202412183026F078A0105A5058503000000'); + const by = {}; + d.items.forEach(it => { by[it.key] = it; }); + assert.strictEqual(by['A5'].decoded, null); + assert.strictEqual(by['A5/85'].decoded, '0 bytes'); +}); + +test('fcpDecode unwraps an FCI 6F template and rejects malformed input', () => { + const d = fcpDecode('6F146212800200098202412183026F078A0105880110'); + assert.strictEqual(d.ok, true); + assert.strictEqual(d.template, '62'); + assert.strictEqual(d.items.find(it => it.key === '80').decoded, '9 bytes'); + + assert.strictEqual(fcpDecode('').ok, false); + assert.strictEqual(fcpDecode('ZZZZ').ok, false); + assert.strictEqual(fcpDecode('6214800200098202412183').ok, false); // truncated + assert.strictEqual(fcpDecode('6213' + FCP_TRANSPARENT.slice(4)).ok, false); // wrong outer length +}); + +test('fcpDiffHtml highlights differing FCP parameters', () => { + global.t = s => s; + const same = fcpDiffHtml(FCP_TRANSPARENT, FCP_TRANSPARENT); + assert.ok(same.includes('File size')); + assert.ok(!same.includes('text-red-600')); + const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110'); + assert.ok(diff.includes('9 bytes')); + assert.ok(diff.includes('10 bytes')); + assert.ok(diff.includes('text-red-600')); + assert.ok(!fcpDiffHtml('garbage', FCP_TRANSPARENT)); + delete global.t; +}); + +test('profilerFciPreviewHtml renders a decoded preview and degrades gracefully', () => { + global.t = s => s; + assert.ok(profilerFciPreviewHtml(FCP_TRANSPARENT).includes('File size: ')); + assert.strictEqual(profilerFciPreviewHtml('not hex'), ''); + assert.strictEqual(profilerFciPreviewHtml(''), ''); + delete global.t; +}); + +test('profilerRenderReport shows the decoded FCI diff for a raw fci mismatch', () => { + global.t = s => s; + global.pysimCustomFiles = []; + const html = profilerRenderReport([ + { path: 'MF/6F07', name: 'EF.IMSI', status: 'fail', checks: [ + { label: 'fci', ok: false, expected: FCP_TRANSPARENT, actual: '62128002000A8202412183026F078A0105880110' }, + ] }, + ]); + assert.ok(html.includes('FCP parameters')); + assert.ok(html.includes('9 bytes')); + assert.ok(html.includes('10 bytes')); + assert.ok(html.includes('text-red-600')); + delete global.t; +});