Files
simtrace-analyser/frontend/tests/files_jump.test.js
T
catarrh 76c5647dd4 Files view: the jumped-to record stays selected (v2.6.2)
- focusMessage()/scrollToType() highlighted the target and removed the class
  after 1500 ms, so a Files-row (or map/category) jump looked selected for a
  moment and then lost it; on top of that any re-render (the 500 ms live
  poll, a filter/search change) rebuilt the list and dropped the highlight —
  even for a clicked entry
- the selection is now persistent app state (STATE.selectedIdx): the render
  marks the entry (gap rows included), focusMessage records the index before
  rendering, showDetail records it for plain clicks, scrollToType persists it
  too (clearing the previous highlight), and a new session/capture resets it
  so a stale index cannot mark an unrelated APDU
- tests: a stub-driven focusMessage case (state + detail updated, no render
  and no timer), the filter-clearing case, and source pins for
  renderMessages, focusMessage, scrollToType, showDetail, loadSession and
  startCapture; 527 Python + 58 Node tests
- README/README_RUS: the jump leaves the record selected; PWA_VERSION/
  pyproject/version.py 2.6.2, SW cache simtrace-analyser-v76
2026-10-17 23:16:33 +03:00

150 lines
5.8 KiB
JavaScript

// The Files-view row jump and the APDU list's content-follow decision:
// clicking a timeline row must scroll straight to its APDU — it used to
// render the list with the stick-to-bottom (jump to the end of the trace)
// and then smooth-scroll back up to the record.
const { test } = require('node:test');
const assert = require('node:assert');
const { loadSrc, extractFunc } = require('./helpers.js');
const src = loadSrc();
let STATE = {};
let API = {};
let renders = [];
let focused = [];
let el = () => ({});
let showDetail = () => {};
function renderMessages(skipScroll, forceStick) {
renders.push([skipScroll, forceStick]);
}
function focusMessage(idx) {
focused.push(idx);
}
eval(
extractFunc(src, 'jumpToMessage') + '\n' +
extractFunc(src, 'shouldStickToBottom') + '\n'
);
// The real focusMessage, under an alias: the stub above stays in place for
// the jumpToMessage tests.
eval('var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';');
test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
renders = [];
focused = [];
STATE = { sessionViewId: 7, messages: [{}, {}] };
API = {
getSession: async (sid, from) => ({
messages: from === 2 ? [{}, {}, {}] : [],
}),
};
await jumpToMessage(3);
assert.equal(STATE.messages.length, 5);
assert.deepEqual(renders, [[true, undefined]]);
assert.deepEqual(focused, [3]);
});
test('jumpToMessage no-ops without a session view', async () => {
renders = [];
focused = [];
STATE = { sessionViewId: null, messages: [] };
API = { getSession: async () => { throw new Error('must not fetch'); } };
await jumpToMessage(3);
assert.deepEqual(renders, []);
assert.deepEqual(focused, []);
});
test('shouldStickToBottom: only a reader at the bottom follows new content', () => {
const list = (scrollTop, clientHeight, scrollHeight) => ({ scrollTop, clientHeight, scrollHeight });
// At the bottom / within the slack → follow.
assert.equal(shouldStickToBottom(list(500, 100, 600), false, false), true);
assert.equal(shouldStickToBottom(list(560, 100, 600), false, false), true);
// Scrolled up (e.g. after a Files/map jump) → leave the view alone.
assert.equal(shouldStickToBottom(list(200, 100, 600), false, false), false);
// An explicit jump never sticks; a new session/capture always does.
assert.equal(shouldStickToBottom(list(200, 100, 600), true, false), false);
assert.equal(shouldStickToBottom(list(200, 100, 600), false, true), true);
// First render: the empty list has no geometry yet.
assert.equal(shouldStickToBottom(list(0, 0, 0), false, false), true);
});
test('renderMessages scrolls through shouldStickToBottom', () => {
const body = extractFunc(src, 'renderMessages');
assert.match(body, /function renderMessages\(skipScroll, forceStick\)/);
assert.match(body, /const stick = shouldStickToBottom\(list, skipScroll, forceStick\);/);
assert.match(body, /if \(stick\) list\.scrollTop = list\.scrollHeight;/);
assert.doesNotMatch(body, /if \(!skipScroll\) list\.scrollTop/);
});
test('jumpToMessage renders with skipScroll (source pin)', () => {
const body = extractFunc(src, 'jumpToMessage');
assert.match(body, /renderMessages\(true\);/);
assert.match(body, /focusMessage\(idx\);/);
});
test('a new session/capture lands at the bottom (source pins)', () => {
assert.match(extractFunc(src, 'loadSession'), /renderMessages\(false, true\);/);
assert.match(extractFunc(src, 'startCapture'), /renderMessages\(false, true\);/);
});
// ── the focused record stays selected ──────────────────────────────────
test('focusMessage marks the record persistently and updates the detail', () => {
renders = [];
const classes = new Set();
const entry = {
dataset: { idx: '1' },
classList: { add: c => classes.add(c), remove: c => classes.delete(c) },
scrollIntoView: () => {},
};
STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 };
el = () => ({ value: '', querySelector: () => entry });
let detailed = null;
showDetail = (msg, e) => { detailed = [msg, e]; };
realFocusMessage(1);
assert.equal(STATE.selectedIdx, 1);
assert.deepEqual(detailed, [{ id: 'b' }, entry]);
// No render is needed when no filter is active — and no flash timeout
// is scheduled: the highlight is owned by showDetail/the state.
assert.deepEqual(renders, []);
});
test('focusMessage clears a filter before focusing', () => {
renders = [];
const entry = {
dataset: { idx: '3' },
classList: { add: () => {}, remove: () => {} },
scrollIntoView: () => {},
};
STATE = { messages: {}, selectedIdx: null };
el = () => ({ value: 'x', querySelector: () => entry });
showDetail = () => {};
realFocusMessage(3);
assert.deepEqual(renders, [[true, undefined]]);
assert.equal(STATE.selectedIdx, 3);
});
test('the selection persists across renders (source pins)', () => {
const render = extractFunc(src, 'renderMessages');
assert.match(render, /const selectedIdx = STATE\.selectedIdx;/);
assert.match(render, /idx === selectedIdx \? ' selected' : ''/);
assert.match(render, /apdu-entry apdu-gap\$\{selCls\}/);
assert.match(render, /apdu-entry\$\{pairCls\}\$\{selCls\}/);
const focus = extractFunc(src, 'focusMessage');
assert.match(focus, /STATE\.selectedIdx = idx;/);
assert.doesNotMatch(focus, /setTimeout/);
const type = extractFunc(src, 'scrollToType');
assert.match(type, /STATE\.selectedIdx = parseInt\(targetEl\.dataset\.idx\);/);
assert.doesNotMatch(type, /setTimeout/);
const detail = extractFunc(src, 'showDetail');
assert.match(detail, /entry\.classList\.add\('selected'\)/);
assert.match(detail, /STATE\.selectedIdx = idx;/);
// A new session/capture must not keep a stale index selected.
assert.match(extractFunc(src, 'loadSession'), /STATE\.selectedIdx = null;/);
assert.match(extractFunc(src, 'startCapture'), /STATE\.selectedIdx = null;/);
});