- the single-command <select> becomes a checkbox dropdown: the filter is a
set of entries (an empty set shows everything), so any number of commands
can be shown at once instead of one
- three fixed presets select whole conversations:
* Proactive sessions — FETCH, TERMINAL PROFILE and every command the card
answered with SW 91xx (the proactive-session initiators)
* Proactive + events — the same plus ENVELOPE
* File operations — SELECT, READ BINARY, READ RECORD, UPDATE BINARY,
UPDATE RECORD (their GET RESPONSE, which carries the data/FCP, comes
along through the existing response_for linkage)
presets and single commands combine as a union, and the menu lists the
session's commands with their counts
- the button summarises the selection ("All commands" / one name /
"{n} commands"); Escape or a click outside closes the menu; focusing an
APDU from the Files panel/map still clears the filter first
- tests: a new filter test (the matching rules per entry kind, the GR
linkage, the presets, the union, the summary, the menu rows, the RU keys
and the wiring pins), and the focusMessage tests follow the new state;
527 Python + 72 Node tests
- README/README_RUS describe the filter; PWA_VERSION/pyproject/version.py
2.7.0, SW cache simtrace-analyser-v80
156 lines
6.0 KiB
JavaScript
156 lines
6.0 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 renderFilterMenu() {} // the real one needs the menu DOM
|
|
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(extractFunc(src, 'clearFilter') + '\n' +
|
|
'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,
|
|
filter: new Set() };
|
|
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,
|
|
filter: new Set(['cmd:SELECT']) };
|
|
el = () => ({ value: 'x', querySelector: () => entry });
|
|
showDetail = () => {};
|
|
realFocusMessage(3);
|
|
assert.deepEqual(renders, [[true, undefined]]);
|
|
assert.equal(STATE.selectedIdx, 3);
|
|
// The stale filter is gone, so the focused record is visible.
|
|
assert.equal(STATE.filter.size, 0);
|
|
});
|
|
|
|
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;/);
|
|
});
|