diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 833573c2b..a92847d54 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -138,6 +138,8 @@ jobs: set -e node test-packet-filter.js node test-packet-filter-time.js + node test-issue-121-clear-filters-selection.js + node test-clear-filters.js node test-confidence-indicator.js node test-1659-analytics-warmup.js node test-app-api-inflight-cleanup-rejection.js diff --git a/public/packets.js b/public/packets.js index a3c505902..aa86bb18e 100644 --- a/public/packets.js +++ b/public/packets.js @@ -791,6 +791,14 @@ } window.buildPacketsQuery = buildPacketsQuery; + // Show the Clear button whenever any filter (URL-backed or not) is active. + function updateClearFiltersVisibility() { + var cb = document.getElementById('clearFiltersBtn'); + if (!cb) return; + var active = !!(filters.hash || filters.node || filters.observer || filters.channel || filters.type || filters._filterExpr || filters.myNodes) || !!RegionFilter.getRegionParam() || savedTimeWindowMin !== DEFAULT_TIME_WINDOW; + cb.style.display = active ? '' : 'none'; + } + function updatePacketsUrl() { // Preserve any subpath after /packets (e.g. #/packets/). var cur = String(location.hash || ''); @@ -800,12 +808,7 @@ // Don't double-encode filters.hash when it's already the path segment. var skipHash = !!(filters.hash && subpath === '/' + filters.hash); history.replaceState(null, '', '#/packets' + subpath + buildPacketsQuery(savedTimeWindowMin, RegionFilter.getRegionParam(), skipHash)); - // Update clear-filters button visibility - var cb = document.getElementById('clearFiltersBtn'); - if (cb) { - var active = !!(filters.hash || filters.node || filters.observer || filters.channel || filters.type || filters._filterExpr || filters.myNodes) || !!RegionFilter.getRegionParam() || savedTimeWindowMin !== DEFAULT_TIME_WINDOW; - cb.style.display = active ? '' : 'none'; - } + updateClearFiltersVisibility(); } let filtersBuilt = false; @@ -1845,6 +1848,9 @@ if (filters.type) localStorage.setItem('meshcore-type-filter', filters.type); else localStorage.removeItem('meshcore-type-filter'); buildTypeMenu(); updateTypeTrigger(); + // Type is not in the URL, so leave the hash alone (updatePacketsUrl() + // would drop ?obs=/?viewPath=); only refresh the Clear button (#121). + updateClearFiltersVisibility(); renderTableRows(); }); @@ -1951,15 +1957,15 @@ document.getElementById('fChannel').value = ''; document.getElementById('fMyNodes').classList.remove('active'); - // Reset observer multi-select - var obMenu = document.getElementById('observerMenu'); - if (obMenu) obMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; }); - document.getElementById('observerTrigger').textContent = 'All Observers ▾'; - - // Reset type multi-select - var typeMenu = document.getElementById('typeMenu'); - if (typeMenu) typeMenu.querySelectorAll('input[type=checkbox]').forEach(function(cb) { cb.checked = false; }); - document.getElementById('typeTrigger').textContent = 'All Types ▾'; + // Reset observer and type multi-selects (#121): empty the selection + // Sets, not only the checkboxes, or the next pick adds to the old + // selection; rebuilding the menus checks "All Observers"/"All Types". + selectedObservers.clear(); + buildObserverMenu(); + updateObsTrigger(); + selectedTypes.clear(); + buildTypeMenu(); + updateTypeTrigger(); // Reset time window to default savedTimeWindowMin = DEFAULT_TIME_WINDOW; diff --git a/test-all.sh b/test-all.sh index 884db1880..e26284041 100755 --- a/test-all.sh +++ b/test-all.sh @@ -11,6 +11,8 @@ echo "" echo "── Unit Tests ──" node test-packet-filter.js node test-packet-filter-ux.js +node test-issue-121-clear-filters-selection.js +node test-clear-filters.js node test-aging.js node test-issue-1065-gesture-hints-gates.js node test-frontend-helpers.js diff --git a/test-clear-filters.js b/test-clear-filters.js index 1eda5be4f..23be1a26d 100644 --- a/test-clear-filters.js +++ b/test-clear-filters.js @@ -130,18 +130,34 @@ function extractClearHandler() { /** * Extract updatePacketsUrl function body */ -function extractUpdatePacketsUrl() { - const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8'); - const marker = 'function updatePacketsUrl()'; +function extractFunctionSource(src, marker) { const idx = src.indexOf(marker); - assert(idx !== -1, 'updatePacketsUrl not found'); + assert(idx !== -1, marker + ' not found'); const fnStart = src.indexOf('{', idx); let depth = 0, fnEnd = -1; for (let i = fnStart; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}') { depth--; if (depth === 0) { fnEnd = i; break; } } } - return src.substring(fnStart + 1, fnEnd); + return src.substring(idx, fnEnd + 1); +} +function extractUpdatePacketsUrl() { + const src = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8'); + const body = extractFunctionSource(src, 'function updatePacketsUrl()'); + // updatePacketsUrl() delegates button visibility to updateClearFiltersVisibility() + const vis = extractFunctionSource(src, 'function updateClearFiltersVisibility()'); + return vis + '\n' + body.substring(body.indexOf('{') + 1, body.length - 1); +} + +// Since #121 the handler empties the closure selection Sets and rebuilds +// both menus through the multi-select helpers in the same scope. +const MENU_PARAMS = ['selectedObservers', 'buildObserverMenu', 'updateObsTrigger', + 'selectedTypes', 'buildTypeMenu', 'updateTypeTrigger']; +function menuStubs() { + const s = { selectedObservers: new Set(['obs1']), selectedTypes: new Set(['4']), rebuilt: [] }; + s.args = [s.selectedObservers, () => s.rebuilt.push('observerMenu'), () => s.rebuilt.push('observerTrigger'), + s.selectedTypes, () => s.rebuilt.push('typeMenu'), () => s.rebuilt.push('typeTrigger')]; + return s; } const clearBody = extractClearHandler(); @@ -163,14 +179,15 @@ test('clear handler resets all filter keys to undefined/null/false', () => { // Build a function with the handler body and needed locals in scope const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody}; return { savedTimeWindowMin, _observerFilterSet };` ); const result = fn( filters, savedTimeWindowMin, DEFAULT_TIME_WINDOW, _observerFilterSet, ctx.localStorage, ctx.document, ctx.RegionFilter, - () => {}, () => {} // stubs for updatePacketsUrl and loadPackets + () => {}, () => {}, // stubs for updatePacketsUrl and loadPackets + ...menuStubs().args ); assert.strictEqual(filters.hash, undefined, 'hash not cleared'); @@ -194,13 +211,13 @@ test('clear handler resets savedTimeWindowMin to DEFAULT_TIME_WINDOW', () => { // The handler assigns to savedTimeWindowMin — we need to check the returned value const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody}; return { savedTimeWindowMin };` ); const result = fn( filters, 120, DEFAULT_TIME_WINDOW, _observerFilterSet, ctx.localStorage, ctx.document, ctx.RegionFilter, - () => {}, () => {} + () => {}, () => {}, ...menuStubs().args ); assert.strictEqual(result.savedTimeWindowMin, 15, 'savedTimeWindowMin not reset to default'); @@ -213,10 +230,10 @@ test('clear handler resets fTimeWindow dropdown value', () => { const filters = { myNodes: false }; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody}; return { savedTimeWindowMin };` ); - fn(filters, 120, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}); + fn(filters, 120, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}, ...menuStubs().args); assert.strictEqual(elements['fTimeWindow'].value, '15', 'fTimeWindow DOM not reset'); }); @@ -227,25 +244,27 @@ test('clear handler clears observer and type localStorage', () => { const filters = { myNodes: false }; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody};` ); - fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}); + fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}, ...menuStubs().args); assert.strictEqual(ctx.localStorage.getItem('meshcore-observer-filter'), null); assert.strictEqual(ctx.localStorage.getItem('meshcore-type-filter'), null); }); -test('clear handler unchecks observer/type multi-select checkboxes', () => { - const { ctx, checkboxes } = makeSandbox(); +test('clear handler empties the observer/type selections and rebuilds both menus', () => { + const { ctx } = makeSandbox(); const filters = { myNodes: false }; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody};` ); - fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}); - for (const cb of checkboxes['observerMenu']) assert.strictEqual(cb.checked, false, 'observer checkbox still checked'); - for (const cb of checkboxes['typeMenu']) assert.strictEqual(cb.checked, false, 'type checkbox still checked'); + const m = menuStubs(); + fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}, ...m.args); + assert.strictEqual(m.selectedObservers.size, 0, 'observer selection not emptied'); + assert.strictEqual(m.selectedTypes.size, 0, 'type selection not emptied'); + assert.deepStrictEqual(m.rebuilt, ['observerMenu', 'observerTrigger', 'typeMenu', 'typeTrigger']); }); test('clear handler resets RegionFilter', () => { @@ -254,10 +273,10 @@ test('clear handler resets RegionFilter', () => { const filters = { myNodes: false }; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', '_observerFilterSet', - 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', + 'localStorage', 'document', 'RegionFilter', 'updatePacketsUrl', 'loadPackets', ...MENU_PARAMS, `${clearBody};` ); - fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}); + fn(filters, 15, 15, null, ctx.localStorage, ctx.document, ctx.RegionFilter, () => {}, () => {}, ...menuStubs().args); assert.deepStrictEqual(regionState.selected, [], 'RegionFilter not cleared'); }); @@ -269,11 +288,11 @@ test('updatePacketsUrl shows clear button when time window != default', () => { const DEFAULT_TIME_WINDOW = 15; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'document', 'history', 'RegionFilter', 'buildPacketsQuery', + 'document', 'history', 'RegionFilter', 'buildPacketsQuery', 'location', updateUrlBody ); fn(filters, savedTimeWindowMin, DEFAULT_TIME_WINDOW, - ctx.document, ctx.history, ctx.RegionFilter, () => ''); + ctx.document, ctx.history, ctx.RegionFilter, () => '', ctx.location); assert.strictEqual(elements['clearFiltersBtn'].style.display, '', 'clear button should be visible when time window != default'); }); @@ -283,10 +302,10 @@ test('updatePacketsUrl hides clear button when all filters default', () => { const filters = {}; const fn = new Function( 'filters', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'document', 'history', 'RegionFilter', 'buildPacketsQuery', + 'document', 'history', 'RegionFilter', 'buildPacketsQuery', 'location', updateUrlBody ); - fn(filters, 15, 15, ctx.document, ctx.history, ctx.RegionFilter, () => ''); + fn(filters, 15, 15, ctx.document, ctx.history, ctx.RegionFilter, () => '', ctx.location); assert.strictEqual(elements['clearFiltersBtn'].style.display, 'none', 'clear button should be hidden'); }); diff --git a/test-issue-121-clear-filters-selection.js b/test-issue-121-clear-filters-selection.js new file mode 100644 index 000000000..baee3863f --- /dev/null +++ b/test-issue-121-clear-filters-selection.js @@ -0,0 +1,302 @@ +/* test-issue-121-clear-filters-selection.js — Clear Filters must empty the + * observer and type selections (#121). + * + * The Packets page keeps the observer and type picks in closure-owned Sets + * (selectedObservers, selectedTypes). This test evaluates the REAL code from + * public/packets.js — buildPacketsQuery(), updatePacketsUrl() and the whole + * filter-bar section from the observer multi-select through the Clear + * handler — in one scope, against a minimal fake DOM whose menus parse the + * checkbox markup the page writes. It then drives the menus and the Clear + * button the way a user does. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-issue-121-clear-filters-selection.js ---'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +const SRC = fs.readFileSync(__dirname + '/public/packets.js', 'utf-8'); + +// Source of the balanced {...} block that starts at the first '{' after `from`. +function blockEnd(src, from) { + const open = src.indexOf('{', from); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return i + 1; } + } + throw new Error('unbalanced block after ' + from); +} +function extractFunction(name) { + const start = SRC.indexOf('function ' + name + '('); + assert(start !== -1, name + ' not found in packets.js'); + return SRC.slice(start, blockEnd(SRC, start)); +} +// The filter-bar section: observer multi-select .. end of the Clear handler. +function extractFilterSection() { + const start = SRC.indexOf('// --- Observer multi-select ---'); + assert(start !== -1, 'observer multi-select section not found'); + const clear = SRC.indexOf("if (clearBtn) clearBtn.addEventListener('click', function()", start); + assert(clear !== -1, 'Clear handler not found'); + const end = SRC.indexOf(');', blockEnd(SRC, clear)) + 2; + return SRC.slice(start, end); +} +// updateClearFiltersVisibility() is optional here so the URL-preservation tests +// below can also run (and fail) against a revision that lacks it. +const VISIBILITY = SRC.includes('function updateClearFiltersVisibility(') + ? extractFunction('updateClearFiltersVisibility') + '\n' : ''; +const SECTION = extractFunction('buildPacketsQuery') + '\n' + VISIBILITY + + extractFunction('updatePacketsUrl') + '\n' + extractFilterSection(); + +// ---- a minimal DOM --------------------------------------------------------- + +function makeEl(id) { + const listeners = {}; + let items = []; // checkboxes parsed from innerHTML + let html = ''; + const classes = new Set(); + const el = { + id, value: '', textContent: '', title: '', style: { display: '' }, children: [], + classList: { + add: (...c) => c.forEach(x => classes.add(x)), + remove: (...c) => c.forEach(x => classes.delete(x)), + toggle: (c) => { if (classes.has(c)) classes.delete(c); else classes.add(c); }, + contains: (c) => classes.has(c), + }, + addEventListener: (ev, fn) => { (listeners[ev] = listeners[ev] || []).push(fn); }, + fire: (ev, e) => (listeners[ev] || []).forEach(fn => fn.call(el, e || {})), + listenerCount: (ev) => (listeners[ev] || []).length, + appendChild: (c) => { el.children.push(c); return c; }, + contains: () => true, + querySelector: () => null, + querySelectorAll: (sel) => (sel === 'input[type=checkbox]' ? items : []), + get items() { return items; }, + get innerHTML() { return html; }, + set innerHTML(v) { + html = String(v); + items = []; + const re = /]*)>/g; + let m; + while ((m = re.exec(html))) { + const attrs = m[1]; + const id = /data-(?:obs|type)-id="([^"]*)"/.exec(attrs); + items.push({ id: id ? id[1] : null, checked: /\schecked\b/.test(attrs), disabled: /\sdisabled\b/.test(attrs) }); + } + }, + }; + return el; +} + +// Mount the section once, as init() does. `state` persists across mounts +// (localStorage, location), `filters` is what the module holds at mount time. +function mount(state, filters) { + const elements = {}; + const document = { + getElementById: (id) => elements[id] || (elements[id] = makeEl(id)), + createElement: (tag) => makeEl('_' + tag), + }; + const calls = { loadPackets: 0, renderTableRows: 0 }; + const sandbox = { + document, + location: state.location, + history: { replaceState: (_s, _t, url) => { state.location.hash = url; } }, + localStorage: { + getItem: (k) => (k in state.storage ? state.storage[k] : null), + setItem: (k, v) => { state.storage[k] = String(v); }, + removeItem: (k) => { delete state.storage[k]; }, + }, + window: {}, + RegionFilter: { + getRegionParam: () => state.region.join(','), + setSelected: (arr) => { state.region = Array.from(arr); }, // outer-realm array + }, + api: () => Promise.resolve({ channels: [] }), + escapeHtml: (s) => String(s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';'), + bindDocumentHandler: () => {}, + debounce: (fn) => fn, + loadPackets: () => { calls.loadPackets++; }, + renderTableRows: () => { calls.renderTableRows++; }, + filters, + observers: [{ id: 'obsA', name: 'Alpha' }, { id: 'obsB', name: 'Bravo' }, { id: 'obsC', name: 'Charlie' }], + SHORT_BY_ID: { 0: 'REQ', 4: 'ADVERT', 5: 'GRP_TXT', 9: 'TRACE' }, + DEFAULT_TIME_WINDOW: 15, + savedTimeWindowMin: 15, + _observerFilterSet: null, + _rebuildObserverMenu: null, + _packetSortColumn: null, + _packetSortDirection: 'desc', + }; + sandbox.observerMap = new Map(sandbox.observers.map((o) => [o.id, o])); + vm.createContext(sandbox); + vm.runInContext(SECTION, sandbox); + // init() calls updatePacketsUrl() right after the Clear handler is wired + // ("Show clear button if page loaded with active filters"). + vm.runInContext('updatePacketsUrl()', sandbox); + + const $ = (id) => document.getElementById(id); + function pick(menuId, attr, id) { + const menu = $(menuId); + const item = menu.items.find((x) => x.id === id); + assert(item, `no ${id} row in #${menuId}`); + item.checked = id === '__all__' ? true : !item.checked; + menu.fire('change', { target: { dataset: { [attr]: id }, checked: item.checked } }); + } + return { + filters, calls, $, sandbox, + pickObserver: (id) => pick('observerMenu', 'obsId', id), + pickType: (id) => pick('typeMenu', 'typeId', id), + clear: () => $('clearFiltersBtn').fire('click'), + checked: (menuId) => $(menuId).items.filter((x) => x.checked).map((x) => x.id), + }; +} + +function newState() { + return { storage: {}, location: { hash: '#/packets' }, region: [] }; +} + +// ---- tests ------------------------------------------------------------------- + +test('observer: select A, Clear, select B -> only B, everywhere', () => { + const st = newState(); + const p = mount(st, {}); + p.pickObserver('obsA'); + assert.strictEqual(p.filters.observer, 'obsA', 'fixture: A selected'); + p.clear(); + p.pickObserver('obsB'); + assert.strictEqual(p.filters.observer, 'obsB', 'filters.observer'); + assert.strictEqual(st.storage['meshcore-observer-filter'], 'obsB', 'localStorage'); + assert(/[?&]observer=obsB(&|$)/.test(st.location.hash) && !/obsA/.test(st.location.hash), 'URL ' + st.location.hash); + assert.deepStrictEqual(p.checked('observerMenu'), ['obsB'], 'menu checkboxes'); + assert.strictEqual(p.$('observerTrigger').textContent, 'Bravo ▾', 'trigger label'); +}); + +test('type: select one type, Clear, select another -> only the new type', () => { + const st = newState(); + const p = mount(st, {}); + p.pickType('4'); + p.clear(); + p.pickType('5'); + assert.strictEqual(p.filters.type, '5', 'filters.type'); + assert.strictEqual(st.storage['meshcore-type-filter'], '5', 'localStorage'); + assert.deepStrictEqual(p.checked('typeMenu'), ['5'], 'menu checkboxes'); + assert.strictEqual(p.$('typeTrigger').textContent, 'GRP_TXT ▾', 'trigger label'); + assert.strictEqual(p.$('typeTrigger').title, 'Selected: GRP_TXT', 'trigger title'); +}); + +test('after Clear: "All Observers" and "All Types" are checked, labels and titles reset', () => { + const st = newState(); + const p = mount(st, {}); + p.pickObserver('obsA'); p.pickObserver('obsC'); + p.pickType('4'); p.pickType('9'); + p.clear(); + assert.deepStrictEqual(p.checked('observerMenu'), ['__all__'], 'observer menu'); + assert.deepStrictEqual(p.checked('typeMenu'), ['__all__'], 'type menu'); + assert.strictEqual(p.$('observerTrigger').textContent, 'All Observers ▾'); + assert.strictEqual(p.$('typeTrigger').textContent, 'All Types ▾'); + assert.strictEqual(p.$('typeTrigger').title, 'Filter by packet type'); + assert.strictEqual(p.filters.observer, undefined); + assert.strictEqual(p.filters.type, undefined); + assert(!('meshcore-observer-filter' in st.storage) && !('meshcore-type-filter' in st.storage), 'localStorage'); + assert(!/observer=/.test(st.location.hash), 'URL ' + st.location.hash); +}); + +test('observer and type together, repeated Clears, then new picks', () => { + const st = newState(); + const p = mount(st, {}); + p.pickObserver('obsA'); p.pickType('4'); + p.clear(); p.clear(); + p.pickObserver('obsB'); p.pickType('5'); + assert.strictEqual(p.filters.observer, 'obsB'); + assert.strictEqual(p.filters.type, '5'); + p.clear(); + p.pickObserver('obsC'); + assert.strictEqual(p.filters.observer, 'obsC'); + assert.strictEqual(p.filters.type, undefined); + assert.deepStrictEqual(p.checked('typeMenu'), ['__all__']); +}); + +test('Clear reloads packets exactly once and renders nothing extra', () => { + const st = newState(); + const p = mount(st, {}); + p.pickObserver('obsA'); p.pickType('4'); + const before = { ...p.calls }; + p.clear(); + assert.strictEqual(p.calls.loadPackets - before.loadPackets, 1, 'loadPackets'); + assert.strictEqual(p.calls.renderTableRows - before.renderTableRows, 0, 'renderTableRows'); +}); + +test('Clear is reachable with only a type selected (button shown), hidden again after', () => { + const st = newState(); + const p = mount(st, {}); + assert.strictEqual(p.$('clearFiltersBtn').style.display, 'none', 'fixture: hidden with no filter'); + p.pickType('4'); + assert.strictEqual(p.$('clearFiltersBtn').style.display, '', 'shown with only a type'); + p.clear(); + assert.strictEqual(p.$('clearFiltersBtn').style.display, 'none', 'hidden after Clear'); +}); + +// Picking a type must not rewrite the URL: type is not a URL parameter, so +// updatePacketsUrl() (which rebuilds the query from filters only) would drop +// ?obs= and ?viewPath= from #/packets/?... deep links. +for (const q of ['?obs=123', '?obs=123&viewPath=1']) { + test('type pick keeps ' + q + ' in the URL and shows Clear', () => { + const st = newState(); + const p = mount(st, {}); + // user is on a packet detail deep link (set after mount, as clicking a row does) + st.location.hash = '#/packets/abcd1234' + q; + p.pickType('4'); + assert.strictEqual(st.location.hash, '#/packets/abcd1234' + q, 'URL ' + st.location.hash); + assert(/obs=123/.test(st.location.hash), 'obs kept'); + if (q.includes('viewPath')) assert(/viewPath=1/.test(st.location.hash), 'viewPath kept'); + assert.strictEqual(p.$('clearFiltersBtn').style.display, '', 'Clear visible'); + p.pickType('4'); // deselect -> no filter -> hidden again, URL still untouched + assert.strictEqual(st.location.hash, '#/packets/abcd1234' + q, 'URL after deselect'); + assert.strictEqual(p.$('clearFiltersBtn').style.display, 'none', 'Clear hidden again'); + }); +} + +test('the other filters are still reset by Clear', () => { + const st = newState(); + st.region = ['CPH']; + const p = mount(st, { hash: 'abcd', node: 'n1', nodeName: 'n', channel: 'c', _filterExpr: 'x', _packetFilter: () => true, myNodes: true }); + p.sandbox.savedTimeWindowMin = 60; + st.storage['meshcore-time-window'] = '60'; + p.clear(); + for (const k of ['hash', 'node', 'nodeName', 'channel', '_filterExpr']) assert.strictEqual(p.filters[k], undefined, k); + assert.strictEqual(p.filters._packetFilter, null); + assert.strictEqual(p.filters.myNodes, false); + assert.strictEqual(p.sandbox.savedTimeWindowMin, 15, 'time window'); + assert(!('meshcore-time-window' in st.storage), 'time-window storage'); + assert.deepStrictEqual(st.region, [], 'region'); + assert.strictEqual(st.location.hash, '#/packets', 'URL'); +}); + +test('SPA remount: a new mount starts from an empty selection after Clear', () => { + const st = newState(); + const p1 = mount(st, {}); + p1.pickObserver('obsA'); p1.pickType('4'); + p1.clear(); + // destroy() resets the module's filters to {}; the page is mounted again + const p2 = mount(st, {}); + assert.deepStrictEqual(p2.checked('observerMenu'), ['__all__']); + assert.deepStrictEqual(p2.checked('typeMenu'), ['__all__']); + p2.pickObserver('obsB'); p2.pickType('5'); + assert.strictEqual(p2.filters.observer, 'obsB'); + assert.strictEqual(p2.filters.type, '5'); + assert.strictEqual(st.storage['meshcore-observer-filter'], 'obsB'); + assert.strictEqual(p2.$('clearFiltersBtn').listenerCount('click'), 1, 'one Clear handler per mount'); + p2.clear(); + p2.pickObserver('obsC'); + assert.strictEqual(p2.filters.observer, 'obsC'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1);