Files view: per-file tracking (counters, image, device/card changes)

- new `simtrace_analyser/filetrack.py`: a single-pass tracker over a
  session's decoded messages, keyed by (DF, FID) and trace-wide (resets only
  wipe the per-channel selection): counters (`select`/`read`/`update`,
  successful operations only), a sparse image of the observed bytes
  (transparent files by offset, record files per record number) and the
  change log
- changes: **device** = an UPDATE seen in the trace (applied to the image,
  `no_change` flags a provable no-op); **card** = a READ whose bytes differ
  from the image on bytes already known (logged at the surfacing read with
  the changed ranges and the before/after hex; never-seen bytes just fill
  the image, they are an observation, not a change)
- SFI-addressed accesses join the same entry as path/FID ones: the SFI ↔ FID
  relations come from the normative tables and from every surfaced FCP `88`
  DO (including the spec's "SFI = 5 LSBs of the FID" rule when the DO is
  absent); an SFI used before its relation is learned stays provisional and
  merges into the resolved entry
- the DF identity follows TS 102 221 §8.4/Table 8.2: `selected_df_fid()` now
  resolves an EF path selection to its parent DF and returns `aid:<hex>` for
  applications selected by an unknown AID (named from EF.DIR when the trace
  surfaced it); `Database._decode_rows` gained an `on_message` hook and
  `track_files()`
- `GET /api/sessions/<id>/files`: the operation timeline, the per-file
  counters/coverage/change log, the SFI relations and the AID labels
- PWA: the side panel's Map/Stats tabs became a view selector (Map / Stats /
  Files); the Files view is a timeline of Read / Update / Changed (card) rows
  (colour-coded, the same file names as the Map view), a row click jumps to
  the APDU (loading the pages it needs) and a card-change row's Δ opens the
  changed ranges with the previous and the new bytes; EN/RU
- bounds: the image freezes past 64 KB of observed bytes per file, the change
  log keeps the last 1000 events per file (counters keep counting)
- tests: tests/test_filetrack.py (counters, resets, channels, image/diff
  semantics, record files, SFI resolution and the provisional merge, EF.DIR
  labels, caps, the endpoint) and Node tests for the Files-view helpers and
  wiring
- v2.3.0; MIN_SERVER_VERSION 2.3.0; SW cache simtrace-analyser-v63
This commit is contained in:
catarrh committed 2026-10-17 20:51:33 +03:00
1 parent b8d9c4cc63
commit c494bf4111
14 files changed
+1302 -36

No files matched your search

+63 -1
View File
@@ -124,7 +124,7 @@ test('the COMPRION import is a two-slot dialog', () => {
assert.match(src, /time_base === 'application-layer'/);
assert.match(src, /\$on\('import-comprion-btn', 'click', openComprionDialog\)/);
assert.match(src, /\$on\('comprion-cancel', 'click', closeComprionDialog\)/);
assert.match(src, /e\.key === 'Escape'/);
assert.match(src, /e\.key !== 'Escape'/);
});
test('comprionSlotCheck: slot/kind outcomes', () => {
@@ -275,3 +275,65 @@ test('COMPRION dialog: success closes, server errors stay', async () => {
closeComprionDialog();
});
// ── Files view (per-file operation timeline) ───────────────────────────
eval(
'function t(x) { return x; }\n' +
extractFunc(src, 'fileOpClass') + '\n' +
extractFunc(src, 'fileOpVerb') + '\n' +
extractFunc(src, 'fileOpLabel') + '\n' +
extractFunc(src, 'fileDiffRows')
);
test('Files view: operation kinds map to classes and verbs', () => {
assert.equal(fileOpClass('read'), 'file-op-read');
assert.equal(fileOpClass('update'), 'file-op-update');
assert.equal(fileOpClass('card_change'), 'file-op-card');
assert.equal(fileOpVerb('read'), 'Read');
assert.equal(fileOpVerb('update'), 'Update');
assert.equal(fileOpVerb('card_change'), 'Changed (card)');
assert.equal(fileOpLabel({kind: 'read', file: {name: 'EF_ICCID'}}), 'Read EF_ICCID');
assert.equal(fileOpLabel({kind: 'card_change', file: {name: 'EF_LOCI'}}),
'Changed (card) EF_LOCI');
assert.equal(fileOpLabel({kind: 'read', file: null}), 'Read ?');
});
test('Files view: the diff rows slice the concatenated hex', () => {
const change = {
ranges: [[8, 2], [16, 1]],
before_hex: '95f9aa',
after_hex: '000001',
};
assert.deepEqual(fileDiffRows(change), [
{pos: 8, len: 2, before: '95f9', after: '0000'},
{pos: 16, len: 1, before: 'aa', after: '01'},
]);
assert.deepEqual(fileDiffRows({ranges: [], before_hex: '', after_hex: ''}), []);
});
test('Files view: side-panel dropdown and the timeline are wired', () => {
assert.match(src, /<select class="side-view-select" id="side-view-select"[^>]*>/);
assert.match(src, /<option value="files" data-l10n="Files">/);
assert.doesNotMatch(src, /class="side-tab/);
assert.match(src, /id="file-view"/);
assert.match(src, /id="file-diff-modal"/);
assert.match(src, /id="file-diff-body"/);
assert.match(src, /\$on\('side-view-select', 'change'/);
assert.match(src, /\$on\('file-view', 'click'/);
assert.match(src, /API\.getSessionFiles\(STATE\.sessionViewId\)/);
assert.match(src, /if \(row\) jumpToMessage\(Number\(row\.dataset\.idx\)\);/);
assert.match(src, /getSessionFiles\(id\)\s*\{ return this\._fetch\('GET', `\/api\/sessions\/\$\{id\}\/files`\); \}/);
});
test('Files view: strings carry Russian translations', () => {
const lang = extractConst(src, 'LANG_RU');
const keys = new Set();
for (const m of lang.matchAll(/'((?:[^'\\]|\\.)*)'\s*:/g)) {
keys.add(m[1].replace(/\\(['\\])/g, '$1'));
}
for (const s of ['Files', 'Read', 'Update', 'Changed (card)', 'Card-side change',
'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change',
'No file operations recorded', 'Failed to load files', 'Loading…']) {
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
}
});