snapshots: measure SELECT / READ commands and show timing stats

Server measures every classified APDU (A4 select, B0 read binary, B2 read
record) from command to response: _collect_apdu_times() enables the tracer
(reattaching it if pySim nulled it) and /api/select + /api/read return
'apdu_times': [{type, ms}]. Collection is safe: handlers hold _CARD_LOCK.

Snapshots store per-file {select_ms, read_ms}, a ms value per record and
snapshot-level stats {select, read_binary, read_record}: {min, max, avg,
count} plus total_ms (wall time of the scan). The snapshot view gets a
summary under the title (files/records counts, scan time, min/avg/max per
command type) and shows select/read per file and read time per record.
Timings are display-only: checks, snapshots comparison and imports ignore
them (old snapshots simply show 'No timing data').

Tests: Python classifier/collection (tests/test_apdu_timing.py) and
frontend stats/accumulator/format/build-file/summary. SW cache v99 ->
v100; help, README and docs/api.md updated.
This commit is contained in:
2026-09-12 14:56:59 +03:00
parent a261675aad
commit 6d30e7095e
11 changed files with 391 additions and 44 deletions
+1 -1
View File
@@ -394,7 +394,7 @@
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <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>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка.</p>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
+1 -1
View File
@@ -394,7 +394,7 @@
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) 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>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> 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 <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name.</p>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
+117 -7
View File
@@ -892,6 +892,7 @@
<span id="snapshot-iccid" class="text-xs font-mono text-gray-500 dark:text-slate-400"></span>
<button onclick="snapshotSaveName()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
</div>
<div id="snapshot-summary" class="mb-3"></div>
<div id="snapshot-files"></div>
</div>
</div>
@@ -7443,8 +7444,12 @@ async function profilerScanStart() {
progEl.textContent = done + ' / ' + total + ' ' + t('files') + (path ? ' — ' + path : '');
};
if (_scanTarget === 'snapshot') {
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot');
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files };
const timing = profilerTimingAccumulator();
const scanStart = performance.now();
const files = await profilerScanCard(new Set(), new Set(), 'exact', onProgress, new Set(), 'snapshot', timing);
const stats = timing.stats();
stats.total_ms = Math.round(performance.now() - scanStart);
const snapshot = { id: profilerNewId(), name: name, created: new Date().toISOString(), iccid: profilerSnapshotIccid(files), files: files, timing: stats };
snapshots.push(snapshot);
snapshotsSave();
profilerScanCancel();
@@ -7476,7 +7481,7 @@ async function profilerScanStart() {
// Phase 1 discovers every file (tree calls only) to know the total; phase 2
// builds a rule per file, reporting progress via the optional onProgress
// callback as onProgress(done, total, path).
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode) {
async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, maskFids, mode, timing) {
const rules = [];
const files = [];
const seen = new Set();
@@ -7531,7 +7536,7 @@ async function profilerScanCard(ignoreFids, ignoreNames, fciMode, onProgress, ma
const entry = files[i];
if (onProgress) onProgress(i + 1, total, entry.path);
const rule = mode === 'snapshot'
? await profilerBuildSnapshotFile(entry.path, entry.child)
? await profilerBuildSnapshotFile(entry.path, entry.child, timing)
: await profilerBuildFileRule(entry.path, entry.child, ignoreFids, ignoreNames, fciMode, maskFids);
if (rule) rules.push(rule);
}
@@ -7583,7 +7588,42 @@ async function profilerBuildFileRule(path, c, ignoreFids, ignoreNames, fciMode,
// Card snapshot entry: metadata + raw FCI + exact contents for every readable
// file (no ignore list, no masking, no FCP/FCI check mode).
async function profilerBuildSnapshotFile(path, c) {
function profilerTimingStats(values) {
const vals = (values || []).filter(v => typeof v === 'number' && isFinite(v));
if (!vals.length) return null;
let min = vals[0], max = vals[0], sum = 0;
for (const v of vals) {
if (v < min) min = v;
if (v > max) max = v;
sum += v;
}
return { min: Math.round(min), max: Math.round(max), avg: Math.round(sum / vals.length * 10) / 10, count: vals.length };
}
function profilerTimingAccumulator() {
const buckets = { select: [], read_binary: [], read_record: [] };
return {
add(type, ms) {
if (buckets[type]) buckets[type].push(ms);
},
stats() {
const out = {};
for (const type in buckets) {
const s = profilerTimingStats(buckets[type]);
if (s) out[type] = s;
}
return out;
},
};
}
function profilerFormatMs(ms) {
if (ms === null || ms === undefined) return 'n/a';
if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s';
return Math.round(ms) + ' ms';
}
async function profilerBuildSnapshotFile(path, c, timing) {
let sel;
try {
sel = await pysimFetch('/api/select', { path: path });
@@ -7599,14 +7639,37 @@ async function profilerBuildSnapshotFile(path, c) {
numRecords: (sel.num_of_rec === null || sel.num_of_rec === undefined) ? null : sel.num_of_rec,
fciHex: sel.fci_hex || null,
content: null,
timing: null,
};
const selectTimes = (sel.apdu_times || []).filter(t => t.type === 'select').map(t => t.ms);
if (selectTimes.length) {
if (timing) selectTimes.forEach(ms => timing.add('select', ms));
file.timing = { select_ms: selectTimes.reduce((a, b) => a + b, 0), read_ms: null };
}
try {
const rd = await pysimFetch('/api/read', { path: path, mode: 'raw' });
if (rd && rd.success) {
const readTimes = rd.apdu_times || [];
const recTimes = readTimes.filter(t => t.type === 'read_record').map(t => t.ms);
const binTimes = readTimes.filter(t => t.type === 'read_binary').map(t => t.ms);
if (rd.records) {
file.content = { kind: 'record', records: rd.records.map(r => ({ num: r.num, data: r.data })) };
file.content = { kind: 'record', records: rd.records.map((r, i) => {
const rec = { num: r.num, data: r.data };
if (recTimes[i] !== undefined) rec.ms = recTimes[i];
return rec;
}) };
if (timing) recTimes.forEach(ms => timing.add('read_record', ms));
if (recTimes.length) {
file.timing = file.timing || { select_ms: null, read_ms: null };
file.timing.read_ms = recTimes.reduce((a, b) => a + b, 0);
}
} else if (rd.data) {
file.content = { kind: 'transparent', data: rd.data };
if (timing) binTimes.forEach(ms => timing.add('read_binary', ms));
if (binTimes.length) {
file.timing = file.timing || { select_ms: null, read_ms: null };
file.timing.read_ms = binTimes.reduce((a, b) => a + b, 0);
}
}
}
} catch (e) {}
@@ -7734,9 +7797,38 @@ function profilerRenderSnapshotData() {
const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return;
document.getElementById('snapshot-iccid').textContent = s.iccid || '';
document.getElementById('snapshot-summary').innerHTML = profilerRenderSnapshotSummary(s);
document.getElementById('snapshot-files').innerHTML = profilerRenderSnapshotFiles(s);
}
function profilerRenderSnapshotSummary(s) {
const files = (s.files || []).length;
let records = 0;
for (const f of (s.files || [])) {
if (f.content && f.content.kind === 'record' && f.content.records) records += f.content.records.length;
}
let html = '<div class="text-xs text-gray-500 dark:text-slate-400">' +
esc(t('Files')) + ': <b>' + files + '</b> · ' + esc(t('Records')) + ': <b>' + records + '</b>';
const timing = s.timing;
if (timing && timing.total_ms !== undefined) {
html += ' · ' + esc(t('Scan time')) + ': <b>' + esc(profilerFormatMs(timing.total_ms)) + '</b>';
}
html += '</div>';
const labels = [['select', 'Select'], ['read_binary', 'Read binary'], ['read_record', 'Read record']];
let rows = '';
if (timing) {
for (const [key, label] of labels) {
const st = timing[key];
if (!st) continue;
rows += '<div>' + esc(t(label)) + ': ' + esc(t('min')) + ' ' + st.min + ' ms · ' + esc(t('avg')) + ' ' + st.avg + ' ms · ' + esc(t('max')) + ' ' + st.max + ' ms (n=' + st.count + ')</div>';
}
}
html += rows
? '<div class="text-xs font-mono text-gray-600 dark:text-slate-400 mt-0.5">' + rows + '</div>'
: '<div class="text-xs text-gray-400 dark:text-slate-500 mt-0.5">' + esc(t('No timing data')) + '</div>';
return html;
}
function snapshotSaveName() {
const s = snapshots.find(x => x.id === snapshotViewId);
if (!s) return;
@@ -7763,6 +7855,15 @@ function profilerRenderSnapshotFiles(s) {
if (f.recordLen !== null && f.recordLen !== undefined) attrs.push(esc(t('Record length')) + ': ' + esc(String(f.recordLen)));
if (f.numRecords !== null && f.numRecords !== undefined) attrs.push(esc(t('Record count')) + ': ' + esc(String(f.numRecords)));
if (attrs.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + attrs.join(' · ') + '</div>';
if (f.timing) {
const parts = [];
if (f.timing.select_ms !== null && f.timing.select_ms !== undefined) parts.push(esc(t('Select')) + ': ' + esc(profilerFormatMs(f.timing.select_ms)));
if (f.timing.read_ms !== null && f.timing.read_ms !== undefined) {
const readKey = profilerContentKindForFileType(f.fileType) === 'record' ? 'Read record' : 'Read binary';
parts.push(esc(t(readKey)) + ': ' + esc(profilerFormatMs(f.timing.read_ms)));
}
if (parts.length) html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' + parts.join(' · ') + '</div>';
}
if (f.fciHex) {
html += '<div class="mt-1 flex gap-3 items-start">';
html += '<div class="flex-1 min-w-0"><div class="text-xs text-gray-500 dark:text-slate-400 mb-0.5">' + esc(t('FCI hex (raw SELECT response)')) + '</div>' +
@@ -7778,7 +7879,9 @@ function profilerRenderSnapshotFiles(s) {
html += '<div class="space-y-0.5">';
for (const rec of (f.content.records || [])) {
html += '<div class="flex items-center gap-2"><span class="text-xs text-gray-500 w-8 shrink-0">' + rec.num + '</span>' +
'<input readonly value="' + escHtml(rec.data) + '" class="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"></div>';
'<input readonly value="' + escHtml(rec.data) + '" class="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">' +
(rec.ms !== undefined ? '<span class="text-xs text-gray-400 w-16 text-right shrink-0">' + esc(profilerFormatMs(rec.ms)) + '</span>' : '') +
'</div>';
}
html += '</div>';
} else {
@@ -8845,6 +8948,13 @@ const LANG_RU = {
'Verify vs pySim': 'Проверить в pySim',
'Send to Card': 'Отправить на карту',
'Phone simulator': 'Симулятор телефона',
'Files': 'Файлов',
'Records': 'Записей',
'Scan time': 'Время сканирования',
'No timing data': 'Нет данных о времени',
'min': 'мин',
'avg': 'сред',
'max': 'макс',
'Phone': 'Телефон',
'TR Config': 'Конфигурация TR',
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v99';
const CACHE = 'otaman-v100';
const URLS = [
'index.html',
'help.html',
+4
View File
@@ -37,3 +37,7 @@ test('phone simulator has Phone / TR Config pills', () => {
test('scan name input starts scanning on Enter', () => {
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
});
test('snapshot view has a timing summary block', () => {
assert.ok(html.includes('id="snapshot-summary"'));
});
+87 -1
View File
@@ -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', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown'];
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -1130,3 +1130,89 @@ test('profilerScanNameKeydown starts the scan on Enter only', () => {
delete global.document;
delete global.profilerScanStart;
});
// --- snapshot command timings ---
test('profilerTimingStats computes min, max and average', () => {
assert.deepStrictEqual(profilerTimingStats([10, 20, 30]), { min: 10, max: 30, avg: 20, count: 3 });
assert.deepStrictEqual(profilerTimingStats([7, 7.5, 8]), { min: 7, max: 8, avg: 7.5, count: 3 });
assert.strictEqual(profilerTimingStats([]), null);
assert.strictEqual(profilerTimingStats(null), null);
});
test('profilerFormatMs formats milliseconds and seconds', () => {
assert.strictEqual(profilerFormatMs(12), '12 ms');
assert.strictEqual(profilerFormatMs(999), '999 ms');
assert.strictEqual(profilerFormatMs(1500), '1.50 s');
assert.strictEqual(profilerFormatMs(null), 'n/a');
});
test('profilerTimingAccumulator aggregates per command type', () => {
const acc = profilerTimingAccumulator();
acc.add('select', 10);
acc.add('select', 20);
acc.add('read_record', 5);
acc.add('bogus', 1);
assert.deepStrictEqual(acc.stats(), {
select: { min: 10, max: 20, avg: 15, count: 2 },
read_record: { min: 5, max: 5, avg: 5, count: 1 },
});
});
test('profilerBuildSnapshotFile records per-command timings', async () => {
const acc = profilerTimingAccumulator();
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2,
apdu_times: [{ type: 'select', ms: 5 }, { type: 'select', ms: 7 }] }),
'/api/read': () => ({ success: true, file_type: 'linear_fixed', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }],
apdu_times: [{ type: 'select', ms: 4 }, { type: 'read_record', ms: 11 }, { type: 'read_record', ms: 13 }] }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, acc);
assert.deepStrictEqual(file.timing, { select_ms: 12, read_ms: 24 });
assert.strictEqual(file.content.records[0].ms, 11);
assert.strictEqual(file.content.records[1].ms, 13);
const stats = acc.stats();
assert.strictEqual(stats.select.count, 2);
assert.strictEqual(stats.read_record.count, 2);
});
test('profilerBuildSnapshotFile without apdu_times has empty timing', async () => {
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'transparent', file_size: 2, record_len: null, num_of_rec: null }),
'/api/read': () => ({ success: true, file_type: 'transparent', data: 'AABB' }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, null);
assert.strictEqual(file.timing, null);
assert.deepStrictEqual(file.content, { kind: 'transparent', data: 'AABB' });
});
test('profilerRenderSnapshotSummary shows counts, scan time and stats', () => {
global.t = s => s;
const snap = {
files: [
{ content: { kind: 'record', records: [{ num: 1, data: 'AA', ms: 5 }, { num: 2, data: 'BB', ms: 7 }] } },
{ content: { kind: 'transparent', data: 'AA' } },
],
timing: {
select: { min: 3, max: 9, avg: 6, count: 3 },
read_record: { min: 5, max: 7, avg: 6, count: 2 },
total_ms: 1500,
},
};
const html = profilerRenderSnapshotSummary(snap);
assert.ok(html.includes('Files: <b>2</b>'), html);
assert.ok(html.includes('Records: <b>2</b>'), html);
assert.ok(html.includes('Scan time: <b>1.50 s</b>'), html);
assert.ok(html.includes('Select: min 3 ms'), html);
assert.ok(html.includes('Read record: min 5 ms'), html);
assert.ok(!html.includes('Read binary'), html);
delete global.t;
});
test('profilerRenderSnapshotSummary notes missing timing data', () => {
global.t = s => s;
const html = profilerRenderSnapshotSummary({ files: [], timing: undefined });
assert.ok(html.includes('Files: <b>0</b>'), html);
assert.ok(html.includes('No timing data'), html);
delete global.t;
});