+
${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);