Command filter: multi-select with conversation presets (v2.7.0)

- 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
This commit is contained in:
catarrh committed 2026-10-17 23:55:28 +03:00
1 parent 009fcf2253
commit e0134c769a
9 files changed
+362 -38

No files matched your search

+9 -3
View File
@@ -15,6 +15,7 @@ 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]);
}
@@ -27,7 +28,8 @@ eval(
);
// The real focusMessage, under an alias: the stub above stays in place for
// the jumpToMessage tests.
eval('var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';');
eval(extractFunc(src, 'clearFilter') + '\n' +
'var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';');
test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
renders = [];
@@ -97,7 +99,8 @@ test('focusMessage marks the record persistently and updates the detail', () =>
classList: { add: c => classes.add(c), remove: c => classes.delete(c) },
scrollIntoView: () => {},
};
STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 };
STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0,
filter: new Set() };
el = () => ({ value: '', querySelector: () => entry });
let detailed = null;
showDetail = (msg, e) => { detailed = [msg, e]; };
@@ -116,12 +119,15 @@ test('focusMessage clears a filter before focusing', () => {
classList: { add: () => {}, remove: () => {} },
scrollIntoView: () => {},
};
STATE = { messages: {}, selectedIdx: null };
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)', () => {
+174
View File
@@ -0,0 +1,174 @@
// The trace view's command filter: a set of entries — single command labels
// and conversation presets — with an empty set meaning "all commands".
const { test } = require('node:test');
const assert = require('node:assert');
const { loadSrc, extractConst, extractFunc } = require('./helpers.js');
const src = loadSrc();
let STATE = {};
const presetsSrc = src.match(/const FILTER_PRESETS = \[[\s\S]*?\n\];/);
assert.ok(presetsSrc, 'FILTER_PRESETS not found');
eval(
presetsSrc[0].replace(/^const /m, 'var ') + '\n' +
extractFunc(src, 'filterPreset') + '\n' +
extractFunc(src, '_sw1') + '\n' +
extractFunc(src, 'filter_label') + '\n' +
extractFunc(src, '_ins_hex') + '\n' +
extractFunc(src, 'matchesFilter') + '\n' +
extractFunc(src, 'isOriginalCommand') + '\n' +
extractFunc(src, 'filterEntryName') + '\n' +
extractFunc(src, 'filterSummary') + '\n' +
extractFunc(src, 'escapeHtml') + '\n' +
'var _filterMenuSig = null;\n' +
extractFunc(src, 'renderFilterMenu') + '\n' +
'function t(x) { return x; }\n'
);
let el = () => ({});
const INS = {
SELECT: 'a4', 'READ BINARY': 'b0', 'READ RECORD': 'b2',
'UPDATE BINARY': 'd6', 'UPDATE RECORD': 'dc',
FETCH: '12', 'TERMINAL PROFILE': '10', ENVELOPE: 'c2',
'GET RESPONSE': 'c0',
};
function msg(name, opts = {}) {
return {
type: opts.type || 'tpdu',
data: '',
decoded: {
ins_name: name,
ins_hex: INS[name] || '00',
sw: { sw1: opts.sw1 || '90', sw2: opts.sw2 || '00' },
response_for: opts.response_for || null,
},
};
}
test('an empty filter shows every message', () => {
STATE = { messages: [] };
assert.equal(matchesFilter(msg('SELECT'), 0, new Set()), true);
assert.equal(matchesFilter(msg('SELECT'), 0, null), true);
});
test('a single command label filters to that command', () => {
const set = new Set(['cmd:SELECT']);
assert.equal(matchesFilter(msg('SELECT'), 0, set), true);
assert.equal(matchesFilter(msg('FETCH'), 0, set), false);
});
test('the GET RESPONSE linkage follows the selected commands', () => {
const cmd = msg('SELECT');
const gr = msg('GET RESPONSE', { response_for: 'SELECT' });
STATE = { messages: [cmd, gr] };
// Selecting the command brings its GET RESPONSE along …
assert.equal(matchesFilter(gr, 1, new Set(['cmd:SELECT'])), true);
// … and selecting GET RESPONSE keeps the commands it belongs to.
assert.equal(matchesFilter(gr, 1, new Set(['cmd:GET RESPONSE'])), true);
assert.equal(matchesFilter(cmd, 0, new Set(['cmd:GET RESPONSE'])), true);
assert.equal(matchesFilter(msg('SELECT'), 0, new Set(['cmd:READ BINARY'])), false);
});
test('the proactive preset: FETCH, TERMINAL PROFILE and SW 91xx initiators', () => {
const set = new Set(['preset:proactive']);
assert.equal(matchesFilter(msg('FETCH'), 0, set), true);
assert.equal(matchesFilter(msg('TERMINAL RESPONSE'), 0, set), true);
assert.equal(matchesFilter(msg('TERMINAL PROFILE'), 0, set), true);
assert.equal(matchesFilter(msg('ENVELOPE', { sw1: '91', sw2: '30' }), 0, set), true);
assert.equal(matchesFilter(msg('UPDATE BINARY', { sw1: '91', sw2: '00' }), 0, set), true);
// Novelties: a plain ENVELOPE (90 00) and unrelated commands stay out.
assert.equal(matchesFilter(msg('ENVELOPE'), 0, set), false);
assert.equal(matchesFilter(msg('SELECT'), 0, set), false);
});
test('the proactive+events preset adds ENVELOPE', () => {
const set = new Set(['preset:proactive-events']);
assert.equal(matchesFilter(msg('ENVELOPE'), 0, set), true);
assert.equal(matchesFilter(msg('FETCH'), 0, set), true);
assert.equal(matchesFilter(msg('SELECT'), 0, set), false);
});
test('the file-operations preset covers the five commands and their replies', () => {
const set = new Set(['preset:files']);
for (const name of ['SELECT', 'READ BINARY', 'READ RECORD', 'UPDATE BINARY',
'UPDATE RECORD']) {
assert.equal(matchesFilter(msg(name), 0, set), true, name);
}
assert.equal(matchesFilter(msg('FETCH'), 0, set), false);
// The GET RESPONSE carrying a SELECT's FCP belongs to the conversation.
assert.equal(matchesFilter(msg('GET RESPONSE', { response_for: 'SELECT' }), 0, set),
true);
assert.equal(matchesFilter(msg('GET RESPONSE', { response_for: 'FETCH' }), 0, set),
false);
});
test('entries combine as a union', () => {
const set = new Set(['preset:proactive', 'cmd:SELECT']);
assert.equal(matchesFilter(msg('FETCH'), 0, set), true);
assert.equal(matchesFilter(msg('SELECT'), 0, set), true);
assert.equal(matchesFilter(msg('READ RECORD'), 0, set), false);
});
test('the summary follows the selection', () => {
STATE = { filter: new Set() };
assert.equal(filterSummary(), 'All commands');
STATE.filter.add('cmd:SELECT');
assert.equal(filterSummary(), 'SELECT');
STATE.filter.clear();
STATE.filter.add('preset:files');
assert.equal(filterSummary(), 'File operations');
STATE.filter.add('cmd:FETCH');
assert.equal(filterSummary(), '2 commands');
});
test('the menu lists the presets and the commands with their counts', () => {
STATE = {
messages: [msg('SELECT'), msg('SELECT'), msg('FETCH')],
filter: new Set(),
};
const menu = { innerHTML: '', style: { display: 'none' } };
const btn = { textContent: '' };
el = id => (id === 'filter-menu' ? menu : btn);
renderFilterMenu(true);
assert.match(menu.innerHTML, /data-filter="preset:proactive"/);
assert.match(menu.innerHTML, /data-filter="preset:proactive-events"/);
assert.match(menu.innerHTML, /data-filter="preset:files"/);
assert.match(menu.innerHTML, /data-filter="cmd:SELECT"/);
assert.match(menu.innerHTML, /<span class="filter-count">2<\/span>/);
assert.match(menu.innerHTML, /data-filter="all" checked>/);
assert.equal(btn.textContent, 'All commands');
// A selection marks its row (and unchecks "All commands").
STATE.filter.add('cmd:SELECT');
renderFilterMenu(true);
assert.match(menu.innerHTML, /data-filter="cmd:SELECT" checked>/);
assert.match(menu.innerHTML, /data-filter="all">/);
assert.equal(btn.textContent, 'SELECT');
});
test('the filter 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 ['All commands', 'Presets', 'Commands', 'Proactive sessions',
'Proactive + events', 'File operations', '{n} commands']) {
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
}
});
test('the filter dropdown is wired (source pins)', () => {
assert.match(src, /<button type="button" id="filter-btn"[^>]*>/);
assert.match(src, /<div class="filter-menu" id="filter-menu"/);
assert.doesNotMatch(src, /id="filter-select"/);
assert.match(src, /\$on\('filter-btn', 'click', \(\) => toggleFilterMenu\(\)\);/);
assert.match(src, /\$on\('filter-menu', 'change', e => \{/);
assert.match(src, /e\.target\.closest\('input\[data-filter\]'\)/);
assert.match(src, /setFilterEntry\(box\.dataset\.filter, box\.checked\)/);
assert.match(src, /if \(!e\.target\.closest\('#filter-dropdown'\)\) toggleFilterMenu\(false\);/);
assert.match(src, /const filterSet = STATE\.filter;/);
assert.match(src, /if \(!matchesFilter\(m, i, filterSet\)\) continue;/);
assert.match(src, /clearFilter\(\);/);
assert.match(src, /item\('all', t\('All commands'\), !STATE\.filter\.size\)/);
});