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

+6 -1
View File
@@ -71,7 +71,12 @@ GSMTAP into this analyser.
and AUTH commands get distinctive styling in map view and AUTH commands get distinctive styling in map view
- Sessions with deep links (`#session=N`), PCAP import (`.pcap/.pcapng/.cap`), - Sessions with deep links (`#session=N`), PCAP import (`.pcap/.pcapng/.cap`),
COMPRION trace import (the Device Manager *ISO Protocol Layer* text export), COMPRION trace import (the Device Manager *ISO Protocol Layer* text export),
search and type filtering search and a **multi-select command filter** — pick any number of commands
and/or the conversation presets (*Proactive sessions*: FETCH, TERMINAL
PROFILE and every command answered with SW 91xx; *Proactive + events*: the
same plus ENVELOPE; *File operations*: SELECT, READ/UPDATE BINARY and
READ/UPDATE RECORD, with the GET RESPONSE carrying a selected command's
data)
- Installable PWA, dark theme, English/Russian UI - Installable PWA, dark theme, English/Russian UI
## Prerequisites ## Prerequisites
+6 -1
View File
@@ -73,7 +73,12 @@ SIM/UICC из UDP-потока GSMTAP (или импортирует файлы
TERMINAL RESPONSE, ENVELOPE и AUTH-команды выделяются в режиме карты TERMINAL RESPONSE, ENVELOPE и AUTH-команды выделяются в режиме карты
- Сессии с прямыми ссылками (`#session=N`), импорт PCAP - Сессии с прямыми ссылками (`#session=N`), импорт PCAP
(`.pcap/.pcapng/.cap`), импорт трасс COMPRION (текстовый экспорт (`.pcap/.pcapng/.cap`), импорт трасс COMPRION (текстовый экспорт
«ISO Protocol Layer» из Device Manager), поиск и фильтрация по типу «ISO Protocol Layer» из Device Manager), поиск и **фильтр команд с
множественным выбором** — любые команды и/или готовые пресеты («Проактивные
сессии»: FETCH, TERMINAL PROFILE и все команды с ответом SW 91xx;
«Проактивные + события»: то же плюс ENVELOPE; «Файловые операции»: SELECT,
READ/UPDATE BINARY и READ/UPDATE RECORD, вместе с GET RESPONSE, несущим
данные выбранной команды)
- Устанавливаемая PWA, тёмная тема, интерфейс на английском/русском - Устанавливаемая PWA, тёмная тема, интерфейс на английском/русском
## Требования ## Требования
+163 -29
View File
@@ -121,6 +121,24 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
.trace-topbar button.connect { background: var(--warn); color: #0f172a; } .trace-topbar button.connect { background: var(--warn); color: #0f172a; }
.trace-topbar button.connect:hover { background: var(--warn-hover); } .trace-topbar button.connect:hover { background: var(--warn-hover); }
.trace-topbar button.disabled { background: var(--bg-input); color: var(--text-muted); cursor: not-allowed; opacity: 0.6; } .trace-topbar button.disabled { background: var(--bg-input); color: var(--text-muted); cursor: not-allowed; opacity: 0.6; }
/* Command filter: a button + a checkbox dropdown (presets and commands) */
.filter-dropdown { position: relative; }
#filter-btn { background: var(--bg-input); color: var(--text); border: 1px solid var(--border);
border-radius: 4px; padding: 8px 12px; cursor: pointer; font-weight: 400;
max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filter-btn:hover { background: var(--border); }
.filter-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 60; min-width: 260px;
max-height: 60vh; overflow-y: auto; padding: 6px; background: var(--bg-panel);
border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 8px 24px var(--modal-bg); }
.filter-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 4px;
cursor: pointer; font-size: 13px; white-space: nowrap; }
.filter-item:hover { background: var(--bg-input); }
.filter-item input { margin: 0; cursor: pointer; }
.filter-item .filter-count { margin-left: auto; color: var(--text-muted); font-size: 12px; }
.filter-menu .filter-heading { margin: 6px 8px 2px; color: var(--text-muted); font-size: 11px;
text-transform: uppercase; letter-spacing: 1px; }
.filter-sep { height: 1px; margin: 6px 4px; background: var(--border); }
.trace-main { display: flex; flex: 1; overflow: hidden; } .trace-main { display: flex; flex: 1; overflow: hidden; }
.trace-left { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .trace-left { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
@@ -352,10 +370,11 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
<div class="trace-topbar"> <div class="trace-topbar">
<button id="capture-btn" data-l10n="Start Capture">Start Capture</button> <button id="capture-btn" data-l10n="Start Capture">Start Capture</button>
<input type="text" id="search-input" data-l10n="Search hex bytes..." placeholder="Search hex bytes..." autocomplete="off"> <input type="text" id="search-input" data-l10n="Search hex bytes..." placeholder="Search hex bytes..." autocomplete="off">
<label for="filter-select" class="filter-label" data-l10n="Filter">Filter</label> <span class="filter-label" data-l10n="Filter">Filter</span>
<select id="filter-select"> <div class="filter-dropdown" id="filter-dropdown">
<option value="" data-l10n="All commands">All commands</option> <button type="button" id="filter-btn" aria-haspopup="true" aria-expanded="false">All commands</button>
</select> <div class="filter-menu" id="filter-menu" role="group" aria-label="Filter" style="display:none"></div>
</div>
<span id="session-name-display" style="display:none; color:var(--text-muted); font-size:15px; font-weight:700;"></span> <span id="session-name-display" style="display:none; color:var(--text-muted); font-size:15px; font-weight:700;"></span>
<select class="side-view-select" id="side-view-select" aria-label="Side panel view"> <select class="side-view-select" id="side-view-select" aria-label="Side panel view">
<option value="map" data-l10n="Map">Map</option> <option value="map" data-l10n="Map">Map</option>
@@ -515,6 +534,7 @@ const STATE = {
livePoller: null, livePoller: null,
messages: [], messages: [],
selectedIdx: null, // the focused APDU entry (persists across renders) selectedIdx: null, // the focused APDU entry (persists across renders)
filter: new Set(), // command-filter entries (empty = all commands)
typeCounts: [], typeCounts: [],
traceMap: [], traceMap: [],
sideView: 'map', sideView: 'map',
@@ -2320,20 +2340,107 @@ function mapFilesHtml(g) {
return `<span class="map-files-toggle" data-start="${g.start}">${label} <span class="map-arrow">${expanded ? '▾' : '▸'}</span></span>`; return `<span class="map-files-toggle" data-start="${g.start}">${label} <span class="map-arrow">${expanded ? '▾' : '▸'}</span></span>`;
} }
function populateFilterOptions() { // ──────────────────── Command filter ────────────────────
const sel = el('filter-select'); // The filter holds a set of entries — a conversation preset or a single
// command label ("cmd:<label>") — and an empty set means "all commands".
const FILTER_PRESETS = [
{
id: 'preset:proactive', name: 'Proactive sessions',
// FETCH / TERMINAL PROFILE plus every proactive-session initiator:
// a command the card answers with SW 91xx.
labels: ['FETCH: ANY PROACTIVE', 'TERMINAL PROFILE'],
test: m => _sw1(m) === 0x91,
},
{
id: 'preset:proactive-events', name: 'Proactive + events',
labels: ['FETCH: ANY PROACTIVE', 'TERMINAL PROFILE', 'ENVELOPE'],
test: m => _sw1(m) === 0x91,
},
{
id: 'preset:files', name: 'File operations',
labels: ['SELECT', 'READ BINARY', 'READ RECORD', 'UPDATE BINARY',
'UPDATE RECORD'],
test: m => ['a4', 'b0', 'b2', 'd6', 'dc'].includes(_ins_hex(m)),
},
];
function _sw1(m) {
const sw = (m.decoded || {}).sw;
return sw && sw.sw1 ? parseInt(sw.sw1, 16) : null;
}
function filterPreset(id) {
return FILTER_PRESETS.find(p => p.id === id) || null;
}
function filterEntryName(entry) {
const p = filterPreset(entry);
return p ? t(p.name) : entry.slice(4);
}
function filterSummary() {
if (!STATE.filter.size) return t('All commands');
if (STATE.filter.size === 1) return filterEntryName([...STATE.filter][0]);
return t('{n} commands').replace('{n}', STATE.filter.size);
}
let _filterMenuSig = null;
// Rebuild the dropdown's rows (presets, then every command seen in the
// session with its count). The DOM is only rewritten when the command set
// changed or the menu is open; the summary button always follows the state.
function renderFilterMenu(force) {
const menu = el('filter-menu');
if (!menu) return;
const counts = {}; const counts = {};
for (const m of STATE.messages) { for (const m of STATE.messages) {
const lbl = filter_label(m); const lbl = filter_label(m);
counts[lbl] = (counts[lbl] || 0) + 1; counts[lbl] = (counts[lbl] || 0) + 1;
} }
const keys = Object.keys(counts).sort((a, b) => a.localeCompare(b)); const keys = Object.keys(counts).sort((a, b) => a.localeCompare(b));
const existing = Array.from(sel.options).slice(1).map(o => o.value); const sig = keys.join('\n');
if (existing.length === keys.length && existing.every((k, i) => k === keys[i])) return; if (!force && menu.style.display === 'none' && sig === _filterMenuSig) {
const cur = sel.value; el('filter-btn').textContent = filterSummary();
sel.innerHTML = '<option value="">' + t('All commands') + '</option>' + return;
keys.map(k => `<option value="${escapeHtml(k).replace(/"/g, '&quot;')}">${escapeHtml(k)}</option>`).join(''); }
sel.value = keys.includes(cur) ? cur : ''; _filterMenuSig = sig;
const item = (value, label, checked, count) =>
`<label class="filter-item"><input type="checkbox" data-filter="${escapeHtml(value)}"` +
`${checked ? ' checked' : ''}> ${escapeHtml(label)}` +
`${count === undefined ? '' : `<span class="filter-count">${count}</span>`}</label>`;
menu.innerHTML = [
item('all', t('All commands'), !STATE.filter.size),
`<div class="filter-heading">${t('Presets')}</div>`,
...FILTER_PRESETS.map(p => item(p.id, t(p.name), STATE.filter.has(p.id))),
'<div class="filter-sep"></div>',
`<div class="filter-heading">${t('Commands')}</div>`,
...keys.map(k => item('cmd:' + k, k, STATE.filter.has('cmd:' + k),
counts[k])),
].join('');
el('filter-btn').textContent = filterSummary();
}
function toggleFilterMenu(force) {
const menu = el('filter-menu');
if (!menu) return;
const open = force === undefined ? menu.style.display === 'none' : !!force;
if (open) renderFilterMenu(true);
menu.style.display = open ? '' : 'none';
el('filter-btn').setAttribute('aria-expanded', open ? 'true' : 'false');
}
function setFilterEntry(entry, on) {
if (entry === 'all') STATE.filter.clear();
else if (on) STATE.filter.add(entry);
else STATE.filter.delete(entry);
renderFilterMenu(true);
renderMessages();
}
function clearFilter() {
if (!STATE.filter.size) return;
STATE.filter.clear();
renderFilterMenu(true);
} }
function scrollToType(typeKey) { function scrollToType(typeKey) {
@@ -2356,10 +2463,9 @@ function scrollToType(typeKey) {
} }
function focusMessage(idx) { function focusMessage(idx) {
const filterEl = el('filter-select');
const searchEl = el('search-input'); const searchEl = el('search-input');
const needRender = filterEl.value !== '' || searchEl.value !== ''; const needRender = STATE.filter.size > 0 || searchEl.value !== '';
filterEl.value = ''; clearFilter();
searchEl.value = ''; searchEl.value = '';
// The focused entry stays selected until another one is focused; the // The focused entry stays selected until another one is focused; the
// state lets a re-render (the live poll included) keep it marked. // state lets a re-render (the live poll included) keep it marked.
@@ -2382,13 +2488,29 @@ function isOriginalCommand(i) {
return false; return false;
} }
function matchesFilter(m, i, filterType) { function matchesFilter(m, i, filterSet) {
if (!filterType) return true; if (!filterSet || !filterSet.size) return true;
if (filter_label(m) === filterType) return true; const label = filter_label(m);
const d = m.decoded; const d = m.decoded;
const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE'; const isGR = m.type === 'tpdu' && d && d.ins_name === 'GET RESPONSE';
if (isGR && d.response_for === filterType) return true; const origin = isGR ? d.response_for : null;
if (filterType === 'GET RESPONSE' && isOriginalCommand(i)) return true; for (const entry of filterSet) {
const preset = filterPreset(entry);
if (preset) {
// A preset covers the commands it names and the rows its rule
// matches (SW 91xx / the file-operation INS set); the GET
// RESPONSE that carries a selected command's data comes along.
if (preset.test(m) || preset.labels.includes(label) ||
(origin && preset.labels.includes(origin))) return true;
continue;
}
const want = entry.slice(4);
if (want === label) return true;
// A GET RESPONSE belongs to the command that fetched it; selecting
// GET RESPONSE also shows those commands.
if (origin && want === origin) return true;
if (want === 'GET RESPONSE' && isOriginalCommand(i)) return true;
}
return false; return false;
} }
@@ -2429,12 +2551,12 @@ function renderMessages(skipScroll, forceStick) {
// scrollHeight, which would hide the fact that the reader was at the end. // scrollHeight, which would hide the fact that the reader was at the end.
const stick = shouldStickToBottom(list, skipScroll, forceStick); const stick = shouldStickToBottom(list, skipScroll, forceStick);
const searchQ = el('search-input').value.toLowerCase(); const searchQ = el('search-input').value.toLowerCase();
const filterType = el('filter-select').value; const filterSet = STATE.filter;
const filtered = []; const filtered = [];
for (let i = 0; i < STATE.messages.length; i++) { for (let i = 0; i < STATE.messages.length; i++) {
const m = STATE.messages[i]; const m = STATE.messages[i];
if (!matchesFilter(m, i, filterType)) continue; if (!matchesFilter(m, i, filterSet)) continue;
if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue; if (searchQ && !m.data.toLowerCase().includes(searchQ)) continue;
filtered.push({ m, idx: i }); filtered.push({ m, idx: i });
} }
@@ -2442,7 +2564,7 @@ function renderMessages(skipScroll, forceStick) {
buildTypeCounts(); buildTypeCounts();
buildTraceMap(); buildTraceMap();
renderSidePanel(); renderSidePanel();
populateFilterOptions(); renderFilterMenu();
if (!STATE.captureActive && !STATE.sessionViewId) { if (!STATE.captureActive && !STATE.sessionViewId) {
renderTraceEmptyState(); renderTraceEmptyState();
@@ -3221,12 +3343,8 @@ function onSearchChange() {
_searchTimer = setTimeout(renderMessages, 200); _searchTimer = setTimeout(renderMessages, 200);
} }
function onFilterChange() {
renderMessages();
}
// ──────────────────── Versioning ──────────────────── // ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.6.5'; const PWA_VERSION = '2.7.0';
const MIN_SERVER_VERSION = '2.3.0'; const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) { function parseVersion(v) {
@@ -3261,6 +3379,12 @@ const LANG_RU = {
'Install PWA': 'Установить PWA', 'Install PWA': 'Установить PWA',
'Search hex bytes...': 'Поиск по данным (hex)...', 'Search hex bytes...': 'Поиск по данным (hex)...',
'All commands': 'Все команды', 'All commands': 'Все команды',
'Presets': 'Пресеты',
'Commands': 'Команды',
'Proactive sessions': 'Проактивные сессии',
'Proactive + events': 'Проактивные + события',
'File operations': 'Файловые операции',
'{n} commands': 'команд: {n}',
'Filter': 'Фильтр', 'Filter': 'Фильтр',
'Start Capture': 'Начать захват', 'Start Capture': 'Начать захват',
'Stop Capture': 'Остановить захват', 'Stop Capture': 'Остановить захват',
@@ -3586,7 +3710,17 @@ function init() {
$on('view-list-btn', 'click', () => toggleSessionView('list')); $on('view-list-btn', 'click', () => toggleSessionView('list'));
$on('search-input', 'input', onSearchChange); $on('search-input', 'input', onSearchChange);
$on('filter-select', 'change', onFilterChange); $on('filter-btn', 'click', () => toggleFilterMenu());
$on('filter-menu', 'change', e => {
const box = e.target.closest('input[data-filter]');
if (box) setFilterEntry(box.dataset.filter, box.checked);
});
document.addEventListener('click', e => {
if (!e.target.closest('#filter-dropdown')) toggleFilterMenu(false);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') toggleFilterMenu(false);
});
$on('server-source', 'change', () => { $on('server-source', 'change', () => {
if (el('server-source').value === 'same') { if (el('server-source').value === 'same') {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v79'; const CACHE = 'simtrace-analyser-v80';
const PRECACHE = [ const PRECACHE = [
'index.html', 'index.html',
+9 -3
View File
@@ -15,6 +15,7 @@ let renders = [];
let focused = []; let focused = [];
let el = () => ({}); let el = () => ({});
let showDetail = () => {}; let showDetail = () => {};
function renderFilterMenu() {} // the real one needs the menu DOM
function renderMessages(skipScroll, forceStick) { function renderMessages(skipScroll, forceStick) {
renders.push([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 real focusMessage, under an alias: the stub above stays in place for
// the jumpToMessage tests. // 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 () => { test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
renders = []; 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) }, classList: { add: c => classes.add(c), remove: c => classes.delete(c) },
scrollIntoView: () => {}, scrollIntoView: () => {},
}; };
STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 }; STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0,
filter: new Set() };
el = () => ({ value: '', querySelector: () => entry }); el = () => ({ value: '', querySelector: () => entry });
let detailed = null; let detailed = null;
showDetail = (msg, e) => { detailed = [msg, e]; }; showDetail = (msg, e) => { detailed = [msg, e]; };
@@ -116,12 +119,15 @@ test('focusMessage clears a filter before focusing', () => {
classList: { add: () => {}, remove: () => {} }, classList: { add: () => {}, remove: () => {} },
scrollIntoView: () => {}, scrollIntoView: () => {},
}; };
STATE = { messages: {}, selectedIdx: null }; STATE = { messages: {}, selectedIdx: null,
filter: new Set(['cmd:SELECT']) };
el = () => ({ value: 'x', querySelector: () => entry }); el = () => ({ value: 'x', querySelector: () => entry });
showDetail = () => {}; showDetail = () => {};
realFocusMessage(3); realFocusMessage(3);
assert.deepEqual(renders, [[true, undefined]]); assert.deepEqual(renders, [[true, undefined]]);
assert.equal(STATE.selectedIdx, 3); 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)', () => { 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\)/);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "simtrace-analyser" name = "simtrace-analyser"
version = "2.6.5" version = "2.7.0"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"} readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"} license = {text = "GPL-2.0-or-later"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version.""" """Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.6.5' __version__ = '2.7.0'
+1 -1
View File
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase): class TestVersion(unittest.TestCase):
def test_package_version(self): def test_package_version(self):
from simtrace2_pysniff import __version__ from simtrace2_pysniff import __version__
self.assertEqual(__version__, '2.6.5') self.assertEqual(__version__, '2.7.0')
def test_status_includes_version(self): def test_status_includes_version(self):
import os import os