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

    Check выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCP и совпадение содержимого).

    +

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

    Опции сканирования «Profile from card»

    Диалог сканирования запрашивает имя профиля и предлагает селектор «FCP/FCI check» (те же три режима, по умолчанию Filetype + size), применяемый ко всем создаваемым правилам, а также список «Ignore contents of» (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: EF.LOCI, EF.PSLOCI, EF.EPSLOCI, EF.5GS3GPPLOCI, EF.Keys, EF.KeysPS, EF.SMS, EF.Kc, EF.KcGPRS, EF.LOCIGPRS, EF.CBMID, EF.SMSS. Строка прогресса показывает N / всего файлов с текущим путём файла во время сканирования. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки Custom files включаются с той же проверкой существования.

    diff --git a/frontend/help.html b/frontend/help.html index 6e9c38a..510910c 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -379,7 +379,7 @@
  • File attributes — file type, size, record length and record count, taken from the FCP template (any may be left unset).
  • 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 runs every rule against the equipped card and shows a live progress line plus a pass/fail report (existence, each FCI attribute, and the content match).

    +

    Check 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. 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” 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. A progress line shows N / total files with the current file path while scanning. 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 a5c0e57..04b1b7b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7611,12 +7611,14 @@ async function profilerRunRule(r) { res.status = 'fail'; res.checks.push({ label: 'content.records', expected: exp.length + ' records', actual: act.length + ' records', ok: false }); } + const matched = []; for (let j = 0; j < n; j++) { const dataOk = sizeMismatch ? profilerMatchMin(r.content.mode, exp[j].data, act[j].data) : profilerMatch(r.content.mode, exp[j].data, act[j].data); const ok = exp[j].num === act[j].num && dataOk; - if (!ok) res.status = 'fail'; + if (!ok) res.status = 'fail'; else matched.push(exp[j].num); res.checks.push({ label: 'content.rec' + exp[j].num, expected: exp[j].data, actual: act[j].data, ok: ok }); } + if (matched.length) res.recordsMatched = matched; } else { const ok = sizeMismatch ? profilerMatchMin(r.content.mode, r.content.expected, rd.data) : profilerMatch(r.content.mode, r.content.expected, rd.data); if (!ok) res.status = 'fail'; @@ -7640,6 +7642,65 @@ function profilerCustomNameForPath(path) { return null; } +// Group a rule's result checks into display aspects, derived from the checks +// that actually ran. 'Exact FCI' subsumes filetype/size/records; 'exists' is +// implicit and omitted. +function profilerResultAspects(res) { + const groups = { filetype: [], size: [], records: [], 'Exact FCI': [], contents: [] }; + for (const c of (res.checks || [])) { + const l = c.label; + if (l === 'fileType') groups.filetype.push(c); + else if (l === 'fileSize') groups.size.push(c); + else if (l === 'recordLen' || l === 'numRecords') groups.records.push(c); + else if (l === 'fci') groups['Exact FCI'].push(c); + else if (l === 'content' || l.startsWith('content.')) groups.contents.push(c); + } + const out = []; + if (groups['Exact FCI'].length) { + out.push({ key: 'Exact FCI', ok: groups['Exact FCI'].every(c => c.ok) }); + } else { + if (groups.filetype.length) out.push({ key: 'filetype', ok: groups.filetype.every(c => c.ok) }); + if (groups.size.length) out.push({ key: 'size', ok: groups.size.every(c => c.ok) }); + if (groups.records.length) out.push({ key: 'records', ok: groups.records.every(c => c.ok) }); + } + if (groups.contents.length) out.push({ key: 'contents', ok: groups.contents.every(c => c.ok) }); + return out; +} + +function profilerAspectSummary(aspects, marked, tr) { + tr = tr || (s => s); + if (!aspects || !aspects.length) return ''; + if (marked) return aspects.map(a => tr(a.key) + ' ' + (a.ok ? '✓' : '✗')).join(', '); + const has = k => aspects.some(a => a.key === k); + let fcp = ''; + if (has('Exact FCI')) fcp = tr('Exact FCI'); + else if (has('filetype') && has('records')) fcp = tr('filetype and records'); + else if (has('filetype') && has('size')) fcp = tr('filetype and size'); + else if (has('filetype')) fcp = tr('filetype'); + else if (has('records')) fcp = tr('records'); + else if (has('size')) fcp = tr('size'); + const parts = []; + if (fcp) parts.push(fcp); + if (has('contents')) parts.push(tr('contents')); + return parts.join(', '); +} + +// Compress a list of record numbers into ranges: [1,2,3,5,7] -> "1-3, 5, 7". +function profilerNumRanges(nums) { + if (!nums || !nums.length) return ''; + const sorted = nums.slice().sort((a, b) => a - b); + const parts = []; + let start = null, prev = null; + for (const n of sorted) { + if (start === null) { start = prev = n; continue; } + if (n === prev + 1) { prev = n; continue; } + parts.push(start === prev ? String(start) : start + '-' + prev); + start = prev = n; + } + if (start !== null) parts.push(start === prev ? String(start) : start + '-' + prev); + return parts.join(', '); +} + function profilerRenderReport(results) { let html = ''; for (const r of results) { @@ -7649,13 +7710,21 @@ function profilerRenderReport(results) { const title = name ? '' + esc(name) + ' (' + esc(r.path) + ')' : '' + esc(r.path) + ''; + const aspects = profilerResultAspects(r); + const marked = aspects.some(a => !a.ok); + const summary = profilerAspectSummary(aspects, marked, t); html += '
    '; - html += '
    ' + icon + '' + title + '
    '; + html += '
    ' + icon + '' + title + '' + + (summary ? ': ' + esc(summary) + '' : '') + '
    '; if (r.error) html += '
    ' + esc(r.error) + '
    '; for (const c of r.checks) { if (c.ok) continue; html += '
    ' + esc(c.label) + ': ' + esc(t('expected')) + ' ' + esc(String(c.expected)) + ', ' + esc(t('actual')) + ' ' + esc(String(c.actual)) + '
    '; } + const recFailed = (r.checks || []).some(c => /^content\.rec\d+$/.test(c.label) && !c.ok); + if (recFailed && r.recordsMatched && r.recordsMatched.length) { + html += '
    ' + esc(t('matching records')) + ': ' + esc(profilerNumRanges(r.recordsMatched)) + '
    '; + } html += '
    '; } return html; @@ -7890,6 +7959,13 @@ const LANG_RU = { 'Record length': 'Длина записи', 'Record count': 'Кол-во записей', 'Check contents': 'Проверить содержимое', + 'filetype': 'тип файла', + 'size': 'размер', + 'records': 'записи', + 'contents': 'содержимое', + 'filetype and size': 'тип файла и размер', + 'filetype and records': 'тип файла и записи', + 'matching records': 'совпадающие записи', 'None': 'Нет', 'Exact': 'Точное', 'Mask': 'Маска', diff --git a/frontend/sw.js b/frontend/sw.js index 0da2bea..2f59ea5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v56'; +const CACHE = 'otaman-v57'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index 329fb14..3eeee43 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']; +const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'profilerRenderReport']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; @@ -365,3 +365,93 @@ test('profilerScanCard without onProgress still works (back-compat)', async () = const rules = await profilerScanCard(new Set(), new Set(), 'type_size'); assert.strictEqual(rules.length, 1); }); + +// --- result report (aspects, summary, matching records) --- + +test('profilerNumRanges compresses consecutive record numbers', () => { + assert.strictEqual(profilerNumRanges([]), ''); + assert.strictEqual(profilerNumRanges([1]), '1'); + assert.strictEqual(profilerNumRanges([1, 2, 3, 4, 5, 7, 8, 9, 10]), '1-5, 7-10'); + assert.strictEqual(profilerNumRanges([1, 3, 5]), '1, 3, 5'); + assert.strictEqual(profilerNumRanges([7, 8, 1, 2]), '1-2, 7-8'); + assert.strictEqual(profilerNumRanges([6]), '6'); +}); + +test('profilerResultAspects groups checks and lets Exact FCI subsume type/size', () => { + assert.deepStrictEqual( + profilerResultAspects({ checks: [ + { label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'content', ok: true }, + ] }), + [{ key: 'filetype', ok: true }, { key: 'size', ok: true }, { key: 'contents', ok: true }]); + assert.deepStrictEqual( + profilerResultAspects({ checks: [ + { label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'fci', ok: false }, { label: 'content', ok: true }, + ] }), + [{ key: 'Exact FCI', ok: false }, { key: 'contents', ok: true }]); + assert.deepStrictEqual( + profilerResultAspects({ checks: [ + { label: 'recordLen', ok: true }, { label: 'numRecords', ok: false }, + ] }), + [{ key: 'records', ok: false }]); + assert.deepStrictEqual(profilerResultAspects({ checks: [{ label: 'exists', ok: true }] }), []); +}); + +test('profilerAspectSummary renders plain list when nothing failed', () => { + const tr = s => s; + assert.strictEqual( + profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'size', ok: true }, { key: 'contents', ok: true }], false, tr), + 'filetype and size, contents'); + assert.strictEqual( + profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'records', ok: true }, { key: 'contents', ok: true }], false, tr), + 'filetype and records, contents'); + assert.strictEqual( + profilerAspectSummary([{ key: 'Exact FCI', ok: true }, { key: 'contents', ok: true }], false, tr), + 'Exact FCI, contents'); + assert.strictEqual(profilerAspectSummary([{ key: 'filetype', ok: true }], false, tr), 'filetype'); + assert.strictEqual(profilerAspectSummary([], false, tr), ''); +}); + +test('profilerAspectSummary marks per-aspect when mixed', () => { + const tr = s => s; + assert.strictEqual( + profilerAspectSummary([{ key: 'filetype', ok: true }, { key: 'size', ok: false }, { key: 'contents', ok: true }], true, tr), + 'filetype ✓, size ✗, contents ✓'); + assert.strictEqual( + profilerAspectSummary([{ key: 'Exact FCI', ok: false }, { key: 'contents', ok: true }], true, tr), + 'Exact FCI ✗, contents ✓'); +}); + +test('profilerRunRule records which records matched on a record mismatch', async () => { + mockFetch({ + '/api/select': () => ({ name: 'EF.X', fid: '6F3A', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 3, exists: true }), + '/api/read': () => ({ success: true, records: [{ num: 1, data: 'AA' }, { num: 2, data: 'XX' }, { num: 3, data: 'CC' }] }), + }); + const res = await profilerRunRule({ + path: 'MF/7F20/6F3A', fileType: 'linear_fixed', recordLen: 2, numRecords: 3, fciMode: 'type_size', + content: { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }, { num: 3, data: 'CC' }] }, + }); + assert.strictEqual(res.status, 'fail'); + assert.deepStrictEqual(res.recordsMatched, [1, 3]); +}); + +test('profilerRenderReport includes the checked-aspects summary and matching-record note', () => { + global.t = s => s; + global.pysimCustomFiles = []; + const html = profilerRenderReport([ + { path: 'MF/7F20/6F3F', name: 'EF.GID2', status: 'pass', checks: [ + { label: 'fileType', ok: true }, { label: 'fileSize', ok: true }, { label: 'content', ok: true }, + ] }, + { path: 'MF/7F20/6F3A', name: 'EF.X', status: 'fail', checks: [ + { label: 'fileType', ok: true }, { label: 'fileSize', ok: false, expected: 4, actual: 99 }, { label: 'content', ok: true }, + ] }, + { path: 'MF/7F20/6F4E', name: 'EF.Y', status: 'fail', checks: [ + { label: 'content.rec6', ok: false, expected: 'BB', actual: 'XX' }, + { label: 'content.rec1', ok: true }, { label: 'content.rec2', ok: true }, { label: 'content.rec5', ok: true }, + ], recordsMatched: [1, 2, 5] }, + ]); + assert.ok(html.includes('filetype and size, contents')); + assert.ok(html.includes('filetype ✓, size ✗, contents ✓')); + assert.ok(html.includes('matching records')); + assert.ok(html.includes('1-2, 5')); + delete global.t; +});