diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 495d06c5f..0aed9391a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -429,6 +429,7 @@ jobs: BASE_URL=http://localhost:13581 node test-slideover-1056-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1692-packets-init-parallel-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-147-packets-url-obs-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/packet-path-map.js b/public/packet-path-map.js index b19ab2c51..7d512cc57 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -80,11 +80,30 @@ // only this top layer. Stopping it keeps the layers below (the packets // detail pane, a SlideOver), whose Escape handlers listen on document in // the bubble phase, open until the next Escape (#167). + // A layer opened over the modal afterwards (the global search via Ctrl+K, + // a nav menu, the More sheet, a filter popover) holds focus, and Escape is + // that layer's: the event is left alone then (#180). function onKeydown(e) { - if (e.key === 'Escape' && document.getElementById('packetPathModal')) { - e.stopPropagation(); - close(); - } + var overlay = document.getElementById('packetPathModal'); + if (e.key !== 'Escape' || !overlay || focusInLayerAbove(overlay, e.target)) return; + e.stopPropagation(); + close(); + } + + // True when el, the focused element, is in a floating layer drawn over the + // modal: outside it, inside a position:fixed ancestor other than the + // packets detail surfaces the modal opens over (SlideOver, mobile sheet), + // and topmost at its own centre, i.e. not under the modal's backdrop. The + // sticky top nav and the page are not floating layers, so Escape with focus + // there still closes the modal. + function focusInLayerAbove(overlay, el) { + if (!el || !el.getBoundingClientRect || overlay.contains(el)) return false; + var layer = el; + while (layer && layer !== document.body && getComputedStyle(layer).position !== 'fixed') layer = layer.parentElement; + if (!layer || layer === document.body || layer.matches('.slide-over-panel, .mobile-detail-sheet')) return false; + var r = el.getBoundingClientRect(); + var top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return !!top && layer.contains(top); } // A #/packets/?…&viewPath=1 URL describes this modal as open (#147). @@ -103,6 +122,41 @@ history.replaceState(null, '', h.slice(0, q) + (kept.length ? '?' + kept.join('&') : '')); } + // The modal belongs to the route it was opened on (#180): the hash path + // (#/packets/, without the query) at open(). A hashchange to another + // path -- Back/Forward, a nav link -- closes it. + var openPath = null; + // The id open() put in the history.state of the #/packets/ entry it + // opened on, and the ids of entries whose modal was closed while another + // route was shown. Such an entry still carries ?viewPath=1, so Back/Forward + // onto it must not reopen the modal (restore()). The id is per entry, so a + // new link to the same URL (no state) still opens it. Bounded list. + var openEntry = null; + var closedEntries = []; + var MAX_CLOSED_ENTRIES = 50; + + function hashPath(h) { + h = String(h || ''); + var q = h.indexOf('?'); + return q < 0 ? h : h.slice(0, q); + } + + function tagEntry() { + if (typeof history === 'undefined' || !history.replaceState || openPath.indexOf('#/packets/') !== 0) return null; + var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + var state = {}; + var cur = history.state; + if (cur && typeof cur === 'object') for (var k in cur) state[k] = cur[k]; + state.packetPathModal = id; + // No URL argument: only the entry's state changes. + history.replaceState(state, ''); + return id; + } + + function onHashChange() { + if (hashPath(location.hash) !== openPath) close(); + } + // Removes the modal; true when one was open. function removeModal() { var overlay = document.getElementById('packetPathModal'); @@ -112,11 +166,32 @@ activeMap = null; } document.removeEventListener('keydown', onKeydown, true); + window.removeEventListener('hashchange', onHashChange); return !!overlay; } function close() { - if (removeModal()) dropViewPathParam(); + var elsewhere = hashPath(location.hash) !== openPath; + var entry = openEntry; + if (!removeModal()) return; + openPath = openEntry = null; + if (!elsewhere) { + dropViewPathParam(); + } else if (entry) { + closedEntries.push(entry); + if (closedEntries.length > MAX_CLOSED_ENTRIES) closedEntries.shift(); + } + } + + // Reopens the modal that a #/packets/?…&viewPath=1 entry describes + // (packets.js init(): a shared link, a reload, Back/Forward), unless that + // entry's modal was closed while another route was shown (#180). Returns + // whether it opened; when not, packets.js drops ?viewPath=1 from the URL. + function restore(hash) { + var state = typeof history !== 'undefined' ? history.state : null; + if (state && state.packetPathModal && closedEntries.indexOf(state.packetPathModal) !== -1) return false; + open(hash); + return true; } // A short prefix marking a node's role in tooltips -- purely a label, @@ -207,6 +282,9 @@ var closeBtn = document.getElementById('packetPathClose'); if (closeBtn) closeBtn.addEventListener('click', close); document.addEventListener('keydown', onKeydown, true); + openPath = hashPath(location.hash); + openEntry = tagEntry(); + window.addEventListener('hashchange', onHashChange); // Shareable link: #/packets/?viewPath=1 -- packets.js's init() // (public/packets.js) checks for viewPath=1 and re-opens this exact @@ -580,5 +658,5 @@ if (statusEl) statusEl.textContent = statusParts.join(' · '); } - window.PacketPathMap = { open: open, close: close }; + window.PacketPathMap = { open: open, close: close, restore: restore }; })(); diff --git a/public/packets.js b/public/packets.js index 8ca06689e..f501936ec 100644 --- a/public/packets.js +++ b/public/packets.js @@ -816,8 +816,9 @@ // selection. That re-runs init(), whose cold-load call here writes the // restored filters back; the previous packet's ?obs= does not apply. // detail (optional): { subpath: '/' or '', obs: id or null } when - // the caller changes the selection; without it the current subpath and - // ?obs= are kept (filter changes, Clear Filters, cold load). + // the caller changes the selection or closes the detail (Clear Filters, + // #180); without it the current subpath and ?obs= are kept (filter + // changes, cold load). function updatePacketsUrl(detail) { var cur = String(location.hash || ''); // The packets list route: #/packets, #/packets/…, #/packets?…, or no @@ -842,7 +843,9 @@ if (pathModal && subpath === '/' + pathModal.dataset.hash) keep.push('viewPath=1'); } if (keep.length) query += (query ? '&' : '?') + keep.join('&'); - history.replaceState(null, '', '#/packets' + subpath + query); + // Keeps the entry's history.state: packet-path-map.js marks the entry + // its modal opened on there (#180). + history.replaceState(history.state, '', '#/packets' + subpath + query); updateClearFiltersVisibility(); } @@ -935,14 +938,21 @@ } function closeDetailPanel() { + // The ≤640 px bottom sheet as well: Escape used to reset only the + // desktop pane and never closed the sheet (#180). + var sheet = document.getElementById('mobileDetailSheet'); + if (sheet) sheet.classList.remove('open'); var panel = document.getElementById('pktRight'); if (panel) { panel.classList.add('empty'); panel.innerHTML = '
' + PANEL_CLOSE_HTML + 'Select a packet to view details'; var layout = panel.closest('.split-layout'); if (layout) layout.classList.add('detail-collapsed'); + // Re-render only to drop a row's selection highlight (#180: Clear + // Filters closes the detail and reloads the rows itself). + var wasSelected = selectedId !== null; selectedId = null; - renderTableRows(); + if (wasSelected) renderTableRows(); } } @@ -1173,11 +1183,13 @@ // link reopens the exact same modal instead of leaving the recipient // on the plain packet detail page. Independent of the packets-list // rendering below (the modal fetches its own data), so it's safe to - // fire immediately. + // fire immediately. restore() skips a history entry whose modal was + // closed on another page (#180); the cold-load updatePacketsUrl() below + // then drops ?viewPath=1, as the modal is not open. var _urlViewPath = _initUrlParams.get('viewPath'); if (_urlViewPath === '1') { var _viewPathHash = directPacketHash || filters.hash; - if (_viewPathHash && window.PacketPathMap) window.PacketPathMap.open(_viewPathHash); + if (_viewPathHash && window.PacketPathMap) window.PacketPathMap.restore(_viewPathHash); } var _urlNode = _initUrlParams.get('node'); if (_urlNode) { filters.node = _urlNode; filters.nodeName = _urlNode.slice(0, 8); } @@ -2019,8 +2031,14 @@ // Reset region filter RegionFilter.setSelected([]); + // Clear also leaves the packet detail (#180): a #/packets/ + // subpath sets filters.hash again on load, so after Clear the URL is + // the list and the detail it named is closed. + selectedObservationId = null; + closeDetailPanel(); + // Update URL and reload - updatePacketsUrl(); + updatePacketsUrl({ subpath: '', obs: null }); loadPackets(); }); // Show clear button if page loaded with active filters (e.g. from URL params) diff --git a/public/style.css b/public/style.css index 5b02ae965..079e3cd9d 100644 --- a/public/style.css +++ b/public/style.css @@ -1355,8 +1355,12 @@ img.brand-logo { * body.style.overflow capture-and-restore string approach which * corrupted overflow under last-writer-wins races. */ body.scroll-locked { overflow: hidden; } +/* #180: the SlideOver is a modal dialog (role=dialog, aria-modal, focus + * trap), so it stacks on the modal tokens, above the sticky .top-nav (1100). + * At 1000/1001 the panel's header and close button sat under the nav at + * 641–1023 px, and a click on × landed on the nav. */ .slide-over-backdrop { - position: fixed; inset: 0; z-index: 1000; + position: fixed; inset: 0; z-index: var(--z-modal-backdrop); background: rgba(0, 0, 0, 0.5); cursor: pointer; } @@ -1364,7 +1368,7 @@ body.scroll-locked { overflow: hidden; } .slide-over-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(480px, 90vw); - z-index: 1001; + z-index: var(--z-modal); background: var(--detail-bg, var(--card-bg, #1f2937)); color: var(--text, inherit); border-left: 1px solid var(--border); @@ -1392,7 +1396,7 @@ body.scroll-locked { overflow: hidden; } color: var(--text-muted, #94a3b8); padding: 4px 8px; border-radius: 4px; /* WCAG 2.5.5 / Apple HIG — 48px tap target on touch devices. */ - min-width: 44px; min-height: 44px; + min-width: 48px; min-height: 48px; display: inline-flex; align-items: center; justify-content: center; } .slide-over-close:hover { color: var(--text, inherit); background: var(--row-hover); } diff --git a/test-all.sh b/test-all.sh index 1eb9ecff4..775b4532b 100755 --- a/test-all.sh +++ b/test-all.sh @@ -33,6 +33,7 @@ run test-packet-filter-ux.js run test-issue-121-clear-filters-selection.js run test-clear-filters.js run test-issue-147-packets-url-detail-params.js +run test-issue-180-packets-detail-close.js run test-aging.js run test-issue-1065-gesture-hints-gates.js run test-frontend-helpers.js diff --git a/test-clear-filters.js b/test-clear-filters.js index 6ca07eac5..1e7b2da4d 100644 --- a/test-clear-filters.js +++ b/test-clear-filters.js @@ -152,13 +152,15 @@ function extractUpdatePacketsUrl() { } // Since #121 the handler empties the closure selection Sets and rebuilds -// both menus through the multi-select helpers in the same scope. +// both menus through the multi-select helpers in the same scope. Since #180 +// it also closes the packet detail (closeDetailPanel, selectedObservationId). const MENU_PARAMS = ['selectedObservers', 'buildObserverMenu', 'updateObsTrigger', - 'selectedTypes', 'buildTypeMenu', 'updateTypeTrigger']; + 'selectedTypes', 'buildTypeMenu', 'updateTypeTrigger', 'closeDetailPanel', 'selectedObservationId']; function menuStubs() { - const s = { selectedObservers: new Set(['obs1']), selectedTypes: new Set(['4']), rebuilt: [] }; + const s = { selectedObservers: new Set(['obs1']), selectedTypes: new Set(['4']), rebuilt: [], detailClosed: 0 }; s.args = [s.selectedObservers, () => s.rebuilt.push('observerMenu'), () => s.rebuilt.push('observerTrigger'), - s.selectedTypes, () => s.rebuilt.push('typeMenu'), () => s.rebuilt.push('typeTrigger')]; + s.selectedTypes, () => s.rebuilt.push('typeMenu'), () => s.rebuilt.push('typeTrigger'), + () => { s.detailClosed++; }, '123']; return s; } @@ -267,6 +269,7 @@ test('clear handler empties the observer/type selections and rebuilds both menus 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']); + assert.strictEqual(m.detailClosed, 1, 'packet detail not closed (#180)'); }); test('clear handler resets RegionFilter', () => { diff --git a/test-issue-121-clear-filters-selection.js b/test-issue-121-clear-filters-selection.js index baee3863f..4e3deb429 100644 --- a/test-issue-121-clear-filters-selection.js +++ b/test-issue-121-clear-filters-selection.js @@ -103,7 +103,7 @@ function mount(state, filters) { getElementById: (id) => elements[id] || (elements[id] = makeEl(id)), createElement: (tag) => makeEl('_' + tag), }; - const calls = { loadPackets: 0, renderTableRows: 0 }; + const calls = { loadPackets: 0, renderTableRows: 0, closeDetailPanel: 0 }; const sandbox = { document, location: state.location, @@ -124,6 +124,9 @@ function mount(state, filters) { debounce: (fn) => fn, loadPackets: () => { calls.loadPackets++; }, renderTableRows: () => { calls.renderTableRows++; }, + // #180: Clear Filters also closes the packet detail. + closeDetailPanel: () => { calls.closeDetailPanel++; }, + selectedObservationId: null, 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' }, diff --git a/test-issue-147-packets-url-detail-params.js b/test-issue-147-packets-url-detail-params.js index 681b6b32b..36ad31b91 100644 --- a/test-issue-147-packets-url-detail-params.js +++ b/test-issue-147-packets-url-detail-params.js @@ -3,8 +3,9 @@ * updatePacketsUrl() rebuilds #/packets/?… from buildPacketsQuery(), * which only knows filter params. The packet-detail params it does not own, * ?obs= (selected observation) and ?viewPath=1 (View Path modal), must - * survive: on cold load (init() calls it to show the Clear button), on every - * filter change and on Clear Filters. + * survive: on cold load (init() calls it to show the Clear button) and on + * every filter change. Clear Filters leaves the detail, so it drops them with + * the subpath (#180). * * Decision under test: ?obs= travels with the detail subpath; ?viewPath=1 * describes the open View Path modal, so it is in the URL exactly while that @@ -81,7 +82,7 @@ function makeSandbox(startHash, opts) { console, URLSearchParams, encodeURIComponent, decodeURIComponent, String, Number, Array, Object, RegExp, JSON, Math, Set, Map, Error, location: { hash: startHash }, - history: { replaceState(_s, _t, url) { urls.push(url); ctx.location.hash = url; } }, + history: { state: opts.state === undefined ? null : opts.state, replaceState(st, _t, url) { urls.push(url); ctx.history.state = st; ctx.location.hash = url; } }, document: { getElementById: (id) => els[id] || null }, localStorage: { getItem: (k) => (k in store ? store[k] : null), @@ -105,6 +106,8 @@ function makeSandbox(startHash, opts) { 'var _observerFilterSet = null; var selectedObservers = new Set(); var selectedTypes = new Set();\n' + 'function buildObserverMenu() {} function updateObsTrigger() {} function buildTypeMenu() {}\n' + 'function updateTypeTrigger() {} function loadPackets() {}\n' + + // #180: Clear Filters closes the detail; record that it did. + 'var selectedObservationId = null; var __detailClosed = 0; function closeDetailPanel() { __detailClosed++; }\n' + URL_FUNCS + '\nfunction __clearFilters() {' + CLEAR_BODY + '}', ctx, { filename: 'packets.js (#147 extract)' }); return { @@ -231,19 +234,33 @@ test('list view (#/packets, no detail) gets no ?obs=', () => { assert.strictEqual(s.update(), '#/packets?observer=OBS1'); }); -// ---- Clear Filters (#121/#132): filter params go, detail subpath + obs stay ---- +// ---- Clear Filters (#121/#132): filter params go ---- +// #180 decision: Clear also leaves the detail. A #/packets/ subpath +// sets filters.hash again on load, so a Clear that kept it was undone by a +// reload. Clear closes the detail and writes the list URL #/packets?…. -test('Clear Filters on a detail URL removes filter params, keeps subpath and ?obs=', () => { +test('Clear Filters on a detail URL removes filter params and the detail (subpath, ?obs=) (#180)', () => { const s = makeSandbox(DETAIL + '?timeWindow=60®ion=EU&observer=OBS1&obs=123'); - s.set("filters.hash = 'abc123'; filters.observer = 'OBS1'; savedTimeWindowMin = 60; __region = ['EU']"); - assert.strictEqual(s.clear(), DETAIL + '?obs=123'); + s.set("filters.hash = 'abc123'; filters.observer = 'OBS1'; savedTimeWindowMin = 60; __region = ['EU']; selectedObservationId = '123'"); + assert.strictEqual(s.clear(), '#/packets'); assert.strictEqual(s.els.clearFiltersBtn.style.display, 'none', 'Clear button still visible after clearing'); + assert.strictEqual(s.ctx.__detailClosed, 1, 'Clear did not close the detail'); + assert.strictEqual(s.ctx.selectedObservationId, null, 'selected observation kept'); }); -test('Clear Filters on a detail URL drops viewPath=1 when the modal is closed', () => { +test('Clear Filters on a detail URL also drops viewPath=1 (#180)', () => { const s = makeSandbox(DETAIL + '?observer=OBS1&obs=123&viewPath=1', { modalOpen: false }); s.set("filters.observer = 'OBS1'"); - assert.strictEqual(s.clear(), DETAIL + '?obs=123'); + assert.strictEqual(s.clear(), '#/packets'); +}); + +test('Clear Filters keeps the default-route form: no hash filter left for a reload to re-apply (#180)', () => { + // The reload of what Clear wrote must not filter: no subpath, no ?hash=. + const s = makeSandbox(DETAIL + '?obs=123'); + s.set("filters.hash = 'abc123'"); + const h = s.clear(); + assert(!/^#\/packets\//.test(h), 'detail subpath kept: ' + h); + assert.strictEqual(params(h).get('hash'), null, 'hash filter in the URL: ' + h); }); test('Clear Filters on the list view still yields bare #/packets', () => { @@ -309,6 +326,23 @@ test('closing the detail drops subpath and detail params, keeps the filters', () assert.strictEqual(s.update({ subpath: '', obs: null }), '#/packets?timeWindow=60&observer=OBS1'); }); +// ---- history.state survives every list-URL write (#180) ---- +// packet-path-map.js marks the #/packets/ entry its modal opened on +// in history.state, so Back/Forward onto it after the modal was closed on +// another page does not reopen it. The writes here must not wipe the mark. + +test('updatePacketsUrl() keeps the entry\'s history.state (filter change, selection, Clear) (#180)', () => { + const mark = { packetPathModal: 'e1' }; + const s = makeSandbox(DETAIL + '?obs=123&viewPath=1', { modalOpen: true, state: mark }); + s.set("filters.observer = 'OBS1'"); + s.update(); + assert.strictEqual(s.ctx.history.state, mark, 'filter change dropped the state'); + s.update({ subpath: '/abc123', obs: '7' }); + assert.strictEqual(s.ctx.history.state, mark, 'selection dropped the state'); + s.clear(); + assert.strictEqual(s.ctx.history.state, mark, 'Clear dropped the state'); +}); + // ---- Route guard (#167 r2): the list URL is written only on #/packets ---- // On the standalone #/packet/ page an observation click used to rewrite // the address bar to #/packets/?&obs=… while the diff --git a/test-issue-147-packets-url-obs-e2e.js b/test-issue-147-packets-url-obs-e2e.js index 0e335d654..46d604b45 100644 --- a/test-issue-147-packets-url-obs-e2e.js +++ b/test-issue-147-packets-url-obs-e2e.js @@ -1,7 +1,7 @@ /** * E2E (#147): a packet-detail deep link #/packets/?obs= keeps its - * selected observation in the address bar on cold load, on filter changes - * and on Clear Filters, and reopening the copied URL in a new tab selects the + * selected observation in the address bar on cold load and on filter changes + * (Clear Filters leaves the detail, #180), and reopening the copied URL in a new tab selects the * same observation. ?viewPath=1 is in the URL while the View Path modal is * open, also when the detail's View Path button opened it, and is dropped * when it closes. @@ -159,12 +159,14 @@ async function setTimeWindow(page, value) { await tab.close(); }); - await step('desktop: Clear Filters removes ?timeWindow=, keeps the detail and ?obs=', async () => { + // #180 decision: Clear Filters also leaves the detail (its subpath + // would set the hash filter again on reload), so ?obs= goes with it. + await step('desktop: Clear Filters removes ?timeWindow= and the detail (subpath, ?obs=) (#180)', async () => { await page.click('#clearFiltersBtn'); await waitHash(page, () => !/timeWindow=/.test(location.hash), 'timeWindow still in URL after Clear'); - const { q, hash: h } = await hashParams(page); - assert(h.startsWith('#/packets/' + hash), 'detail subpath lost: ' + h); - assert(q.obs === SYN_OBS, 'obs dropped by Clear Filters: ' + h); + const { hash: h } = await hashParams(page); + assert(h === '#/packets', 'URL after Clear: ' + h); + assert(!(await detailPaneOpen(page)), 'detail pane still open after Clear'); }); await step('desktop: Escape closes only the View Path modal (drops viewPath, keeps detail + obs); a second Escape closes the detail', async () => { @@ -255,9 +257,8 @@ async function setTimeWindow(page, value) { await page.goto(`${DETAIL}?timeWindow=60&obs=${SYN_OBS}`, { waitUntil: 'load' }); assert(await currentObs(page) === SYN_OBS, 'observation not selected in the SlideOver'); assert(await slideOverOpen(), 'detail did not open in a SlideOver'); - // A real click on the backdrop (left of the panel). The panel's × - // sits under the fixed top nav at this width, so a real click on it - // hits the nav instead (pre-existing layout issue, not #147). + // A real click on the backdrop (left of the panel). A click on × + // is covered by test-issue-180-packets-url-modal-e2e.js (#180). await page.click('.slide-over-backdrop', { position: { x: 40, y: 450 } }); await waitHash(page, () => !location.hash.startsWith('#/packets/'), 'detail subpath still in URL after the SlideOver closed'); const p = await hashParams(page); diff --git a/test-issue-180-packets-detail-close.js b/test-issue-180-packets-detail-close.js new file mode 100644 index 000000000..2a5a141be --- /dev/null +++ b/test-issue-180-packets-detail-close.js @@ -0,0 +1,106 @@ +/* test-issue-180-packets-detail-close.js — #180 + * + * closeDetailPanel() in public/packets.js closes the packet detail. Clear + * Filters calls it (#180 item 2: Clear leaves the detail), so with no row + * selected it must not re-render the rows: Clear reloads them itself. + * Escape calls it too, and at ≤640 px it must close the mobile bottom sheet + * (#180 item 6: it only reset the desktop pane). + * + * Runs the REAL closeDetailPanel() from public/packets.js inside a + * vm.createContext sandbox with a small fake DOM. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +console.log('--- test-issue-180-packets-detail-close.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(path.join(__dirname, 'public/packets.js'), 'utf8'); + +// Source of `function (…) {…}` through its matching '}'. +function extractFunction(name) { + const start = SRC.indexOf('function ' + name + '('); + assert(start !== -1, name + ' not found in public/packets.js'); + const open = SRC.indexOf('{', start); + let depth = 0; + for (let i = open; i < SRC.length; i++) { + if (SRC[i] === '{') depth++; + else if (SRC[i] === '}' && --depth === 0) return SRC.slice(start, i + 1); + } + throw new Error('unbalanced braces after ' + name); +} + +function makeEl(id, classes) { + const set = new Set(classes || []); + return { + id, innerHTML: '', + classList: { + add: (c) => set.add(c), remove: (c) => set.delete(c), contains: (c) => set.has(c), + }, + closest: () => null, + }; +} + +// opts.pane: the desktop pane is open (not .empty); opts.selectedId; +// opts.sheet: the mobile sheet exists ('open' or 'closed'). +function makeSandbox(opts) { + opts = opts || {}; + const els = { pktRight: makeEl('pktRight', opts.pane ? [] : ['empty']) }; + if (opts.sheet) els.mobileDetailSheet = makeEl('mobileDetailSheet', ['mobile-detail-sheet'].concat(opts.sheet === 'open' ? ['open'] : [])); + const ctx = { + document: { getElementById: (id) => els[id] || null }, + PANEL_CLOSE_HTML: '', + __renders: 0, + }; + vm.createContext(ctx); + vm.runInContext( + 'var selectedId = ' + JSON.stringify(opts.selectedId == null ? null : opts.selectedId) + ';\n' + + 'function renderTableRows() { __renders++; }\n' + extractFunction('closeDetailPanel'), + ctx, { filename: 'packets.js (#180 extract)' }); + return { + ctx, els, + close() { vm.runInContext('closeDetailPanel()', ctx); }, + selectedId() { return vm.runInContext('selectedId', ctx); }, + }; +} + +test('desktop pane open with a selected row: closes the pane, clears the selection, re-renders once', () => { + const s = makeSandbox({ pane: true, selectedId: 42 }); + s.close(); + assert(s.els.pktRight.classList.contains('empty'), 'pane not emptied'); + assert.strictEqual(s.selectedId(), null); + assert.strictEqual(s.ctx.__renders, 1); +}); + +test('no row selected (e.g. Clear Filters on the list): no extra render', () => { + const s = makeSandbox({ pane: false, selectedId: null }); + s.close(); + assert.strictEqual(s.ctx.__renders, 0, 'rows re-rendered with nothing selected'); +}); + +test('≤640 px: closes the open mobile detail sheet and clears the selection (#180 item 6)', () => { + const s = makeSandbox({ pane: false, selectedId: 42, sheet: 'open' }); + s.close(); + assert(!s.els.mobileDetailSheet.classList.contains('open'), 'mobile sheet still open'); + assert.strictEqual(s.selectedId(), null); + assert.strictEqual(s.ctx.__renders, 1, 'selection highlight not re-rendered'); +}); + +test('no mobile sheet in the DOM (desktop, or never opened): no error', () => { + const s = makeSandbox({ pane: true, selectedId: 1 }); + s.close(); + assert(s.els.pktRight.classList.contains('empty')); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✅'); diff --git a/test-issue-180-packets-url-modal-e2e.js b/test-issue-180-packets-url-modal-e2e.js new file mode 100644 index 000000000..280f6365c --- /dev/null +++ b/test-issue-180-packets-url-modal-e2e.js @@ -0,0 +1,255 @@ +/** + * E2E (#180): packets URL and View Path modal leftovers from #167. + * + * - Item 2 (1400 px): Clear Filters on a detail URL #/packets/?… also + * leaves the detail, so the URL is the list and a reload shows the same + * unfiltered list (the subpath used to set the hash filter again). + * + * - Item 3 (641, 800, 1023 px): the SlideOver's close button is the topmost + * element at its centre (it used to sit under the sticky .top-nav), is at + * least 48×48, and a real click on it closes the SlideOver. + * + * - Item 4 (1400 px): with the View Path modal open, Escape in a layer opened + * over it (global search via Ctrl+K, the nav More menu) closes that layer, + * not the modal; the next Escape closes the modal. + * + * - Item 5 (800 and 1400 px): Back from a packet whose View Path modal is + * open closes the modal, and Forward onto that history entry (which still + * says viewPath=1) does not reopen it; the URL then drops viewPath. + * + * - Item 6 (390 px): Escape closes the mobile detail sheet; with the View + * Path modal open over it, the first Escape closes only the modal. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-180-packets-url-modal-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +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 function waitFor(page, pred, what, arg) { + try { + await page.waitForFunction(pred, arg, { timeout: 8000 }); + } catch (_) { + throw new Error(what + ' — address bar: ' + (await page.evaluate(() => location.hash))); + } +} + +// Load url as a new document, so nothing from an earlier step carries over +// (goto alone is a same-document hash change). +async function fresh(page, url) { + await page.goto(url, { waitUntil: 'load' }); + await page.reload({ waitUntil: 'load' }); +} + +// The "Latest Packets (N)" count, once the list has rendered. +async function listCount(page) { + await page.waitForSelector('#pktLeft .count', { timeout: 15000 }); + return page.evaluate(() => Number((document.querySelector('#pktLeft .count').textContent.match(/\d+/) || [])[0])); +} +function clearShown(page) { + return page.evaluate(() => { + const b = document.getElementById('clearFiltersBtn'); + return !!b && getComputedStyle(b).display !== 'none'; + }); +} +function detailPaneOpen(page) { + return page.evaluate(() => { + const r = document.getElementById('pktRight'); + return !!r && !r.classList.contains('empty'); + }); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== #180 packets URL / View Path modal against ${BASE} ===`); + + const list = await (await fetch(BASE + '/api/packets?limit=50&groupByHash=true')).json(); + const pkt = (list.packets || []).find((p) => p.hash && /^[0-9a-f]+$/.test(p.hash)); + if (!pkt) { console.error('no packet with a hash in the fixture'); process.exit(1); } + const hash = pkt.hash; + const DETAIL = `${BASE}/#/packets/${hash}`; + console.log(' packet ' + hash); + + async function newPage(opts) { + const context = await browser.newContext(opts); + await context.addInitScript(() => { + try { localStorage.removeItem('meshcore-time-window'); localStorage.removeItem('meshcore-observer-filter'); localStorage.removeItem('meshcore-type-filter'); } catch (_) {} + }); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + return { context, page }; + } + + // ---- Item 2: Clear Filters on a detail URL (desktop) ---- + { + const { context, page } = await newPage({ viewport: { width: 1400, height: 900 } }); + await step('desktop (1400): Clear Filters on #/packets/?… closes the detail, writes #/packets, and a reload shows the same list', async () => { + await fresh(page, `${DETAIL}?timeWindow=60`); + await page.waitForSelector('#pktRight [data-view-path]', { timeout: 15000 }); + await waitFor(page, () => /\(1\)/.test(document.querySelector('#pktLeft .count').textContent), 'detail URL did not filter the list to 1 packet'); + assert(await clearShown(page), 'Clear button hidden on the filtered detail URL'); + + // The fixture's packets may have aged out of the default 15 min window + // that Clear restores, so compare what Clear showed with what the + // reload shows rather than expecting a number of rows. + const listLoaded = () => page.waitForResponse((r) => /\/api\/packets\?/.test(r.url()), { timeout: 15000 }); + await Promise.all([listLoaded(), page.click('#clearFiltersBtn')]); + await page.waitForTimeout(500); + let h = await page.evaluate(() => location.hash); + assert(h === '#/packets', 'URL after Clear: ' + h); + const shown = await listCount(page); + assert(!(await clearShown(page)), 'Clear button still visible after Clear'); + assert(!(await detailPaneOpen(page)), 'detail pane still open after Clear'); + + await Promise.all([listLoaded(), page.reload({ waitUntil: 'load' })]); + await page.waitForTimeout(500); + h = await page.evaluate(() => location.hash); + assert(h === '#/packets', 'URL after reload: ' + h); + const reloaded = await listCount(page); + assert(reloaded === shown, 'reload shows ' + reloaded + ' packets, Clear showed ' + shown); + assert(!(await clearShown(page)), 'Clear button back after reload'); + assert(!(await detailPaneOpen(page)), 'detail pane open after reload'); + assert(await page.evaluate(() => document.getElementById('fHash').value === ''), 'hash filter input filled after reload'); + }); + await context.close(); + } + + // ---- Item 4: Escape in a layer over the View Path modal (desktop) ---- + { + const { context, page } = await newPage({ viewport: { width: 1400, height: 900 } }); + const modalOpen = () => page.evaluate(() => !!document.getElementById('packetPathModal')); + async function openModal() { + await fresh(page, `${DETAIL}?viewPath=1`); + await page.waitForSelector('#packetPathModal'); + await page.waitForSelector('#pktRight [data-view-path]', { timeout: 15000 }); + } + async function secondEscapeClosesModal() { + await page.keyboard.press('Escape'); + await page.waitForSelector('#packetPathModal', { state: 'detached', timeout: 5000 }) + .catch(() => { throw new Error('the next Escape did not close the modal'); }); + await waitFor(page, () => !/viewPath=/.test(location.hash), 'viewPath still in URL after the modal closed'); + } + + await step('desktop (1400): Escape in the global search (Ctrl+K) over the View Path modal closes the search, not the modal', async () => { + await openModal(); + await page.keyboard.press('Control+k'); + await waitFor(page, () => !document.getElementById('searchOverlay').classList.contains('hidden') && document.activeElement && document.activeElement.id === 'searchInput', 'search did not open with focus in its input'); + await page.keyboard.press('Escape'); + await waitFor(page, () => document.getElementById('searchOverlay').classList.contains('hidden'), 'Escape did not close the search'); + assert(await modalOpen(), 'Escape in the search closed the View Path modal underneath'); + const h = await page.evaluate(() => location.hash); + assert(/[?&]viewPath=1(&|$)/.test(h), 'viewPath dropped although the modal is open: ' + h); + await secondEscapeClosesModal(); + }); + + await step('desktop (1400): Escape in the nav More menu over the View Path modal closes the menu, not the modal', async () => { + await openModal(); + await page.click('#navMoreBtn'); + await waitFor(page, () => document.getElementById('navMoreMenu').classList.contains('open') && document.getElementById('navMoreMenu').contains(document.activeElement), 'More menu did not open with focus in it'); + await page.keyboard.press('Escape'); + await waitFor(page, () => !document.getElementById('navMoreMenu').classList.contains('open'), 'Escape did not close the More menu'); + assert(await modalOpen(), 'Escape in the More menu closed the View Path modal underneath'); + await secondEscapeClosesModal(); + }); + await context.close(); + } + + // ---- Item 5: Back/Forward and a closed View Path modal ---- + for (const vp of [ + { label: 'tablet (800)', width: 800, btn: '.slide-over-panel [data-view-path]' }, + { label: 'desktop (1400)', width: 1400, btn: '#pktRight [data-view-path]' }, + ]) { + const { context, page } = await newPage({ viewport: { width: vp.width, height: 900 } }); + await step(`${vp.label}: Back closes the View Path modal; Forward does not reopen it and drops viewPath`, async () => { + await fresh(page, `${BASE}/#/nodes`); + await page.evaluate((h) => { location.hash = '#/packets/' + h; }, hash); + await page.waitForSelector(vp.btn, { timeout: 15000 }); + await page.waitForTimeout(400); + await page.click(vp.btn); + await page.waitForSelector('#packetPathModal'); + await waitFor(page, () => /^#\/packets\/[^?]+\?(.*&)?viewPath=1(&|$)/.test(location.hash), 'viewPath=1 not written by the View Path button'); + + await page.evaluate(() => history.back()); + await waitFor(page, () => location.hash.startsWith('#/nodes'), 'Back did not go to #/nodes'); + await page.waitForSelector('#packetPathModal', { state: 'detached', timeout: 5000 }) + .catch(() => { throw new Error('the View Path modal stayed open over #/nodes after Back'); }); + + await page.evaluate(() => history.forward()); + await waitFor(page, () => location.hash.startsWith('#/packets/'), 'Forward did not return to the packet'); + await page.waitForSelector(vp.btn, { timeout: 15000 }); + await page.waitForTimeout(1000); + assert(!(await page.evaluate(() => !!document.getElementById('packetPathModal'))), 'Forward reopened the closed View Path modal'); + await waitFor(page, () => !/viewPath=/.test(location.hash), 'viewPath=1 still in the URL with no modal open'); + }); + await context.close(); + } + + // ---- Item 3: SlideOver × above the top nav (641–1023 px) ---- + for (const width of [641, 800, 1023]) { + const { context, page } = await newPage({ viewport: { width, height: 900 } }); + await step(`tablet (${width}): the SlideOver × is topmost, 48×48, and a real click closes it`, async () => { + await fresh(page, DETAIL); + await page.waitForSelector('.slide-over-panel [data-view-path]', { timeout: 15000 }); + // Past the 200 ms slideInRight animation. + await page.waitForTimeout(400); + const x = await page.evaluate(() => { + const btn = document.querySelector('.slide-over-panel .slide-over-close'); + const r = btn.getBoundingClientRect(); + const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return { w: r.width, h: r.height, onTop: btn.contains(top), top: top ? (top.className && top.className.baseVal !== undefined ? top.className.baseVal : top.className) || top.tagName : null }; + }); + assert(x.onTop, 'the element at the × centre is "' + x.top + '", not the close button'); + assert(x.w >= 48 && x.h >= 48, 'close button is ' + x.w + '×' + x.h + ', expected at least 48×48'); + await page.click('.slide-over-panel .slide-over-close', { timeout: 3000 }); + await waitFor(page, () => !window.SlideOver.isOpen(), 'the SlideOver is still open after a click on ×'); + const h = await page.evaluate(() => location.hash); + assert(h.startsWith('#/packets') && !h.startsWith('#/packets/'), 'URL after closing the SlideOver: ' + h); + }); + await context.close(); + } + + // ---- Item 6: Escape closes the mobile detail sheet (≤640 px) ---- + { + const { context, page } = await newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const sheetOpen = () => page.evaluate(() => { + const s = document.getElementById('mobileDetailSheet'); + return !!s && s.classList.contains('open') && getComputedStyle(s).display !== 'none'; + }); + await step('mobile (390): Escape closes the packet detail sheet', async () => { + await fresh(page, DETAIL); + await page.waitForSelector('#mobileDetailSheet.open [data-view-path]', { timeout: 15000 }); + assert(await sheetOpen(), 'detail sheet not shown'); + await page.keyboard.press('Escape'); + await waitFor(page, () => !document.getElementById('mobileDetailSheet').classList.contains('open'), 'Escape did not close the detail sheet'); + }); + await step('mobile (390): with View Path open over the sheet, the first Escape closes only the modal, the next the sheet', async () => { + await fresh(page, DETAIL); + await page.waitForSelector('#mobileDetailSheet.open [data-view-path]', { timeout: 15000 }); + await page.click('#mobileDetailSheet [data-view-path]'); + await page.waitForSelector('#packetPathModal'); + await page.keyboard.press('Escape'); + await page.waitForSelector('#packetPathModal', { state: 'detached', timeout: 5000 }) + .catch(() => { throw new Error('the first Escape did not close the modal'); }); + assert(await sheetOpen(), 'the first Escape also closed the detail sheet'); + await page.keyboard.press('Escape'); + await waitFor(page, () => !document.getElementById('mobileDetailSheet').classList.contains('open'), 'the second Escape did not close the detail sheet'); + }); + await context.close(); + } + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 8fa1540c4..bf2dd515a 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -28,8 +28,8 @@ function test(name, fn) { console.log('\n=== packet-path-map.js: string-contract checks ==='); -test('exports window.PacketPathMap.{open,close}', () => { - assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close\s*\}/.test(src)); +test('exports window.PacketPathMap.{open,close,restore}', () => { + assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close,\s*restore:\s*restore\s*\}/.test(src)); }); test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-URL handling)', () => { @@ -56,7 +56,7 @@ test('draws highlighted branch(es) on top of the others', () => { }); test('handles Escape key and click-outside to close, matching other CoreScope modals', () => { - assert.ok(/e\.key === 'Escape'/.test(src)); + assert.ok(/e\.key [!=]== 'Escape'/.test(src)); assert.ok(/e\.target === overlay/.test(src)); }); @@ -107,6 +107,7 @@ function makeSandbox(apiImpl) { get textContent() { return this._text || ''; }, appendChild(child) { this.children.push(child); child._parent = this; return child; }, remove() { if (this._parent) this._parent.children = this._parent.children.filter(c => c !== this); }, + contains(node) { for (let n = node; n; n = n._parent || n.parentElement) if (n === this) return true; return false; }, addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }, removeEventListener(type, fn) { if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(f => f !== fn); }, querySelector() { return null; }, @@ -128,6 +129,8 @@ function makeSandbox(apiImpl) { }; return search(body); }, + // #180: the topmost element at a point; a test sets __topAt. + elementFromPoint() { return ctx.__topAt || null; }, // docLog: the live document listeners with their capture flag (#167 r2: // the modal's Escape handler must run in the capture phase). addEventListener(type, fn, opts) { @@ -143,21 +146,45 @@ function makeSandbox(apiImpl) { }; const docLog = []; + // #180: the modal listens for hashchange on window while it is open. + const winListeners = {}; + const win = { + addEventListener(type, fn) { (winListeners[type] = winListeners[type] || []).push(fn); }, + removeEventListener(type, fn) { if (winListeners[type]) winListeners[type] = winListeners[type].filter(f => f !== fn); }, + }; const ctx = { - window: {}, document: doc, console, Math, String, JSON, Promise, Error, + window: win, document: doc, console, Math, String, JSON, Promise, Error, Date, setTimeout, clearTimeout, // Returns the variable name itself (not a real color) so tests can // assert two markers use DIFFERENT css vars without caring what the // actual theme color is. - getComputedStyle: () => ({ getPropertyValue: (name) => name }), + // position: an element's own _pos (#180 layer test), else static. + getComputedStyle: (el) => ({ getPropertyValue: (name) => name, position: (el && el._pos) || 'static' }), escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'), api: apiImpl, L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths. location: { origin: 'https://stg.meshview.dk', hash: '' }, - // Records replaceState() so the #147 tests can see the address bar. - history: { replaceState(_s, _t, url) { ctx.__replaced.push(url); ctx.location.hash = url; } }, + // Records replaceState() so the #147 tests can see the address bar. A + // call without a URL changes only the entry's state (#180), not the + // address bar, so it is not in __replaced. + history: { + state: null, + replaceState(state, _t, url) { + ctx.history.state = state; + if (url === undefined) return; + ctx.__replaced.push(url); ctx.location.hash = url; + }, + }, __replaced: [], __docLog: docLog, + __winListeners: winListeners, + // #180: a navigation (Back/Forward, a nav link): the new entry's hash and + // state, then the window's hashchange listeners. + __navigate(hash, state) { + ctx.location.hash = hash; + ctx.history.state = state === undefined ? null : state; + (winListeners.hashchange || []).slice().forEach(fn => fn({})); + }, }; ctx.window.copyToClipboard = (text, onDone) => { ctx.__copiedText = text; if (onDone) onDone(); }; vm.createContext(ctx); @@ -1328,6 +1355,158 @@ function makeSandbox(apiImpl) { }, '#/packets/deadbeef?obs=123', 1); + // #180: Escape is the top layer's. A layer opened over the modal later + // (global search via Ctrl+K, a nav menu, the More sheet, a filter popover) + // holds focus and has its own Escape handler: the modal leaves the event + // alone then. Focus on the page, in the sticky top nav, or in the detail + // surface the modal opened over (SlideOver, mobile sheet) still closes it. + // fakeFocus: a focused element inside a chain of ancestors; each entry is + // { pos, cls } from the element itself outwards. + function fakeFocus(chain) { + let parent = null; + const nodes = chain.slice().reverse().map((c) => { + const n = { + _pos: c.pos || 'static', parentElement: parent, _cls: c.cls || '', + matches(sel) { return sel.split(',').some((x) => x.trim() === '.' + this._cls); }, + contains(other) { for (let o = other; o; o = o.parentElement) if (o === this) return true; return false; }, + getBoundingClientRect: () => ({ left: 10, top: 10, width: 20, height: 20 }), + }; + parent = n; + return n; + }); + return nodes[nodes.length - 1]; + } + async function escapeCase(name, chain, topIs, expectClosed) { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('boom'))); + ctx.location.hash = '#/packets/deadbeef?obs=1&viewPath=1'; + await ctx.window.PacketPathMap.open('deadbeef'); + const overlay = ctx.document.getElementById('packetPathModal'); + const target = fakeFocus(chain); + ctx.__topAt = topIs === 'target' ? target : topIs === 'modal' ? overlay.children[0] || overlay : null; + const key = ctx.__docLog.find(r => r.type === 'keydown'); + let stopped = 0; + key.fn({ key: 'Escape', target, stopPropagation() { stopped++; } }); + const open = !!ctx.document.getElementById('packetPathModal'); + assert.strictEqual(!open, expectClosed, expectClosed ? 'modal still open' : 'modal closed'); + assert.strictEqual(stopped, expectClosed ? 1 : 0, 'stopPropagation calls: ' + stopped); + assert.strictEqual(ctx.location.hash, expectClosed ? '#/packets/deadbeef?obs=1' : '#/packets/deadbeef?obs=1&viewPath=1'); + passed++; + console.log(' ✅ ' + name); + } catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } + } + await escapeCase('Escape in the global search drawn over the modal is left to the search (#180)', + [{}, { pos: 'fixed', cls: 'search-overlay' }], 'target', false); + await escapeCase('Escape in a fixed nav menu / More sheet drawn over the modal is left to it (#180)', + [{}, { cls: 'nav-more-wrap' }, { pos: 'fixed', cls: 'nav-more-menu' }], 'target', false); + await escapeCase('Escape with focus on a control covered by the modal (e.g. the View Path button) closes the modal (#180)', + [{}, { pos: 'fixed', cls: 'search-overlay' }], 'modal', true); + await escapeCase('Escape with focus in the SlideOver the modal opened over closes the modal (#180)', + [{}, { pos: 'fixed', cls: 'slide-over-panel' }], 'target', true); + await escapeCase('Escape with focus in the mobile sheet the modal opened over closes the modal (#180)', + [{}, { pos: 'fixed', cls: 'mobile-detail-sheet' }], 'target', true); + await escapeCase('Escape with focus in the sticky top nav (no floating layer) closes the modal (#180)', + [{}, { pos: 'sticky', cls: 'top-nav' }], 'target', true); + + // #180: the modal closes on a route change, and Back/Forward onto the + // #/packets/?…&viewPath=1 entry it was closed away from does not + // reopen it. A new link to the same URL (an entry without that state) + // and a reload of an entry whose modal was open still do. + const ENTRY = '#/packets/deadbeef?obs=1&viewPath=1'; + async function routeCase(name, fn) { + try { + const ctx = makeSandbox(() => Promise.reject(new Error('boom'))); + ctx.location.hash = ENTRY; + ctx.history.state = { other: 'kept' }; + await fn(ctx); + passed++; + console.log(' ✅ ' + name); + } catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } + } + const isOpen = (ctx) => !!ctx.document.getElementById('packetPathModal'); + const hashListeners = (ctx) => (ctx.__winListeners.hashchange || []).length; + + await routeCase('open() on a #/packets/ entry marks it in history.state, keeping other state, without a URL write (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + assert.ok(ctx.history.state.packetPathModal, 'entry not marked: ' + JSON.stringify(ctx.history.state)); + assert.strictEqual(ctx.history.state.other, 'kept'); + assert.strictEqual(ctx.__replaced.length, 0, 'URL written: ' + JSON.stringify(ctx.__replaced)); + assert.strictEqual(hashListeners(ctx), 1, 'no hashchange listener while open'); + }); + await routeCase('open() on another page (e.g. #/analytics) does not mark the entry (#180)', async (ctx) => { + ctx.location.hash = '#/analytics?tab=distance'; + ctx.history.state = null; + await ctx.window.PacketPathMap.open('deadbeef'); + assert.strictEqual(ctx.history.state, null); + }); + await routeCase('a route change (Back to #/nodes) closes the modal and writes no URL (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + ctx.__navigate('#/nodes', null); + assert.ok(!isOpen(ctx), 'modal still open over #/nodes'); + assert.strictEqual(ctx.location.hash, '#/nodes'); + assert.strictEqual(ctx.__replaced.length, 0, 'URL written: ' + JSON.stringify(ctx.__replaced)); + assert.strictEqual(hashListeners(ctx), 0, 'hashchange listener left behind'); + }); + await routeCase('a hashchange that keeps the path (query only) keeps the modal (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + ctx.__navigate('#/packets/deadbeef?obs=2&viewPath=1', ctx.history.state); + assert.ok(isOpen(ctx), 'modal closed by a query-only hashchange'); + }); + await routeCase('Forward onto the entry whose modal closed on route change: restore() does not reopen it (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + const entryState = ctx.history.state; + ctx.__navigate('#/nodes', null); + ctx.__navigate(ENTRY, entryState); + assert.strictEqual(ctx.window.PacketPathMap.restore('deadbeef'), false); + assert.ok(!isOpen(ctx), 'modal reopened from history'); + }); + await routeCase('a modal closed with Escape while another route is shown is not reopened by Back/Forward (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + const entryState = ctx.history.state; + // A route switch without a hashchange event (replaceState-based). + ctx.location.hash = '#/nodes'; + ctx.history.state = null; + ctx.__docLog.find(r => r.type === 'keydown').fn({ key: 'Escape', stopPropagation() {} }); + assert.ok(!isOpen(ctx), 'Escape did not close the modal'); + assert.strictEqual(ctx.location.hash, '#/nodes', 'the other route\'s URL was rewritten'); + ctx.__navigate(ENTRY, entryState); + assert.strictEqual(ctx.window.PacketPathMap.restore('deadbeef'), false); + assert.ok(!isOpen(ctx), 'modal reopened from history'); + }); + await routeCase('restore() on a new link to the same URL (entry without state) opens the modal (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + ctx.__navigate('#/nodes', null); + ctx.__navigate(ENTRY, null); + assert.strictEqual(ctx.window.PacketPathMap.restore('deadbeef'), true); + assert.ok(isOpen(ctx), 'a fresh link did not open the modal'); + }); + await routeCase('restore() on a reload of an entry whose modal was open reopens it (#180)', async (ctx) => { + await ctx.window.PacketPathMap.open('deadbeef'); + const entryState = ctx.history.state; + ctx.window.PacketPathMap.close(); + // A reload: same entry state, a new document (fresh module, no list). + const ctx2 = makeSandbox(() => Promise.reject(new Error('boom'))); + ctx2.location.hash = ENTRY; + ctx2.history.state = entryState; + assert.strictEqual(ctx2.window.PacketPathMap.restore('deadbeef'), true); + assert.ok(isOpen(ctx2), 'reload did not reopen the modal'); + }); + await routeCase('the closed-entry list is bounded: the oldest entry is forgotten after 50 newer ones (#180)', async (ctx) => { + const states = []; + for (let i = 0; i < 51; i++) { + ctx.__navigate(ENTRY, null); + await ctx.window.PacketPathMap.open('deadbeef'); + states.push(ctx.history.state); + ctx.__navigate('#/nodes', null); + } + ctx.__navigate(ENTRY, states[1]); + assert.strictEqual(ctx.window.PacketPathMap.restore('deadbeef'), false, 'entry 2 of 51 forgotten too early'); + ctx.window.PacketPathMap.close(); + ctx.__navigate('#/nodes', null); + ctx.__navigate(ENTRY, states[0]); + assert.strictEqual(ctx.window.PacketPathMap.restore('deadbeef'), true, 'the oldest of 51 entries is still remembered'); + }); + console.log('\n════════════════════════════════════════'); console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');