profiler: richer check report (what was verified, matched records)
The Check report now states exactly what was verified for each file:
- passing files append a plain summary to the path line
('filetype and size, contents' / 'filetype and records, contents' /
'Exact FCI, contents')
- mixed results mark each aspect inline
('filetype ✓, size ✗, contents ✓'), with the existing red detail lines
for the mismatches; 'Exact FCI' subsumes type/size/records
- record files with a contents mismatch add a 'matching records: 1-5,
7-10' note listing the records that did match
New pure helpers: profilerResultAspects (derives checked aspects from the
result checks), profilerAspectSummary (plain vs marked text),
profilerNumRanges (compresses record numbers into ranges). profilerRunRule
now also returns res.recordsMatched. 6 new tests incl. an end-to-end report
render. RU i18n keys added. Help docs synced. SW cache v56 -> v57.
This commit is contained in:
@@ -379,7 +379,7 @@
|
||||
<li><strong>Атрибуты файла</strong> — тип файла, размер, длина записи и число записей из FCP-шаблона (любой можно оставить незаданным).</li>
|
||||
<li><strong>Содержимое</strong> (опционально) — <strong>Exact</strong> (точное совпадение hex) или <strong>Mask</strong>, где <code class="font-mono text-sm">?</code> — шаблон на один полубайт (маска без <code class="font-mono text-sm">?</code> — совпадение префикса, например <code class="font-mono text-sm">0891</code> для MCC/MNC из IMSI). Для record-файлов хранится список по записям.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения (существование, каждый атрибут FCP и совпадение содержимого).</p>
|
||||
<p class="text-sm mb-3"><strong>Check</strong> выполняет каждое правило на подключённой карте и показывает строку прогресса и отчёт прохождения. Рядом с путём файла указывается, что именно проверялось (например, <em>тип файла и размер, содержимое</em> или <em>точный FCI</em>); если часть проверок прошла, а часть нет — каждый аспект помечается (<em>тип файла ✓, размер ✗, содержимое ✓</em>), а расхождения расписываются ниже. Для record-файлов при расхождении содержимого добавляется пометка <em>совпадающие записи: 1-5, 7-10</em> со списком записей, которые совпали.</p>
|
||||
<h4 class="font-medium mb-1">Опции сканирования «Profile from card»</h4>
|
||||
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>«FCP/FCI check»</strong> (те же три режима, по умолчанию <strong>Filetype + size</strong>), применяемый ко всем создаваемым правилам, а также список <strong>«Ignore contents of»</strong> (все отмечены по умолчанию) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>Custom files</strong> включаются с той же проверкой существования.</p>
|
||||
|
||||
|
||||
+1
-1
@@ -379,7 +379,7 @@
|
||||
<li><strong>File attributes</strong> — file type, size, record length and record count, taken from the FCP template (any may be left unset).</li>
|
||||
<li><strong>Contents</strong> (optional) — <strong>Exact</strong> hex equality, or <strong>Mask</strong> where <code class="font-mono text-sm">?</code> is a per-nibble wildcard (a mask with no <code class="font-mono text-sm">?</code> is a prefix match, e.g. <code class="font-mono text-sm">0891</code> for the IMSI MCC/MNC). Record files store a per-record list.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-3"><strong>Check</strong> 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).</p>
|
||||
<p class="text-sm mb-3"><strong>Check</strong> 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. <em>filetype and size, contents</em> or <em>exact FCI</em>); when some checks pass and others fail, each aspect is marked (<em>filetype ✓, size ✗, contents ✓</em>) with the mismatches detailed below. For record files with a contents mismatch, a <em>matching records: 1-5, 7-10</em> note lists the records that did match.</p>
|
||||
<h4 class="font-medium mb-1">“Profile from card” scan options</h4>
|
||||
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>“FCP/FCI check”</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>“Ignore contents of”</strong> checklist (all checked by default) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>. A progress line shows <em>N / total files</em> 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 <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
|
||||
|
||||
|
||||
+78
-2
@@ -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
|
||||
? '<span>' + esc(name) + '</span> <span class="text-gray-400 dark:text-slate-500">(' + esc(r.path) + ')</span>'
|
||||
: '<span>' + esc(r.path) + '</span>';
|
||||
const aspects = profilerResultAspects(r);
|
||||
const marked = aspects.some(a => !a.ok);
|
||||
const summary = profilerAspectSummary(aspects, marked, t);
|
||||
html += '<div class="mb-2 p-2 border border-gray-200 dark:border-slate-600 rounded">';
|
||||
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span></div>';
|
||||
html += '<div class="flex items-center gap-2"><span class="' + color + ' font-bold">' + icon + '</span><span class="font-mono text-sm">' + title + '</span>' +
|
||||
(summary ? '<span class="text-xs text-gray-500 dark:text-slate-400">: ' + esc(summary) + '</span>' : '') + '</div>';
|
||||
if (r.error) html += '<div class="text-xs text-yellow-600 mt-1">' + esc(r.error) + '</div>';
|
||||
for (const c of r.checks) {
|
||||
if (c.ok) continue;
|
||||
html += '<div class="text-xs text-red-600 mt-1">' + esc(c.label) + ': ' + esc(t('expected')) + ' <b>' + esc(String(c.expected)) + '</b>, ' + esc(t('actual')) + ' <b>' + esc(String(c.actual)) + '</b></div>';
|
||||
}
|
||||
const recFailed = (r.checks || []).some(c => /^content\.rec\d+$/.test(c.label) && !c.ok);
|
||||
if (recFailed && r.recordsMatched && r.recordsMatched.length) {
|
||||
html += '<div class="text-xs text-emerald-600 mt-1">' + esc(t('matching records')) + ': ' + esc(profilerNumRanges(r.recordsMatched)) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
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': 'Маска',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v56';
|
||||
const CACHE = 'otaman-v57';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user