ui: generate a profile from a card snapshot

- new 'Profile from snapshot' button in the Profiles toolbar: reuses the
  snapshot picker (profilerSnapshotPickListHtml refactor) and then opens
  the same scan form as Profile from card (profilerScanOpenForm), with
  the profile name prefilled from the snapshot
- new profiler-build target 'profile-snapshot': the form's ignore list,
  mask options and FCP/FCI mode apply to profilerScanSnapshot(), which
  walks the snapshot's captured files instead of the card; uncaptured
  contents produce rules without a content check, record files map to
  exact record content, transparent files honor the masks
- profilerScanStart() pushes the generated profile and opens the editor
  like the live scan; _scanSnapshot is cleared in the finally block
- RU entry 'Профиль из снимка'; tests for the snapshot rule builder
  (exact/mask/ignore/uncaptured/record), the snapshot scan progress, the
  picker wiring and the form title, plus a structural button check;
  help EN/RU and READMEs updated; SW cache v130 -> v131.
This commit is contained in:
2026-09-13 11:32:18 +03:00
parent bf8abbb0fd
commit 2213e25385
8 changed files with 191 additions and 21 deletions
+79 -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', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView'];
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', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -31,6 +31,7 @@ code += extractFunc(html, 'profilerBuildSnapshotFile', true) + '\n';
code += extractFunc(html, 'profilerCardIccid', true) + '\n';
code += extractFunc(html, 'profilerCheck', true) + '\n';
code += extractFunc(html, 'profilerCheckSnapshot', true) + '\n';
code += extractFunc(html, 'profilerScanSnapshot', true) + '\n';
code += "var _scanTarget = 'profile';\nvar profilerResults = null;\nvar profilerResultsHeader = null;\nvar profilerMismatchOnly = false;\n";
code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n';
eval(code);
@@ -1402,3 +1403,80 @@ test('profilerRenderResultsView writes the built header into the title', () => {
profilerResultsHeader = null;
delete global.t;
});
test('profilerBuildFileRuleFromSnapshot builds a rule with exact contents', () => {
const f = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'transparent', fileSize: 4, recordLen: null, numRecords: null, fciHex: '620B', content: { kind: 'transparent', data: 'AABBCCDD' } };
const rule = profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type_size', new Set());
assert.strictEqual(rule.path, 'MF/7F10/6F3A');
assert.strictEqual(rule.name, 'EF.ADN');
assert.strictEqual(rule.fileType, 'transparent');
assert.strictEqual(rule.fileSize, 4);
assert.strictEqual(rule.fciMode, 'type_size');
assert.strictEqual(rule.fciHex, '620B');
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'transparent', expected: 'AABBCCDD' });
});
test('profilerBuildFileRuleFromSnapshot masks the first 4 bytes on request', () => {
const f = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: '0891101234567890' } };
assert.deepStrictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set(['6F07'])).content,
{ mode: 'mask', kind: 'transparent', expected: '08911012????????' });
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(), 'type', new Set()).content.mode, 'exact');
});
test('profilerBuildFileRuleFromSnapshot honors the ignore list by FID and name', () => {
const f = { path: 'MF/7F20/6F52', name: 'EF.KcGPRS', fileType: 'transparent', fileSize: 9, content: { kind: 'transparent', data: 'AABB' } };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(['6F52']), new Set(), 'type_size', new Set()).content, null);
assert.strictEqual(profilerBuildFileRuleFromSnapshot(f, new Set(), new Set(['EF.KCGPRS']), 'type_size', new Set()).content, null);
});
test('profilerBuildFileRuleFromSnapshot keeps uncaptured and record contents', () => {
const noContent = { path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', fileSize: 9, content: null };
assert.strictEqual(profilerBuildFileRuleFromSnapshot(noContent, new Set(), new Set(), 'type_size', new Set()).content, null);
const records = { path: 'MF/7F10/6F3A', name: 'EF.ADN', fileType: 'linear_fixed', fileSize: 60, recordLen: 30, numRecords: 2, content: { kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] } };
const rule = profilerBuildFileRuleFromSnapshot(records, new Set(), new Set(), 'exact', new Set());
assert.strictEqual(rule.fileSize, null);
assert.strictEqual(rule.recordLen, 30);
assert.strictEqual(rule.numRecords, 2);
assert.deepStrictEqual(rule.content, { mode: 'exact', kind: 'record', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }] });
});
test('profilerScanSnapshot walks snapshot files with progress', async () => {
const snapshot = { files: [
{ path: 'MF/6F07', name: 'EF.IMSI', fileType: 'transparent', content: null },
{ path: 'MF/2FE2', name: 'EF.ICCID', fileType: 'transparent', content: { kind: 'transparent', data: '9807' } },
] };
const progress = [];
const rules = await profilerScanSnapshot(snapshot, new Set(), new Set(), 'type_size', new Set(), (d, tt, p) => progress.push([d, tt, p]));
assert.strictEqual(rules.length, 2);
assert.deepStrictEqual(rules.map(r => r.path), ['MF/6F07', 'MF/2FE2']);
assert.deepStrictEqual(progress, [[0, 2, ''], [1, 2, 'MF/6F07'], [2, 2, 'MF/2FE2']]);
});
test('profilerSnapshotPickListHtml wires the chosen action per snapshot', () => {
global.t = s => s;
global.snapshots = [
{ name: 'SnapA', iccid: '8901', created: '2026-01-01T00:00:00Z', files: [{}, {}] },
{ name: 'SnapB', iccid: '', created: '2026-01-02T00:00:00Z', files: [] },
];
const html = profilerSnapshotPickListHtml(si => 'profilerScanFromSnapshot(' + si + ')');
assert.ok(html.includes('onclick="profilerScanFromSnapshot(0)"'), html);
assert.ok(html.includes('onclick="profilerScanFromSnapshot(1)"'), html);
assert.ok(html.includes('SnapA'), html);
assert.ok(html.includes('2 files'), html);
delete global.snapshots;
delete global.t;
});
test('profilerScanSetTarget labels the snapshot-sourced profile form', () => {
global.t = s => s;
const els = {};
for (const id of ['profiler-scan-title', 'profiler-scan-name-label', 'profiler-scan-options']) {
els[id] = { textContent: '', attrs: {}, classList: { set: new Set(), toggle(c, on) { if (on) this.set.add(c); else this.set.delete(c); }, contains(c) { return this.set.has(c); } }, setAttribute(k, v) { this.attrs[k] = v; } };
}
global.document = { getElementById: id => els[id] || null };
profilerScanSetTarget('profile-snapshot');
assert.strictEqual(els['profiler-scan-title'].textContent, 'Profile from snapshot');
assert.strictEqual(els['profiler-scan-name-label'].textContent, 'Profile name');
assert.ok(!els['profiler-scan-options'].classList.contains('hidden'));
delete global.t;
});