diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 461c43e0..e4acf477 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -208,6 +208,7 @@ jobs: - name: Run Playwright E2E tests (fail-fast) run: | BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt + BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt - name: Collect frontend coverage (parallel) if: success() && github.event_name == 'push' diff --git a/public/filter-ux.js b/public/filter-ux.js new file mode 100644 index 00000000..41653147 --- /dev/null +++ b/public/filter-ux.js @@ -0,0 +1,405 @@ +/* filter-ux.js — Wireshark-style filter UX (issue #966) + * + * Owns: + * - Help popover (filter syntax, fields, operators, examples) + * - Autocomplete dropdown (field names, operators, type/route values, payload.*) + * - Right-click context menu on packet table cells → "Filter by this value" + * - Saved-filter dropdown (localStorage, with starter defaults) + * + * Pure-logic helpers (SavedFilters, buildCellFilterClause, appendClauseToExpr) + * are unit-tested in test-packet-filter-ux.js. DOM glue is exercised by + * test-filter-ux-e2e.js (Playwright). + */ +(function() { + 'use strict'; + + var LS_KEY = 'corescope_saved_filters_v1'; + + // ── Saved filters store ──────────────────────────────────────────────── + var DEFAULT_FILTERS = [ + { name: 'Adverts only', expr: 'type == ADVERT', builtin: true }, + { name: 'Channel traffic', expr: 'type == GRP_TXT', builtin: true }, + { name: 'Direct messages', expr: 'type == TXT_MSG', builtin: true }, + { name: 'Strong signal (SNR > 5)', expr: 'snr > 5', builtin: true }, + { name: 'Multi-hop (hops > 1)', expr: 'hops > 1', builtin: true }, + { name: 'Repeater adverts', expr: 'type == ADVERT && payload.flags.repeater == true', builtin: true }, + { name: 'Recent (last 5 min)', expr: 'age < 5m', builtin: true }, + ]; + + function _getStore() { + try { + var raw = window.localStorage.getItem(LS_KEY); + if (!raw) return []; + var parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { return []; } + } + function _setStore(arr) { + try { window.localStorage.setItem(LS_KEY, JSON.stringify(arr)); } catch (e) {} + } + + var SavedFilters = { + defaults: function() { return DEFAULT_FILTERS.slice(); }, + list: function() { + // Defaults first, then user filters (deduped by name — user wins on collision) + var user = _getStore(); + var userNames = {}; + for (var i = 0; i < user.length; i++) userNames[user[i].name] = true; + var defaults = DEFAULT_FILTERS.filter(function(d) { return !userNames[d.name]; }); + return defaults.concat(user); + }, + save: function(name, expr) { + if (!name || !expr) return; + var user = _getStore(); + var idx = -1; + for (var i = 0; i < user.length; i++) { if (user[i].name === name) { idx = i; break; } } + var entry = { name: name, expr: expr, ts: Date.now() }; + if (idx >= 0) user[idx] = entry; else user.push(entry); + _setStore(user); + }, + delete: function(name) { + var user = _getStore(); + _setStore(user.filter(function(f) { return f.name !== name; })); + }, + }; + + // ── Right-click filter clause builders ───────────────────────────────── + // Numeric strings stay unquoted; identifiers from TYPE_VALUES/ROUTE_VALUES + // stay unquoted; everything else gets double-quoted. + function _isNumericString(s) { + if (typeof s !== 'string') return false; + return /^-?\d+(\.\d+)?$/.test(s.trim()); + } + function _isBareIdentifier(s) { + return typeof s === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(s); + } + function buildCellFilterClause(field, value, op) { + op = op || '=='; + if (value == null) value = ''; + var v = String(value); + var rendered; + if (op === 'contains' || op === 'starts_with' || op === 'ends_with') { + // String-only ops: always quote + rendered = '"' + v.replace(/"/g, '\\"') + '"'; + } else if (_isNumericString(v)) { + rendered = v; + } else if (_isBareIdentifier(v)) { + rendered = v; + } else { + rendered = '"' + v.replace(/"/g, '\\"') + '"'; + } + return field + ' ' + op + ' ' + rendered; + } + function appendClauseToExpr(expr, clause) { + if (!expr || !expr.trim()) return clause; + return expr.trim() + ' && ' + clause; + } + + // ── DOM glue (only runs in browser, after init()) ────────────────────── + var _ctxMenu = null; + + function _h(tag, attrs, html) { + var el = document.createElement(tag); + if (attrs) for (var k in attrs) { + if (k === 'class') el.className = attrs[k]; + else if (k === 'style') el.setAttribute('style', attrs[k]); + else if (k.indexOf('data-') === 0) el.setAttribute(k, attrs[k]); + else el[k] = attrs[k]; + } + if (html != null) el.innerHTML = html; + return el; + } + function _esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function _buildHelpHtml() { + var PF = window.PacketFilter; + var rows = (PF.FIELDS || []).map(function(f) { + return '' + _esc(f.name) + '' + _esc(f.desc) + ''; + }).join(''); + var ops = (PF.OPERATORS || []).map(function(o) { + return '' + _esc(o.op) + '' + _esc(o.desc) + + '' + _esc(o.example) + ''; + }).join(''); + var examples = [ + 'type == ADVERT', + 'type == GRP_TXT && size > 50', + 'payload.name contains "Gilroy"', + 'payload.flags.repeater == true', + 'snr > 5 && rssi > -90', + 'hops < 2', + 'observer == "Dorrington" && type == ADVERT', + '(type == ADVERT || type == ACK) && snr > 0', + 'age < 1h', + 'time after "2025-01-01"', + ].map(function(e) { return '
  • ' + _esc(e) + '
  • '; }).join(''); + return [ + '

    Filter syntax

    ', + '

    Wireshark-style boolean expressions over packet fields. Combine with &&, ||, !, and parentheses. Strings are case-insensitive. Tip: append ?filter=… to the URL to share a filter.

    ', + '

    Fields

    ', + '' + rows + '
    NameDescription
    ', + '

    Operators

    ', + '' + ops + '
    OpMeaningExample
    ', + '

    Examples

    ', + '', + '

    Tips

    ', + '', + ].join(''); + } + + function _showHelp() { + var existing = document.getElementById('filterHelpPopover'); + if (existing) { existing.remove(); return; } + var pop = _h('div', { id: 'filterHelpPopover', class: 'fux-popover', role: 'dialog', 'aria-label': 'Filter syntax help' }); + pop.innerHTML = + '
    Filter syntax' + + '
    ' + + '
    ' + _buildHelpHtml() + '
    '; + document.body.appendChild(pop); + pop.querySelector('.fux-popover-close').addEventListener('click', function() { pop.remove(); }); + document.addEventListener('keydown', function _esc(ev) { + if (ev.key === 'Escape') { pop.remove(); document.removeEventListener('keydown', _esc); } + }); + } + + // ── Autocomplete ─────────────────────────────────────────────────────── + function _wireAutocomplete(input) { + var dd = _h('div', { id: 'filterAcDropdown', class: 'fux-ac-dropdown', role: 'listbox' }); + dd.style.display = 'none'; + input.parentNode.appendChild(dd); + var sel = -1, items = []; + + function _gatherPayloadKeys() { + // Best-effort: scan the first ~50 visible packets for decoded_json keys + var keys = {}; + try { + var rows = document.querySelectorAll('#pktTable tbody tr'); + for (var r = 0; r < rows.length && r < 50; r++) { + var dj = rows[r].getAttribute('data-decoded'); + if (!dj) continue; + var obj = JSON.parse(dj); + for (var k in obj) keys[k] = true; + } + } catch (e) {} + return Object.keys(keys); + } + + function close() { dd.style.display = 'none'; sel = -1; items = []; input.removeAttribute('aria-activedescendant'); } + function render() { + if (!items.length) { close(); return; } + dd.innerHTML = items.map(function(it, i) { + return '
    ' + + '' + _esc(it.value) + '' + + (it.desc ? '' + _esc(it.desc) + '' : '') + + '
    '; + }).join(''); + dd.style.display = 'block'; + if (sel >= 0) input.setAttribute('aria-activedescendant', 'fux-ac-' + sel); + } + function accept(idx) { + if (!items[idx]) return; + var rs = items._replaceStart, re = items._replaceEnd; + var val = items[idx].value; + var v = input.value; + var newVal = v.slice(0, rs) + val + v.slice(re); + var caret = rs + val.length; + // Append space + helpful next char for fields (so user can type op) + if (items[idx].kind === 'field') { newVal = newVal.slice(0, caret) + ' ' + newVal.slice(caret); caret++; } + input.value = newVal; + input.setSelectionRange(caret, caret); + close(); + // Trigger filter recompile + input.dispatchEvent(new Event('input', { bubbles: true })); + } + + function refresh() { + var PF = window.PacketFilter; + if (!PF || !PF.suggest) return close(); + var r = PF.suggest(input.value, input.selectionStart || 0, { payloadKeys: _gatherPayloadKeys() }); + items = (r && r.suggestions) ? r.suggestions.slice(0, 12) : []; + items._replaceStart = r ? r.replaceStart : 0; + items._replaceEnd = r ? r.replaceEnd : 0; + sel = items.length ? 0 : -1; + render(); + } + input.addEventListener('input', refresh); + input.addEventListener('focus', refresh); + input.addEventListener('blur', function() { setTimeout(close, 150); }); + input.addEventListener('keydown', function(ev) { + if (dd.style.display === 'none') return; + if (ev.key === 'ArrowDown') { sel = (sel + 1) % items.length; render(); ev.preventDefault(); } + else if (ev.key === 'ArrowUp') { sel = (sel - 1 + items.length) % items.length; render(); ev.preventDefault(); } + else if (ev.key === 'Tab' || ev.key === 'Enter') { + if (sel >= 0) { accept(sel); ev.preventDefault(); } + } else if (ev.key === 'Escape') { close(); ev.preventDefault(); } + }); + dd.addEventListener('mousedown', function(ev) { + var target = ev.target.closest('.fux-ac-item'); + if (!target) return; + ev.preventDefault(); + accept(parseInt(target.getAttribute('data-idx'), 10)); + }); + } + + // ── Right-click context menu ─────────────────────────────────────────── + function _showContextMenu(x, y, field, value) { + if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; } + var input = document.getElementById('packetFilterInput'); + if (!input) return; + var menu = _h('div', { id: 'filterContextMenu', class: 'fux-ctx-menu', role: 'menu' }); + var ops = [ + { label: 'Filter ' + field + ' == "' + value + '"', op: '==' }, + { label: 'Filter ' + field + ' != "' + value + '"', op: '!=' }, + { label: 'Filter ' + field + ' contains "' + value + '"', op: 'contains' }, + ]; + menu.innerHTML = ops.map(function(o, i) { + return ''; + }).join(''); + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + document.body.appendChild(menu); + _ctxMenu = menu; + menu.addEventListener('click', function(ev) { + var btn = ev.target.closest('.fux-ctx-item'); + if (!btn) return; + var op = ops[parseInt(btn.getAttribute('data-idx'), 10)].op; + var clause = buildCellFilterClause(field, value, op); + input.value = appendClauseToExpr(input.value, clause); + input.dispatchEvent(new Event('input', { bubbles: true })); + menu.remove(); _ctxMenu = null; + }); + function dismiss(ev) { + if (_ctxMenu && !_ctxMenu.contains(ev.target)) { _ctxMenu.remove(); _ctxMenu = null; + document.removeEventListener('mousedown', dismiss); + document.removeEventListener('keydown', escDismiss); + } + } + function escDismiss(ev) { if (ev.key === 'Escape') dismiss({ target: document.body }); } + setTimeout(function() { + document.addEventListener('mousedown', dismiss); + document.addEventListener('keydown', escDismiss); + }, 0); + } + + function _wireContextMenu() { + // Delegated listener on the table — extracts field+value from data-* attrs. + var tbl = document.getElementById('pktTable'); + if (!tbl) return; + tbl.addEventListener('contextmenu', function(ev) { + var cell = ev.target.closest('td[data-filter-field]'); + if (!cell) return; + var field = cell.getAttribute('data-filter-field'); + var value = cell.getAttribute('data-filter-value'); + if (!field || value == null || value === '') return; + ev.preventDefault(); + _showContextMenu(ev.pageX, ev.pageY, field, value); + }); + } + + // ── Saved filters dropdown ───────────────────────────────────────────── + function _renderSavedDropdown(container, input) { + var btn = _h('button', { type: 'button', class: 'fux-saved-trigger', id: 'filterSavedTrigger', title: 'Saved filters' }, '★ Saved ▾'); + var menu = _h('div', { class: 'fux-saved-menu hidden', id: 'filterSavedMenu', role: 'menu' }); + container.appendChild(btn); + container.appendChild(menu); + + function build() { + var list = SavedFilters.list(); + var rows = list.map(function(f, i) { + var del = f.builtin ? '' : + ''; + return '
    ' + + '' + _esc(f.name) + '' + + '' + _esc(f.expr) + '' + + del + '
    '; + }).join(''); + menu.innerHTML = + '
    Saved filters
    ' + + rows + + ''; + } + + btn.addEventListener('click', function(ev) { + ev.stopPropagation(); + build(); + menu.classList.toggle('hidden'); + }); + document.addEventListener('click', function(ev) { + if (!menu.contains(ev.target) && ev.target !== btn) menu.classList.add('hidden'); + }); + menu.addEventListener('click', function(ev) { + var del = ev.target.closest('.fux-saved-del'); + if (del) { + SavedFilters.delete(del.getAttribute('data-name')); + build(); + ev.stopPropagation(); + return; + } + if (ev.target.id === 'filterSaveCurrent') { + var expr = (input.value || '').trim(); + if (!expr) { alert('Type a filter expression first.'); return; } + var name = prompt('Name this filter:', ''); + if (name && name.trim()) { + SavedFilters.save(name.trim(), expr); + build(); + } + return; + } + var item = ev.target.closest('.fux-saved-item'); + if (item) { + var list = SavedFilters.list(); + var f = list[parseInt(item.getAttribute('data-idx'), 10)]; + if (f) { + input.value = f.expr; + input.dispatchEvent(new Event('input', { bubbles: true })); + menu.classList.add('hidden'); + } + } + }); + } + + // ── Init: idempotent, called by packets.js after filter input renders ── + function init() { + var input = document.getElementById('packetFilterInput'); + if (!input || input.dataset.fuxInit === '1') return; + input.dataset.fuxInit = '1'; + + // Help icon + saved-filters dropdown — injected next to the input + var wrap = input.parentNode; + if (wrap) { + var bar = document.getElementById('filterUxBar'); + if (!bar) { + bar = _h('div', { id: 'filterUxBar', class: 'fux-bar' }); + var helpBtn = _h('button', { type: 'button', class: 'fux-help-btn', id: 'filterHelpBtn', + 'aria-label': 'Filter syntax help', title: 'Filter syntax help' }, 'ⓘ Help'); + helpBtn.addEventListener('click', _showHelp); + bar.appendChild(helpBtn); + _renderSavedDropdown(bar, input); + wrap.appendChild(bar); + } + } + + _wireAutocomplete(input); + _wireContextMenu(); + } + + var _exports = { + SavedFilters: SavedFilters, + buildCellFilterClause: buildCellFilterClause, + appendClauseToExpr: appendClauseToExpr, + init: init, + _showHelp: _showHelp, // exposed for E2E + }; + if (typeof window !== 'undefined') window.FilterUX = _exports; + if (typeof module !== 'undefined' && module.exports) module.exports = _exports; +})(); diff --git a/public/index.html b/public/index.html index 5060c278..9ffd5aec 100644 --- a/public/index.html +++ b/public/index.html @@ -99,6 +99,7 @@ + diff --git a/public/packet-filter.js b/public/packet-filter.js index cd6cf794..7aad6956 100644 --- a/public/packet-filter.js +++ b/public/packet-filter.js @@ -384,7 +384,142 @@ }; } - var _exports = { parse: parse, evaluate: evaluate, compile: compile }; + // ── Metadata for autocomplete + in-UI documentation (#966) ──────────────── + var FIELDS = [ + { name: 'type', desc: 'Packet payload type (ADVERT, GRP_TXT, TXT_MSG, ACK, …)' }, + { name: 'route', desc: 'Route type (FLOOD, DIRECT, TRANSPORT_FLOOD, TRANSPORT_DIRECT)' }, + { name: 'transport', desc: 'true if route is TRANSPORT_FLOOD or TRANSPORT_DIRECT' }, + { name: 'hash', desc: 'Packet hash (hex)' }, + { name: 'raw', desc: 'Full raw hex of the packet' }, + { name: 'size', desc: 'Total packet size in bytes' }, + { name: 'snr', desc: 'Signal-to-noise ratio (dB)' }, + { name: 'rssi', desc: 'Received signal strength (dBm)' }, + { name: 'hops', desc: 'Number of hops in the path' }, + { name: 'observer', desc: 'Observer station name' }, + { name: 'observer_id', desc: 'Observer pubkey/id' }, + { name: 'observations', desc: 'Number of observations of this packet' }, + { name: 'path', desc: 'Hop path (joined with arrows)' }, + { name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' }, + { name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' }, + { name: 'time', desc: 'Packet timestamp (epoch ms)' }, + { name: 'age', desc: 'Seconds since the packet was observed (use with durations: age < 1h)' }, + { name: 'payload.name', desc: 'Decoded payload: node name (adverts)' }, + { name: 'payload.lat', desc: 'Decoded payload: latitude' }, + { name: 'payload.lon', desc: 'Decoded payload: longitude' }, + { name: 'payload.text', desc: 'Decoded payload: message text (channel/DM)' }, + { name: 'payload.channel', desc: 'Decoded payload: channel name' }, + { name: 'payload.channelHash', desc: 'Decoded payload: channel hash' }, + { name: 'payload.sender', desc: 'Decoded payload: sender name' }, + { name: 'payload.flags.repeater', desc: 'Decoded payload: advert flag (repeater role)' }, + { name: 'payload.flags.room', desc: 'Decoded payload: advert flag (room server)' }, + { name: 'payload.flags.hasLocation', desc: 'Decoded payload: advert has location' }, + ]; + + var OPERATORS = [ + { op: '==', desc: 'Equal (case-insensitive for strings, alias-aware for type/route)', example: 'type == ADVERT' }, + { op: '!=', desc: 'Not equal', example: 'type != ACK' }, + { op: '>', desc: 'Greater than (numeric)', example: 'snr > 5' }, + { op: '<', desc: 'Less than (numeric)', example: 'rssi < -90' }, + { op: '>=', desc: 'Greater or equal', example: 'hops >= 2' }, + { op: '<=', desc: 'Less or equal', example: 'size <= 100' }, + { op: 'contains', desc: 'Substring match (case-insensitive)', example: 'payload.name contains "Gilroy"' }, + { op: 'starts_with', desc: 'String prefix match', example: 'hash starts_with "8a91"' }, + { op: 'ends_with', desc: 'String suffix match', example: 'hash ends_with "ff"' }, + { op: 'after', desc: 'Datetime after (ISO or epoch)', example: 'time after "2025-01-01"' }, + { op: 'before', desc: 'Datetime before', example: 'time before "2025-12-31"' }, + { op: 'between', desc: 'Datetime between two values', example: 'time between "2025-01-01" "2025-02-01"' }, + ]; + + // Canonical type names (firmware payload types) + var TYPE_VALUES = ['REQ', 'RESPONSE', 'TXT_MSG', 'ACK', 'ADVERT', 'GRP_TXT', 'GRP_DATA', 'ANON_REQ', 'PATH', 'TRACE', 'MULTIPART', 'CONTROL', 'RAW_CUSTOM']; + var ROUTE_VALUES = ['TRANSPORT_FLOOD', 'FLOOD', 'DIRECT', 'TRANSPORT_DIRECT']; + + // suggest(input, cursor, opts?) → { suggestions: [{value, kind, desc?}], replaceStart, replaceEnd } + // Token-aware autocomplete: + // - Empty / partial-word at cursor → field names + // - Right after `field` → operators + // - Right after `type ==` → TYPE_VALUES (filtered by partial) + // - Right after `route ==` → ROUTE_VALUES + // - Partial `payload.` → payload.* fields (incl. dynamic opts.payloadKeys) + function suggest(input, cursor, opts) { + opts = opts || {}; + input = input || ''; + if (cursor == null) cursor = input.length; + var before = input.slice(0, cursor); + + // Determine the current word being typed (the replaceable span). + // Treat alphanumerics, '_', and '.' as word chars (so "payload.na" is one word). + var i = cursor; + while (i > 0 && /[A-Za-z0-9_.]/.test(input.charAt(i - 1))) i--; + var replaceStart = i; + var replaceEnd = cursor; + while (replaceEnd < input.length && /[A-Za-z0-9_.]/.test(input.charAt(replaceEnd))) replaceEnd++; + var partial = input.slice(replaceStart, cursor); + + // Look at preceding non-space tokens (very small recogniser) + var preceding = before.slice(0, replaceStart).replace(/\s+$/, ''); + var lastTokMatch = preceding.match(/(==|!=|>=|<=|>|<|contains|starts_with|ends_with|after|before|between|&&|\|\||\(|!)$/); + var lastTok = lastTokMatch ? lastTokMatch[1] : null; + // The token before lastTok (the field, if any) + var fieldBefore = null; + if (lastTok) { + var beforeOp = preceding.slice(0, preceding.length - lastTok.length).replace(/\s+$/, ''); + var fm = beforeOp.match(/([A-Za-z_][A-Za-z0-9_.]*)$/); + if (fm) fieldBefore = fm[1]; + } + + function makePrefixSuggestions(items, kind) { + var p = partial.toLowerCase(); + var out = []; + for (var k = 0; k < items.length; k++) { + var it = items[k]; + var val = typeof it === 'string' ? it : it.value; + if (!p || val.toLowerCase().indexOf(p) === 0) { + out.push({ value: val, kind: kind, desc: typeof it === 'string' ? '' : (it.desc || '') }); + } + } + return out; + } + + // Case A: just typed `field ==` (or other comparison op) → value suggestions + if (lastTok && fieldBefore) { + if (fieldBefore === 'type' && (lastTok === '==' || lastTok === '!=')) { + return { suggestions: makePrefixSuggestions(TYPE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd }; + } + if (fieldBefore === 'route' && (lastTok === '==' || lastTok === '!=')) { + return { suggestions: makePrefixSuggestions(ROUTE_VALUES, 'value'), replaceStart: replaceStart, replaceEnd: replaceEnd }; + } + } + + // Case B: a field is just typed (no operator yet) → operator suggestions + // Detect: preceding ends with a known field-like identifier and there's no partial word at cursor + if (!partial && preceding.length) { + var afterField = preceding.match(/([A-Za-z_][A-Za-z0-9_.]*)$/); + if (afterField && !lastTok) { + var ops = OPERATORS.map(function(o) { return { value: o.op, kind: 'op', desc: o.desc }; }); + return { suggestions: ops, replaceStart: replaceStart, replaceEnd: replaceEnd }; + } + } + + // Case C: default → field name suggestions (incl. dynamic payload.* keys) + var fieldItems = FIELDS.map(function(f) { return { value: f.name, desc: f.desc }; }); + if (Array.isArray(opts.payloadKeys)) { + var have = {}; + for (var z = 0; z < fieldItems.length; z++) have[fieldItems[z].value] = true; + for (var y = 0; y < opts.payloadKeys.length; y++) { + var pkey = 'payload.' + opts.payloadKeys[y]; + if (!have[pkey]) fieldItems.push({ value: pkey, desc: 'Decoded payload field (dynamic)' }); + } + } + return { suggestions: makePrefixSuggestions(fieldItems, 'field'), replaceStart: replaceStart, replaceEnd: replaceEnd }; + } + + var _exports = { + parse: parse, evaluate: evaluate, compile: compile, + FIELDS: FIELDS, OPERATORS: OPERATORS, + TYPE_VALUES: TYPE_VALUES, ROUTE_VALUES: ROUTE_VALUES, + suggest: suggest, + }; if (typeof window !== 'undefined') window.PacketFilter = _exports; // ── Self-tests (Node.js only) ───────────────────────────────────────────── diff --git a/public/packets.js b/public/packets.js index 37f834b8..28d8c161 100644 --- a/public/packets.js +++ b/public/packets.js @@ -805,7 +805,7 @@ -
    +
    ${isSingle ? '' : (isExpanded ? '▼' : '▶')} ${groupRegion ? `${groupRegion}` : '—'} ${renderTimestampCell(p.latest)} - ${truncate(p.hash || '—', 8)} - ${groupSize ? groupSize + 'B' : '—'} + ${truncate(p.hash || '—', 8)} + ${groupSize ? groupSize + 'B' : '—'} ${groupHashBytes} - ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} - ${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')} + ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} + ${isSingle ? truncate(obsName(headerObserverId), 16) : truncate(obsName(headerObserverId), 10) + (p.observer_count > 1 ? ' +' + (p.observer_count - 1) : '')} ${groupPathStr} ${p.observation_count > 1 ? '👁 ' + p.observation_count + '' : (isSingle ? '' : p.count)} ${getDetailPreview(getParsedDecoded(p))} @@ -1487,11 +1493,11 @@ html += ` ${childRegion ? `${childRegion}` : '—'} ${renderTimestampCell(c.timestamp)} - ${truncate(c.hash || '', 8)} - ${size}B + ${truncate(c.hash || '', 8)} + ${size}B ${childHashBytes} - ${typeName}${transportBadge(c.route_type)} - ${truncate(obsName(c.observer_id), 16)} + ${typeName}${transportBadge(c.route_type)} + ${truncate(obsName(c.observer_id), 16)} ${childPathStr} ${getDetailPreview(getParsedDecoded(c))} @@ -1519,11 +1525,11 @@ return ` ${region ? `${region}` : '—'} ${renderTimestampCell(p.timestamp)} - ${truncate(p.hash || String(p.id), 8)} - ${size}B + ${truncate(p.hash || String(p.id), 8)} + ${size}B ${hashBytes} - ${typeName}${transportBadge(p.route_type)} - ${truncate(obsName(p.observer_id), 16)} + ${typeName}${transportBadge(p.route_type)} + ${truncate(obsName(p.observer_id), 16)} ${pathStr} ${detail} diff --git a/public/style.css b/public/style.css index 9719bba5..783c704d 100644 --- a/public/style.css +++ b/public/style.css @@ -2766,3 +2766,51 @@ th.sort-active { color: var(--accent, #60a5fa); } .ch-section-toggle:hover { color: var(--text); } .ch-section-empty { padding: 8px 12px; font-size: 12px; color: var(--text-muted); font-style: italic; } .ch-section-caret { display: inline-block; width: 10px; } + +/* ── Filter UX (issue #966) ────────────────────────────────────────────── */ +.fux-bar { display: flex; gap: 6px; margin-top: 4px; align-items: center; flex-wrap: wrap; position: relative; } +.fux-help-btn, +.fux-saved-trigger { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; cursor: pointer; } +.fux-help-btn:hover, +.fux-saved-trigger:hover { background: var(--bg-hover, var(--surface)); } + +.fux-popover { position: fixed; top: 60px; right: 24px; width: min(720px, 92vw); max-height: 80vh; overflow: auto; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); z-index: 10000; padding: 0; } +.fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); } +.fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; } +.fux-popover-close:hover { color: var(--text); } +.fux-popover-body { padding: 12px 16px; font-size: 13px; } +.fux-popover-body h3, +.fux-popover-body h4 { margin: 12px 0 6px; } +.fux-table { width: 100%; border-collapse: collapse; font-size: 12px; margin: 4px 0 12px; } +.fux-table th, +.fux-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } +.fux-table th { color: var(--text-muted); font-weight: 600; } +.fux-mono { font-family: var(--mono); font-size: 12px; } +.fux-examples { margin: 4px 0; padding-left: 20px; } +.fux-examples li { margin: 2px 0; } + +.fux-ac-dropdown { position: absolute; left: 0; right: 0; top: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; max-height: 280px; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.25); margin-top: 2px; } +.fux-ac-item { padding: 4px 10px; display: flex; justify-content: space-between; gap: 12px; cursor: pointer; font-size: 12px; } +.fux-ac-item:hover, +.fux-ac-item.active { background: var(--bg-hover, rgba(120,160,255,0.12)); } +.fux-ac-val { font-family: var(--mono); color: var(--text); } +.fux-ac-desc { color: var(--text-muted); font-size: 11px; max-width: 60%; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.fux-ctx-menu { position: absolute; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; box-shadow: 0 4px 14px rgba(0,0,0,0.35); z-index: 10001; min-width: 200px; padding: 4px 0; } +.fux-ctx-item { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 5px 12px; font-size: 12px; cursor: pointer; font-family: var(--mono); } +.fux-ctx-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); } + +.fux-saved-menu { position: absolute; top: 100%; left: 0; min-width: 320px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; z-index: 9999; box-shadow: 0 4px 14px rgba(0,0,0,0.3); margin-top: 4px; padding: 4px 0; } +.fux-saved-menu.hidden { display: none; } +.fux-saved-header { padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--border); } +.fux-saved-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; font-size: 12px; } +.fux-saved-item:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); } +.fux-saved-name { font-weight: 600; min-width: 120px; } +.fux-saved-expr { color: var(--text-muted); font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fux-saved-del { background: transparent; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 0 4px; } +.fux-saved-del:hover { color: var(--status-red, #ef4444); } +.fux-saved-footer { border-top: 1px solid var(--border); padding: 4px 0; } +.fux-saved-save { display: block; width: 100%; text-align: left; background: transparent; border: none; color: var(--text); padding: 6px 10px; font-size: 12px; cursor: pointer; } +.fux-saved-save:hover { background: var(--bg-hover, rgba(120,160,255,0.12)); } + +td[data-filter-field] { cursor: context-menu; } diff --git a/test-all.sh b/test-all.sh index 9d867b90..c0e23b68 100755 --- a/test-all.sh +++ b/test-all.sh @@ -10,6 +10,7 @@ echo "" # Unit tests (deterministic, fast) echo "── Unit Tests ──" node test-packet-filter.js +node test-packet-filter-ux.js node test-aging.js node test-frontend-helpers.js node test-url-state.js diff --git a/test-filter-ux-e2e.js b/test-filter-ux-e2e.js new file mode 100644 index 00000000..dc008667 --- /dev/null +++ b/test-filter-ux-e2e.js @@ -0,0 +1,180 @@ +/** + * E2E (#966): Wireshark-style filter UX. + * + * Boots Chromium against a local corescope-server (defaults to fixture instance + * on :39966) and exercises: + * - Help button opens popover with field/operator reference + * - Autocomplete dropdown appears as user types and accepts on Enter + * - Right-click on a packet table cell opens "Filter by this value" menu + * and clicking populates the filter input + * - Saved-filter dropdown lists default starter filters + * + * Usage: BASE_URL=http://localhost:39966 node test-filter-ux-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:39966'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + console.log(`\n=== #966 filter UX E2E against ${BASE} ===`); + + await step('navigate to /packets', async () => { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#packetFilterInput', { timeout: 8000 }); + await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 }); + }); + + await step('PacketFilter metadata is exposed in window', async () => { + const meta = await page.evaluate(() => ({ + fields: window.PacketFilter && Array.isArray(window.PacketFilter.FIELDS) && window.PacketFilter.FIELDS.length, + ops: window.PacketFilter && Array.isArray(window.PacketFilter.OPERATORS) && window.PacketFilter.OPERATORS.length, + types: window.PacketFilter && Array.isArray(window.PacketFilter.TYPE_VALUES) && window.PacketFilter.TYPE_VALUES.length, + hasSuggest: typeof window.PacketFilter.suggest === 'function', + })); + assert(meta.fields >= 10, 'FIELDS not populated: ' + JSON.stringify(meta)); + assert(meta.ops >= 8, 'OPERATORS not populated'); + assert(meta.types >= 5, 'TYPE_VALUES not populated'); + assert(meta.hasSuggest, 'suggest() missing'); + }); + + await step('Help button opens popover with field reference', async () => { + await page.click('#filterHelpBtn'); + await page.waitForSelector('#filterHelpPopover', { timeout: 3000 }); + const txt = await page.textContent('#filterHelpPopover'); + assert(/Filter syntax/i.test(txt), 'header missing'); + assert(/payload\.name/.test(txt), 'fields table missing payload.name'); + assert(/contains/.test(txt), 'operators missing'); + assert(/ADVERT/.test(txt), 'examples missing'); + // Close it + await page.click('#filterHelpPopover .fux-popover-close'); + await page.waitForFunction(() => !document.getElementById('filterHelpPopover'), { timeout: 3000 }); + }); + + await step('Autocomplete dropdown appears on focus and filters by prefix', async () => { + await page.click('#packetFilterInput'); + await page.fill('#packetFilterInput', ''); + await page.keyboard.type('pay'); + await page.waitForSelector('#filterAcDropdown .fux-ac-item', { timeout: 3000 }); + const items = await page.$$eval('#filterAcDropdown .fux-ac-item .fux-ac-val', els => els.map(e => e.textContent)); + assert(items.some(v => v.startsWith('payload')), 'no payload* in dropdown: ' + items.join(',')); + }); + + await step('Autocomplete accepts on Enter and updates input', async () => { + await page.fill('#packetFilterInput', ''); + await page.click('#packetFilterInput'); + await page.keyboard.type('typ'); + await page.waitForSelector('#filterAcDropdown .fux-ac-item.active', { timeout: 3000 }); + await page.keyboard.press('Enter'); + const val = await page.inputValue('#packetFilterInput'); + assert(/^type/.test(val), 'expected `type` after accept, got: ' + val); + }); + + await step('Saved-filter dropdown lists default starters', async () => { + // Reset LS so defaults are unmodified + await page.evaluate(() => { try { localStorage.removeItem('corescope_saved_filters_v1'); } catch (e) {} }); + await page.click('#filterSavedTrigger'); + await page.waitForSelector('#filterSavedMenu:not(.hidden)', { timeout: 3000 }); + const names = await page.$$eval('#filterSavedMenu .fux-saved-name', els => els.map(e => e.textContent)); + assert(names.length >= 5, 'expected ≥ 5 default filters, got: ' + names.length); + assert(names.some(n => /Adverts only/i.test(n)), 'Adverts only missing: ' + names.join('|')); + assert(names.some(n => /Strong signal/i.test(n)), 'Strong signal missing: ' + names.join('|')); + }); + + await step('Clicking a saved filter populates the input and applies it', async () => { + // Click the "Adverts only" entry + await page.evaluate(() => { + const items = document.querySelectorAll('#filterSavedMenu .fux-saved-item'); + for (const it of items) { if (/Adverts only/i.test(it.textContent)) { it.click(); break; } } + }); + await page.waitForFunction(() => /type\s*==\s*ADVERT/i.test(document.getElementById('packetFilterInput').value), { timeout: 3000 }); + const val = await page.inputValue('#packetFilterInput'); + assert(/type\s*==\s*ADVERT/i.test(val), 'expected Adverts expr, got: ' + val); + }); + + await step('Right-click on a type cell opens context menu and appends a clause', async () => { + // Reset filter + await page.fill('#packetFilterInput', ''); + await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true }))); + // Widen time window so fixture rows render + await page.evaluate(() => { + const sel = document.getElementById('fTimeWindow'); + if (sel) { + sel.value = '0'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + // Wait for the table to populate with cells that have a real value + await page.waitForFunction(() => { + const cells = document.querySelectorAll('#pktBody td[data-filter-field="type"]'); + for (const c of cells) { + const v = c.getAttribute('data-filter-value'); + if (v && v !== '—' && v !== '') return true; + } + return false; + }, { timeout: 8000 }); + // Dispatch contextmenu event programmatically (Playwright headless mouse + // right-click does not reliably trigger 'contextmenu' DOM events). + const result = await page.evaluate(() => { + const cell = Array.from(document.querySelectorAll('#pktBody td[data-filter-field="type"]')) + .find(c => { + const v = c.getAttribute('data-filter-value'); + return v && v !== '—' && v !== ''; + }); + if (!cell) return { error: 'no type cell with value' }; + const rect = cell.getBoundingClientRect(); + const ev = new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, button: 2, + clientX: rect.left + 5, clientY: rect.top + 5, + }); + cell.dispatchEvent(ev); + const menu = document.getElementById('filterContextMenu'); + if (!menu) return { error: 'context menu not opened' }; + const items = Array.from(menu.querySelectorAll('.fux-ctx-item')).map(i => i.textContent); + // Click the first item (== filter) + menu.querySelector('.fux-ctx-item').click(); + return { items, inputAfter: document.getElementById('packetFilterInput').value }; + }); + assert(!result.error, 'menu open failed: ' + (result.error || '')); + assert(result.items.length === 3, 'expected 3 menu items, got: ' + result.items.length); + assert(/type\s*==\s*/.test(result.inputAfter), 'expected type clause appended, got: ' + result.inputAfter); + }); + + await step('Save current expression persists to localStorage', async () => { + await page.fill('#packetFilterInput', 'snr > 7'); + await page.evaluate(() => document.getElementById('packetFilterInput').dispatchEvent(new Event('input', { bubbles: true }))); + await page.click('#filterSavedTrigger'); + await page.waitForSelector('#filterSavedMenu:not(.hidden)'); + // Stub prompt + await page.evaluate(() => { window.prompt = () => 'E2E test filter'; }); + await page.click('#filterSaveCurrent'); + await page.waitForFunction(() => { + const raw = localStorage.getItem('corescope_saved_filters_v1') || ''; + return /E2E test filter/.test(raw) && /snr > 7/.test(raw); + }, { timeout: 3000 }); + // Cleanup + await page.evaluate(() => localStorage.removeItem('corescope_saved_filters_v1')); + }); + + await browser.close(); + + console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test-packet-filter-ux.js b/test-packet-filter-ux.js new file mode 100644 index 00000000..042221ec --- /dev/null +++ b/test-packet-filter-ux.js @@ -0,0 +1,190 @@ +/* Unit tests for filter UX helpers: PacketFilter metadata + autocomplete + + * SavedFilters store (issue #966). Pure-logic only — DOM exercised by E2E. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); + +function loadInCtx(files, ctx) { + for (const f of files) vm.runInContext(fs.readFileSync(f, 'utf8'), ctx); +} + +// Fake DOM-less window with a localStorage shim so filter-ux.js can be loaded +// in Node without touching the document object model. +function makeCtx() { + const store = {}; + const ls = { + getItem: k => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null, + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, + }; + // Minimal document stub — filter-ux.js init() must early-exit when DOM missing. + const doc = { getElementById: () => null, addEventListener: () => {}, body: null }; + const win = { localStorage: ls, document: doc, addEventListener: () => {} }; + const ctx = { window: win, document: doc, localStorage: ls, console }; + vm.createContext(ctx); + return ctx; +} + +let pass = 0, fail = 0; +function test(name, fn) { + try { fn(); pass++; console.log(' ✓ ' + name); } + catch (e) { console.log(' ✗ ' + name + ' — ' + e.message); fail++; } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const ctx = makeCtx(); +loadInCtx(['public/packet-filter.js', 'public/filter-ux.js'], ctx); +const PF = ctx.window.PacketFilter; +const UX = ctx.window.FilterUX; + +console.log('\n=== #966 filter-UX unit tests ==='); + +// ── Metadata exposed by PacketFilter ────────────────────────────────────── +test('PacketFilter.FIELDS exposes top-level fields', () => { + assert(Array.isArray(PF.FIELDS), 'FIELDS is array'); + const names = PF.FIELDS.map(f => f.name); + for (const want of ['type', 'route', 'snr', 'rssi', 'hops', 'observer', 'hash', 'size', 'age']) { + assert(names.includes(want), 'FIELDS missing ' + want); + } + for (const f of PF.FIELDS) { assert(typeof f.desc === 'string' && f.desc.length, 'desc required for ' + f.name); } +}); + +test('PacketFilter.OPERATORS lists comparison operators with examples', () => { + assert(Array.isArray(PF.OPERATORS), 'OPERATORS array'); + const ops = PF.OPERATORS.map(o => o.op); + for (const want of ['==', '!=', '>', '<', '>=', '<=', 'contains', 'starts_with']) { + assert(ops.includes(want), 'OPERATORS missing ' + want); + } + for (const o of PF.OPERATORS) { assert(typeof o.example === 'string' && o.example.length, 'example required for ' + o.op); } +}); + +test('PacketFilter.TYPE_VALUES exposes canonical type names', () => { + assert(Array.isArray(PF.TYPE_VALUES)); + for (const want of ['ADVERT', 'GRP_TXT', 'GRP_DATA', 'TXT_MSG', 'ACK']) { + assert(PF.TYPE_VALUES.includes(want), 'TYPE_VALUES missing ' + want); + } +}); + +test('PacketFilter.ROUTE_VALUES exposes route names', () => { + assert(Array.isArray(PF.ROUTE_VALUES)); + for (const want of ['FLOOD', 'DIRECT', 'TRANSPORT_FLOOD', 'TRANSPORT_DIRECT']) { + assert(PF.ROUTE_VALUES.includes(want), 'ROUTE_VALUES missing ' + want); + } +}); + +// ── Autocomplete suggestions ────────────────────────────────────────────── +test('suggest() on empty input returns top-level fields', () => { + const r = PF.suggest('', 0); + assert(r && Array.isArray(r.suggestions), 'returns object with suggestions'); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('type'), 'suggests type'); + assert(vals.includes('snr'), 'suggests snr'); +}); + +test('suggest() prefix-matches field names', () => { + const r = PF.suggest('pay', 3); + const vals = r.suggestions.map(s => s.value); + // payload.* aliases or payload_bytes/payload_hex should surface + assert(vals.some(v => v.startsWith('payload')), 'no payload* suggestion: ' + vals.join(',')); + assert(r.replaceStart === 0 && r.replaceEnd === 3, 'replace range covers prefix'); +}); + +test('suggest() after `type ==` lists type values', () => { + const r = PF.suggest('type == ', 8); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('ADVERT'), 'ADVERT in type values'); + assert(vals.includes('GRP_TXT'), 'GRP_TXT in type values'); +}); + +test('suggest() after `type == AD` filters type values', () => { + const r = PF.suggest('type == AD', 10); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('ADVERT'), 'ADVERT matches AD prefix'); + assert(!vals.includes('GRP_TXT'), 'GRP_TXT filtered out'); +}); + +test('suggest() after `route ==` lists route values', () => { + const r = PF.suggest('route == ', 9); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('FLOOD'), 'FLOOD route'); + assert(vals.includes('DIRECT'), 'DIRECT route'); +}); + +test('suggest() after operator suggests operators when no field given yet (no crash)', () => { + const r = PF.suggest('snr ', 4); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('>') || vals.includes('==') || vals.includes('<'), 'op suggested: ' + vals.join(',')); +}); + +test('suggest() includes payload.* keys from dynamic discovery', () => { + const r = PF.suggest('payload.', 8, { payloadKeys: ['name', 'lat', 'channelHash'] }); + const vals = r.suggestions.map(s => s.value); + assert(vals.includes('payload.name'), 'payload.name from dynamic keys'); + assert(vals.includes('payload.lat'), 'payload.lat from dynamic keys'); +}); + +// ── Improved parse-error positioning ────────────────────────────────────── +test('error message cites position for unknown character', () => { + const r = PF.parse('snr @ 5'); + assert(r.error && /position/i.test(r.error), 'error should cite position: ' + r.error); +}); + +// ── Saved filters store ─────────────────────────────────────────────────── +test('SavedFilters.defaults() returns at least 5 starter filters', () => { + const d = UX.SavedFilters.defaults(); + assert(Array.isArray(d) && d.length >= 5, 'defaults length ≥ 5: ' + (d && d.length)); + for (const f of d) { assert(f.name && f.expr, 'each default has name + expr'); } +}); + +test('SavedFilters.list() includes defaults when nothing saved', () => { + ctx.window.localStorage.clear(); + const list = UX.SavedFilters.list(); + assert(list.length >= 5, 'list seeded with defaults'); +}); + +test('SavedFilters.save() persists to localStorage and survives list()', () => { + ctx.window.localStorage.clear(); + UX.SavedFilters.save('my filter', 'snr > 10'); + const list = UX.SavedFilters.list(); + const found = list.find(f => f.name === 'my filter' && f.expr === 'snr > 10'); + assert(found, 'saved filter present in list'); + // Must persist to LS, not memory + const raw = ctx.window.localStorage.getItem('corescope_saved_filters_v1'); + assert(raw && raw.includes('snr > 10'), 'persisted to LS: ' + raw); +}); + +test('SavedFilters.delete() removes user filter but keeps defaults', () => { + ctx.window.localStorage.clear(); + UX.SavedFilters.save('temp', 'hops > 0'); + UX.SavedFilters.delete('temp'); + const list = UX.SavedFilters.list(); + assert(!list.find(f => f.name === 'temp'), 'temp removed'); + assert(list.length >= 5, 'defaults still present'); +}); + +test('SavedFilters.save() overwrites existing user filter with same name', () => { + ctx.window.localStorage.clear(); + UX.SavedFilters.save('x', 'snr > 1'); + UX.SavedFilters.save('x', 'snr > 99'); + const list = UX.SavedFilters.list(); + const matches = list.filter(f => f.name === 'x'); + assert(matches.length === 1, 'no duplicate name'); + assert(matches[0].expr === 'snr > 99', 'overwritten to latest'); +}); + +// ── Filter-by-cell helper (for right-click) ─────────────────────────────── +test('buildCellFilterClause() emits field == "value" with quoting for strings', () => { + assert(UX.buildCellFilterClause('observer', 'Dorrington', '==') === 'observer == "Dorrington"'); + assert(UX.buildCellFilterClause('snr', '8.5', '==') === 'snr == 8.5'); + assert(UX.buildCellFilterClause('type', 'ADVERT', '==') === 'type == ADVERT'); +}); + +test('appendClauseToExpr() appends with && when expr present', () => { + assert(UX.appendClauseToExpr('', 'snr > 5') === 'snr > 5'); + assert(UX.appendClauseToExpr('type == ADVERT', 'snr > 5') === 'type == ADVERT && snr > 5'); +}); + +console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`); +process.exit(fail > 0 ? 1 : 0);