From d964c279645e358716ddb1b67e6847ca82de16bc Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Thu, 28 May 2026 16:11:25 -0700 Subject: [PATCH] feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Mobile UX overhaul for the packets surface plus two discoverable defects found along the way. All UI changes are mobile-only (`@media (max-width: 900px)` or `isMobile()` gates) — desktop unchanged. ## Closes - #1415 — packets layout cross-viewport jank - #1458 — Tufte mobile packets critique (P0s) - #1461 — Tufte v2 mobile packets critique (P0/P1) - #1467 — Favorites/Search/Customize unreachable on mobile - #1468 — client-side "unknown" channel synthesis - #1470 — node-detail map inset doesn't honor customizer dark provider ## Commits 1. `fix(#1468): drop client-side "unknown" channel synthesis` — `channels.js` 2. `feat(#1470): node-detail map inset honors customizer dark-tile provider` — `nodes.js`, `roles.js` 3. `feat(mobile): packets UX overhaul + bottom-nav More controls (#1415, #1458, #1461, #1467)` — `style.css`, `index.html`, `mobile-page-actions.js` (new) ## Mobile-list view changes - Kill empty chevron rail - Slim sticky THEAD (24px, retains sort affordance per operator preference) - Hide entire page-header on mobile - Mirror pause + Filters pill into navbar via new `mobile-page-actions.js` - Convert group-header `toggle-select` → `select-hash` on mobile (no dead-end expand) ## Mobile detail-panel changes - Drop redundant src→dst line (identity already in sticky header) - Hide boxed "decoded message" duplication card - Hide PAYLOAD TYPE row (already in header badge) - 2-col label/value grid (cuts panel height ~40%) - Sticky in-sheet header for packet identity - Kill iOS-style drag handle (conflicts with browser pull-to-refresh) - Make ✕ close visible + always reachable - Outer sheet `overflow:hidden`, inner content `overflow-y:auto` (scrollable region distinct, scrollbar visible) - Bottom-nav clearance (`padding-bottom: 60px`) - Close detail sheet on route change away from /packets - Tap-to-toast popovers for score tooltips (`title=` doesn't fire on touch) ## Mobile nav surface - Mirror Favorites ⭐ / Search 🔍 / Customize 🎨 into bottom-nav More sheet (#1467) - Brand stays in top nav; per-page controls (pause, Filters) injected into `.nav-left` ## Other fixes shipped together - **#1468**: drop CHAN messages with no decoded channel name (eliminates fake "unknown" channel row) - **#1470**: `_applyTilesToNodeMap` helper — node-detail inset map reads from `MC_TILE_PROVIDERS[active]` instead of hardcoded OSM; honors customizer's dark-tile provider pick + applies invert filter for inverted variants - `getTileUrl()` + new `getActiveTileProvider()` in `roles.js` now consult `MC_TILE_PROVIDERS` ## CDP verification (local chromium) Tested on staging at viewport 390×844 + 1206×928. | Surface | Before | After | |---|---|---| | Chrome above first data row | 231px (27% viewport) | ~80px (10% viewport) | | Packets visible above fold | 10 | 17 | | Detail panel duplications | 3× identity | 1× (header only) | | Mobile group-expand UX | dead-end (no chevron) | converts to select-hash | | Score tooltips on touch | broken (title= silent) | tap → toast popover | | Node detail map inset (dark mode) | always OSM light tiles | honors customizer provider + invert filter | | Bottom-nav More controls | Dark mode only | + Favorites, Search, Customize | ## What's NOT in this PR - Paths-through-node sort fix lives in #1431 (parallel PR for #1145) - Detail-panel hex byte-grid behind disclosure — operator wants it; follow-up - Group-header row sizing (some render 200–700px tall) — existing behavior, follow-up ## Test plan - [ ] Existing frontend tests stay green (`test-issue-1415-packets-layout.js`, `test-issue-1420-tile-providers.js`, `test-issue-1454-channels-toggle.js` all pass locally on this branch) - [ ] Existing Playwright E2E stays green - [ ] CDP on local chromium: 390×844 mobile + 1024×768 tablet + 1440×900 desktop — no regressions --------- Co-authored-by: openclaw-bot --- public/channels.js | 8 +- public/index.html | 1 + public/mobile-page-actions.js | 194 ++++++++++++++ public/nodes.js | 27 +- public/roles.js | 30 ++- public/style.css | 159 +++++++++++ test-all.sh | 4 + test-e2e-playwright.js | 83 ++++++ test-issue-1461-mobile-page-actions.js | 351 +++++++++++++++++++++++++ test-issue-1470-node-tile-helper.js | 226 ++++++++++++++++ 10 files changed, 1079 insertions(+), 4 deletions(-) create mode 100644 public/mobile-page-actions.js create mode 100644 test-issue-1461-mobile-page-actions.js create mode 100644 test-issue-1470-node-tile-helper.js diff --git a/public/channels.js b/public/channels.js index 87864696..cc56ceae 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1332,7 +1332,13 @@ var payload = m.data?.decoded?.payload; if (!payload) continue; - var channelName = payload.channel || 'unknown'; + // #1468: drop CHAN messages with no decoded channel name instead of + // synthesizing a literal "unknown" row that renders as a fake channel + // in the sidebar. Server-side (#1373/#1377) already filters these from + // /api/channels; the live WebSocket router was the remaining offender. + if (!payload.channel) continue; + + var channelName = payload.channel; // For live-decrypted user-added (PSK) channels, decryptLivePSKBatch // also stamps payload.channelKey ("user:") so we route the // message to the correct sidebar row and to the open chat view. diff --git a/public/index.html b/public/index.html index 0cbb5d98..a1ed0d89 100644 --- a/public/index.html +++ b/public/index.html @@ -170,5 +170,6 @@ + diff --git a/public/mobile-page-actions.js b/public/mobile-page-actions.js new file mode 100644 index 00000000..e983f600 --- /dev/null +++ b/public/mobile-page-actions.js @@ -0,0 +1,194 @@ +/* #1461 mobile page-actions: mirror per-page header buttons (pause, filter + * toggle) into the top nav's .nav-right area on mobile so the page-header + * row can be hidden entirely. On desktop this script is a no-op. */ +(function () { + 'use strict'; + const MOBILE_BP = 600; + const SLOT_ID = 'navPageActions'; + + function isMobile() { return window.innerWidth <= MOBILE_BP; } + + function ensureSlot() { + let slot = document.getElementById(SLOT_ID); + if (slot) return slot; + // On mobile, .nav-right is display:none — use .nav-left so the slot is + // visible. Append after the brand link. + const navLeft = document.querySelector('.nav-left'); + if (!navLeft) return null; + slot = document.createElement('div'); + slot.id = SLOT_ID; + // Layout-only styles inline; visual tokens (border/colors) come from + // the mobile @media block in style.css so the customizer can theme us. + slot.style.cssText = 'display:inline-flex;gap:4px;align-items:center;margin-left:8px;'; + navLeft.appendChild(slot); + return slot; + } + + function clearSlot() { + const slot = document.getElementById(SLOT_ID); + if (slot) slot.innerHTML = ''; + } + + function makeBtn(label, title, onClick) { + const b = document.createElement('button'); + b.className = 'nav-btn'; + b.type = 'button'; + b.title = title; + b.textContent = label; + b.addEventListener('click', onClick); + return b; + } + + function syncForRoute() { + // #1461 #6: close mobile detail sheet on route change away from packets + try { + const sheet = document.getElementById('mobileDetailSheet'); + if (sheet && !/^#\/packets/.test(location.hash || '')) { + sheet.classList.remove('open'); + } + } catch (_e) {} + + if (!isMobile()) { clearSlot(); return; } + const hash = location.hash || ''; + const slot = ensureSlot(); + if (!slot) return; + slot.innerHTML = ''; + + if (/^#\/packets(\/|$|\?)/.test(hash)) { + // Mirror pause button (icon only — small) + const pause = makeBtn('⏸', 'Pause live updates', function () { + const real = document.getElementById('pktPauseBtn'); + if (real) real.click(); + }); + pause.classList.add('mpa-btn-icon'); + slot.appendChild(pause); + // Mirror filter toggle as a labeled "Filters ▾" pill (matches inline style) + const filt = makeBtn('Filters ▾', 'Toggle filters', function () { + const real = document.querySelector('.filter-bar .filter-toggle-btn, #filterToggleBtn'); + if (real) real.click(); + }); + filt.className = 'nav-btn filter-toggle-btn-mirror mpa-btn-pill'; + slot.appendChild(filt); + } + } + + window.addEventListener('hashchange', syncForRoute); + window.addEventListener('resize', syncForRoute); + + /* #1461 #7: on mobile, packets-list group-header expand is a UX dead-end + * (we hid the chevron so there's no way to collapse). Intercept those + * clicks and force them to the single-select code path instead — the + * detail pane has all the obs info anyway. */ + document.addEventListener('click', function (e) { + if (!isMobile()) return; + const row = e.target.closest && e.target.closest('#pktTable tr[data-action="toggle-select"]'); + if (!row) return; + // Convert to a select-hash event by re-dispatching synthetically — simpler + // to mutate the attribute briefly so the existing delegated handler + // routes it correctly. + row.setAttribute('data-action', 'select-hash'); + }, true); + + /* #1461 #8: traffic_share_score / bridge_score tooltips use title= which + * doesn't fire on touch. Show a click-to-toast popover on mobile when + * operator taps a TD whose title mentions traffic/bridge/centrality. */ + document.addEventListener('click', function (e) { + if (!isMobile()) return; + const el = e.target.closest('[title]'); + if (!el) return; + const text = el.getAttribute('title'); + if (!text) return; + // Limit to score / metric explanations to avoid spamming on every titled element + if (!/traffic share|bridge|centrality|score|usefulness/i.test(text + ' ' + el.textContent)) return; + e.preventDefault(); + e.stopPropagation(); + showToast(text); + }, true); + function showToast(msg) { + let t = document.getElementById('mcMobileToast'); + if (!t) { + t = document.createElement('div'); + t.id = 'mcMobileToast'; + t.className = 'mpa-toast'; + document.body.appendChild(t); + } + t.textContent = msg; + t.style.opacity = '1'; + clearTimeout(t._timer); + t._timer = setTimeout(() => { t.style.opacity = '0'; }, 4000); + } + + /* #1467: mirror missing top-nav controls (Favorites, Search, Customize) + * into the bottom-nav More sheet. bottom-nav.js only wired Dark mode; + * the others have no mobile surface today. Insert above the existing + * dark-mode separator so the new items group with the other route items. */ + function addMissingMoreSheetItems(retryCount) { + retryCount = retryCount || 0; + const sheet = document.querySelector('[data-bottom-nav-sheet]'); + if (!sheet) { + // Bounded retry — bottom-nav.js builds the sheet asynchronously, but + // give up after ~5s so we don't poll forever on pages that don't have + // bottom-nav (e.g. embedded views, headless tests). + if (retryCount < 10) setTimeout(() => addMissingMoreSheetItems(retryCount + 1), 500); + return; + } + if (sheet.querySelector('[data-mpa-mirror]')) return; // already injected + + const mirrors = [ + { id: 'favToggle', icon: '⭐', label: 'Favorites' }, + { id: 'searchToggle', icon: '🔍', label: 'Search' }, + { id: 'customizeToggle', icon: '🎨', label: 'Customize' }, + ]; + + const sep = sheet.querySelector('.bottom-nav-sheet-sep'); + mirrors.forEach((m) => { + const real = document.getElementById(m.id); + if (!real) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'bottom-nav-sheet-item'; + btn.setAttribute('role', 'menuitem'); + btn.setAttribute('data-mpa-mirror', m.id); + + const ic = document.createElement('span'); + ic.className = 'bottom-nav-sheet-icon'; + ic.setAttribute('aria-hidden', 'true'); + ic.textContent = m.icon; + + const lb = document.createElement('span'); + lb.className = 'bottom-nav-sheet-label'; + lb.textContent = m.label; + + btn.appendChild(ic); + btn.appendChild(lb); + btn.addEventListener('click', function () { + real.click(); + // close the sheet after delegating + try { sheet.classList.remove('open'); } catch (_e) {} + }); + + if (sep) sheet.insertBefore(btn, sep); + else sheet.appendChild(btn); + }); + } + // Also re-run when sheet is opened (bottom-nav rebuilds it on open) + document.addEventListener('click', function (e) { + const target = e.target.closest && e.target.closest('[data-bottom-nav-more]'); + if (target) setTimeout(addMissingMoreSheetItems, 50); + }, true); + + // Run after page-header is rendered (packets.js builds it async); retry briefly + let tries = 0; + function init() { + syncForRoute(); + addMissingMoreSheetItems(); + if (tries++ < 20 && /^#\/packets/.test(location.hash) && !document.getElementById('pktPauseBtn')) { + setTimeout(init, 250); + } + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/public/nodes.js b/public/nodes.js index 7fc3a699..87acfc3b 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -74,6 +74,29 @@ let wsHandler = null; let detailMap = null; + // #1461 followup: node-detail inset map tile layer that honors the + // customizer dark-tile-provider pick (#1420/#1430). Falls back to + // window.getTileUrl() output if the registry isn't loaded. Also applies + // the provider's invert CSS filter to the tile pane when needed. + function _applyTilesToNodeMap(map) { + if (!map) return; + var tileUrl = (window.getTileUrl && window.getTileUrl()) || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + var provider = window.getActiveTileProvider && window.getActiveTileProvider(); + var attribution = (provider && provider.attribution) || '© OpenStreetMap contributors'; + var layer = L.tileLayer(tileUrl, { maxZoom: 18, attribution: attribution }).addTo(map); + // Esri 2-layer provider: add the labels reference overlay too + if (provider && provider.refUrl) { + try { L.tileLayer(provider.refUrl, { maxZoom: 18 }).addTo(map); } catch (_e) {} + } + // Apply invert CSS filter to the tile pane if the provider needs it + try { + var pane = map.getPane && map.getPane('tilePane'); + if (pane) pane.style.filter = (provider && provider.invertFilter) ? provider.invertFilter : ''; + } catch (_e) {} + return layer; + } + + // ROLE_COLORS loaded from shared roles.js const TABS = [ { key: 'all', label: 'All' }, @@ -676,7 +699,7 @@ try { if (detailMap) { detailMap.remove(); detailMap = null; } detailMap = L.map('nodeFullMap', { zoomControl: true, attributionControl: false }).setView([n.lat, n.lon], 13); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); + _applyTilesToNodeMap(detailMap); L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); setTimeout(() => detailMap.invalidateSize(), 100); } catch {} @@ -1524,7 +1547,7 @@ try { if (detailMap) { detailMap.remove(); detailMap = null; } detailMap = L.map('nodeMap', { zoomControl: false, attributionControl: false }).setView([n.lat, n.lon], 13); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18 }).addTo(detailMap); + _applyTilesToNodeMap(detailMap); L.marker([n.lat, n.lon]).addTo(detailMap).bindPopup(n.name || n.public_key.slice(0, 12)); setTimeout(() => detailMap.invalidateSize(), 100); } catch {} diff --git a/public/roles.js b/public/roles.js index 68b5d2c6..022a40f1 100644 --- a/public/roles.js +++ b/public/roles.js @@ -363,7 +363,35 @@ var isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches); - return isDark ? TILE_DARK : TILE_LIGHT; + if (!isDark) return TILE_LIGHT; + // #1461 followup: honor customizer's dark-tile-provider pick (#1420 / #1430) + // when the registry is loaded. Falls back to TILE_DARK if absent. + try { + if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) { + var id = window.MC_getDarkTileProvider(); + var p = window.MC_TILE_PROVIDERS[id]; + if (p && (p.url || p.baseUrl)) { + return p.url || p.baseUrl; + } + } + } catch (_e) {} + return TILE_DARK; + }; + /* Helper: get the full provider object (for callers that also need the + * invertFilter or refUrl/attribution). Returns null when no customizer + * provider applies (light mode, or registry not loaded). */ + window.getActiveTileProvider = function () { + var isDark = document.documentElement.getAttribute('data-theme') === 'dark' || + (document.documentElement.getAttribute('data-theme') !== 'light' && + window.matchMedia('(prefers-color-scheme: dark)').matches); + if (!isDark) return null; + try { + if (window.MC_getDarkTileProvider && window.MC_TILE_PROVIDERS) { + var id = window.MC_getDarkTileProvider(); + return window.MC_TILE_PROVIDERS[id] || null; + } + } catch (_e) {} + return null; }; // ─── SNR thresholds ─── diff --git a/public/style.css b/public/style.css index d29ae713..ae53e7f9 100644 --- a/public/style.css +++ b/public/style.css @@ -879,6 +879,165 @@ img.brand-logo { width: 32px; min-width: 32px; max-width: 32px; padding: 3px 4px; text-align: center; } + +/* #1415 / #1458 / #1461 — mobile packets UX overhaul. + * NOTE: hiding the first dt/dd pair below assumes Payload Type is the + * first
in .detail-meta (per packets.js renderDetail). Keep that + * invariant — test-issue-1415-packets-layout.js asserts it. */ +@media (max-width: 600px) { + /* #1: kill empty chevron rail */ + #pktTable th.col-expand, + #pktTable td.col-expand { display: none !important; } + + /* #1b: slim THEAD ~24px sticky */ + #pktTable thead th { + padding: 3px 6px !important; + font-size: 9px !important; + line-height: 1.2 !important; + letter-spacing: 0.04em !important; + text-transform: uppercase; + height: 24px !important; + } + #pktTable thead { position: sticky; top: 0; z-index: 5; background: var(--surface-1, #1a1a1a); } + + /* #2: hide page-header row + filter-expr. + * pause + Filters pill are mirrored into .nav-left by mobile-page-actions.js. + * NOTE: do NOT display:none the entire .filter-bar — the original + * .filter-toggle-btn lives inside it and must remain clickable for the + * #534 E2E test (and for any user whose navbar mirror failed to inject). */ + #pktLeft .page-header { display: none !important; } + #pktLeft .pkt-filter-expr { display: none !important; } + + /* Navbar-mirror buttons injected by mobile-page-actions.js. Visual tokens + * (border, colors) live here so the customizer can theme them and so we + * don't ship them as inline cssText strings. */ + .mpa-btn-icon { + padding: 4px 6px; + font-size: 14px; + background: transparent; + border: 1px solid var(--border, #444); + border-radius: 6px; + color: var(--nav-text, #fff); + cursor: pointer; + } + .mpa-btn-pill { + padding: 4px 10px; + font-size: 12px; + background: transparent; + border: 1px solid var(--border, #444); + border-radius: 999px; + color: var(--nav-text, #fff); + cursor: pointer; + font-weight: 500; + } + .mpa-toast { + position: fixed; + left: 50%; + bottom: 90px; + transform: translateX(-50%); + background: var(--surface-2, #222); + color: var(--text, #fff); + padding: 10px 16px; + border: 1px solid var(--border, #444); + border-radius: 8px; + font-size: 13px; + max-width: 80vw; + z-index: 9999; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + transition: opacity .3s; + } + + .filter-bar.filters-expanded { + padding: 6px 8px !important; + margin-bottom: 4px !important; + background: var(--surface-2, rgba(0,0,0,0.05)) !important; + border: 1px solid var(--border, #333) !important; + border-radius: 6px !important; + height: auto; + } + + /* #3: detail panel cleanup */ + /* Strip redundant src→dst line (identity in sticky header) */ + .mobile-detail-sheet .detail-srcdst { display: none !important; } + /* Hide boxed "decoded message" card on mobile — content already in + * the sticky header's semantic summary */ + .mobile-detail-sheet .detail-message { display: none !important; } + /* Hide PAYLOAD TYPE row (first dt+dd pair — already in header badge) */ + .mobile-detail-sheet dl.detail-meta > dt:first-child, + .mobile-detail-sheet dl.detail-meta > dt:first-child + dd { display: none !important; } + /* 2-col grid for label/value rows */ + .mobile-detail-sheet dl.detail-meta { + display: grid; + grid-template-columns: minmax(90px, 30%) 1fr; + gap: 4px 10px; + margin: 8px 0 !important; + } + .mobile-detail-sheet dl.detail-meta dt { + margin: 0 !important; + padding: 0 !important; + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + align-self: center; + } + .mobile-detail-sheet dl.detail-meta dd { + margin: 0 !important; + padding: 0 !important; + font-size: 13px; + } + + /* #4: sheet structure — kill handle, sticky header inside sheet, clear + * bottom-nav (58px tall) so last row doesn't get cut off, taller max */ + /* Restructure: outer sheet has fixed height + overflow:hidden; the + * .mobile-sheet-content is the ONLY scroll region. ✕ close stays sticky + * pinned at top of the sheet. Title also stays sticky within the + * scroll region. */ + .mobile-detail-sheet { + max-height: calc(100vh - 110px) !important; /* leave room for bottom nav */ + padding: 0 !important; + overflow: hidden !important; + display: none; + flex-direction: column; + } + .mobile-detail-sheet.open { display: flex !important; } + .mobile-sheet-handle { display: none !important; } /* conflicts with browser pull-to-refresh */ + .mobile-sheet-close { + position: absolute !important; + top: 6px !important; + right: 10px !important; + background: var(--surface-2, rgba(0,0,0,0.6)) !important; + border-radius: 999px !important; + width: 32px !important; + height: 32px !important; + display: flex !important; + align-items: center; + justify-content: center; + font-size: 16px !important; + z-index: 20 !important; + border: 1px solid var(--border, #444) !important; + } + .mobile-sheet-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 12px 60px !important; + /* visible scrollbar so operator sees there's more */ + scrollbar-width: thin; + } + .mobile-sheet-content::-webkit-scrollbar { width: 6px; } + .mobile-sheet-content::-webkit-scrollbar-thumb { background: var(--border, #555); border-radius: 3px; } + /* Sticky in-sheet header for packet identity */ + .mobile-detail-sheet .detail-title { + position: sticky; + top: 0; + background: var(--detail-bg); + z-index: 5; + padding: 6px 36px 8px 0; + margin: 0 0 8px 0; + border-bottom: 1px solid var(--border); + } +} .data-table td:has(.spark-bar), .data-table td.col-spark { max-width: none; overflow: visible; min-width: 80px; } .data-table .col-time { min-width: clamp(72px, 8vw, 108px); white-space: nowrap; } diff --git a/test-all.sh b/test-all.sh index 95e555ac..cb82537d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -45,6 +45,10 @@ node test-issue-1450-logo-aspect.js node test-issue-1454-channels-toggle.js node test-issue-1456-score-labels.js +# #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E) +node test-issue-1461-mobile-page-actions.js +node test-issue-1470-node-tile-helper.js + echo "" echo "═══════════════════════════════════════" echo " All tests passed" diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 55305c0e..bf05fbdb 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -2071,6 +2071,89 @@ async function run() { // ─── End mobile filter tests ────────────────────────────────────────────── + // ─── #1468 — drop client-side "unknown" channel synthesis ──────────────── + + await test('#1468: live WS CHAN message with no payload.channel is dropped (no "unknown" bucket)', async () => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' }); + // Wait for the channels init() to mount and expose the test hook. + await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 }); + + // Snapshot starting state so we can compare deltas. + const before = await page.evaluate(() => { + const s = window._channelsGetStateForTest(); + return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') }; + }); + + // Feed a CHAN-like message with NO payload.channel field (but valid hash). + await page.evaluate(() => { + window._channelsProcessWSBatchForTest([ + { + type: 'packet', + data: { + hash: 'test1468drophash' + Date.now(), + decoded: { + header: { payloadTypeName: 'GRP_TXT' }, + payload: { /* no `channel` */ text: 'orphan: hello' }, + }, + }, + }, + ], null); + }); + + const after = await page.evaluate(() => { + const s = window._channelsGetStateForTest(); + return { count: s.channels.length, names: s.channels.map(c => c.name || c.channel || '') }; + }); + + // No "unknown" channel materialized. + assert(!after.names.includes('unknown'), + 'channels list does not contain a synthesized "unknown" entry — got ' + JSON.stringify(after.names)); + // And the channel-count delta is 0 — the orphan message was dropped, not bucketed. + assert(after.count === before.count, + `channel count unchanged after orphan WS msg — before=${before.count}, after=${after.count}`); + }); + + await test('#1468 control: same WS message WITH payload.channel is still routed', async () => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window._channelsProcessWSBatchForTest === 'function', { timeout: 10000 }); + + const sentinel = '__test_chan_1468_' + Date.now(); + const before = await page.evaluate((name) => { + const s = window._channelsGetStateForTest(); + return { hasSentinel: s.channels.some(c => (c.name || c.channel) === name) }; + }, sentinel); + assert(!before.hasSentinel, 'pre: sentinel channel does not pre-exist'); + + await page.evaluate((name) => { + window._channelsProcessWSBatchForTest([ + { + type: 'packet', + data: { + hash: 'test1468hash' + Date.now(), + decoded: { + header: { payloadTypeName: 'GRP_TXT' }, + payload: { channel: name, text: 'alice: hi', sender: 'alice' }, + }, + }, + }, + ], null); + }, sentinel); + + const after = await page.evaluate((name) => { + const s = window._channelsGetStateForTest(); + return { + hasSentinel: s.channels.some(c => (c.name || c.channel) === name), + names: s.channels.map(c => c.name || c.channel || ''), + }; + }, sentinel); + assert(after.hasSentinel, + 'control: channel WITH payload.channel IS routed into the registry — got ' + JSON.stringify(after.names)); + }); + + // ─── End #1468 tests ────────────────────────────────────────────────────── + // Extract frontend coverage if instrumented server is running try { const coverage = await page.evaluate(() => window.__coverage__); diff --git a/test-issue-1461-mobile-page-actions.js b/test-issue-1461-mobile-page-actions.js new file mode 100644 index 00000000..59edb039 --- /dev/null +++ b/test-issue-1461-mobile-page-actions.js @@ -0,0 +1,351 @@ +/* test-issue-1461-mobile-page-actions.js — behavioral test for the mobile + * page-actions wiring shipped in #1471. Loads mobile-page-actions.js into + * a vm sandbox with a minimal DOM mock (no jsdom dep), then exercises the + * public observable surfaces: + * + * 1. On mobile + /#/packets, hashchange synthesizes a "Filters ▾" button + * and a "⏸" button under .nav-left, and clicking them delegates to the + * real #filterToggleBtn / #pktPauseBtn elements. + * 2. The bottom-nav More sheet receives 3 injected mirrors (Favorites / + * Search / Customize) when the sheet element is present. + * 3. On desktop viewport, no buttons are injected. + * 4. Idempotent: re-init does not duplicate mirrors. + * 5. Route change off /packets clears the slot AND closes the detail sheet. + * + * No source-grep. All assertions are on real function side effects. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// ── Minimal Element / Document shim — no jsdom dep ──────────────────────── +function makeElement(tag) { + const el = { + tagName: String(tag || 'div').toUpperCase(), + children: [], + parentNode: null, + id: '', + className: '', + type: '', + title: '', + textContent: '', + _attrs: {}, + _listeners: {}, + _clickCount: 0, + }; + el.style = { _cssText: '', get cssText() { return this._cssText; }, set cssText(v) { this._cssText = v; } }; + el.setAttribute = function (k, v) { + this._attrs[k] = String(v); + if (k === 'class') this.className = String(v); + if (k === 'id') this.id = String(v); + }; + el.getAttribute = function (k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; }; + el.appendChild = function (child) { child.parentNode = this; this.children.push(child); return child; }; + el.insertBefore = function (child, ref) { + child.parentNode = this; + const idx = this.children.indexOf(ref); + if (idx < 0) this.children.push(child); + else this.children.splice(idx, 0, child); + return child; + }; + el.removeChild = function (child) { + const idx = this.children.indexOf(child); + if (idx >= 0) this.children.splice(idx, 1); + child.parentNode = null; + return child; + }; + el.addEventListener = function (type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }; + el.dispatchEvent = function (ev) { (this._listeners[ev.type] || []).forEach(fn => fn(ev)); }; + el.click = function () { + this._clickCount++; + this.dispatchEvent({ type: 'click', target: this, preventDefault() {}, stopPropagation() {} }); + }; + el.closest = function (sel) { + let cur = this; + while (cur) { + if (_elementMatches(cur, sel)) return cur; + cur = cur.parentNode; + } + return null; + }; + el.querySelector = function (sel) { return _walk(this, sel, true)[0] || null; }; + el.querySelectorAll = function (sel) { return _walk(this, sel, false); }; + el.classList = { + add(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.add(c); el.className = Array.from(s).join(' '); }, + remove(c) { const s = new Set(String(el.className).split(/\s+/).filter(Boolean)); s.delete(c); el.className = Array.from(s).join(' '); }, + contains(c) { return new Set(String(el.className).split(/\s+/).filter(Boolean)).has(c); }, + }; + Object.defineProperty(el, 'innerHTML', { + configurable: true, + get() { return ''; }, + set(v) { + if (v === '' || v == null) { + el.children.forEach(c => { c.parentNode = null; }); + el.children = []; + } + }, + }); + return el; +} + +function _elementMatches(el, sel) { + if (!el || !sel) return false; + sel = String(sel).trim(); + // Comma list: any selector matches + if (sel.indexOf(',') >= 0) { + return sel.split(',').some(s => _elementMatches(el, s.trim())); + } + // Descendant combinator + if (sel.indexOf(' ') >= 0) { + const parts = sel.split(/\s+/); + const last = parts[parts.length - 1]; + if (!_elementMatches(el, last)) return false; + let cur = el.parentNode; + for (let i = parts.length - 2; i >= 0; i--) { + let found = false; + while (cur) { + if (_elementMatches(cur, parts[i])) { found = true; cur = cur.parentNode; break; } + cur = cur.parentNode; + } + if (!found) return false; + } + return true; + } + // ID + if (sel.startsWith('#')) return el.id === sel.slice(1); + // Attribute [data-X] or [data-X="v"] + const attrMatch = sel.match(/^\[([a-zA-Z0-9-]+)(?:="([^"]+)")?\]$/); + if (attrMatch) { + const k = attrMatch[1], v = attrMatch[2]; + if (!Object.prototype.hasOwnProperty.call(el._attrs, k)) return false; + return v == null ? true : el._attrs[k] === v; + } + // .class + if (sel.startsWith('.')) { + return el.classList && el.classList.contains(sel.slice(1)); + } + // tag.class + if (sel.indexOf('.') > 0) { + const [tag, ...cls] = sel.split('.'); + if (el.tagName.toLowerCase() !== tag.toLowerCase()) return false; + return cls.every(c => el.classList && el.classList.contains(c)); + } + // Bare tag + return el.tagName.toLowerCase() === sel.toLowerCase(); +} + +function _walk(root, sel, firstOnly) { + const out = []; + function visit(n) { + if (n !== root && _elementMatches(n, sel)) { + out.push(n); + if (firstOnly) return true; + } + for (const c of (n.children || [])) { + if (visit(c) && firstOnly) return true; + } + return false; + } + visit(root); + return out; +} + +function makeSandbox(opts) { + opts = opts || {}; + const docElements = {}; + const docRoot = makeElement('html'); + const docBody = makeElement('body'); + docRoot.appendChild(docBody); + const documentListeners = {}; + const windowListeners = {}; + + const documentMock = { + documentElement: docRoot, + body: docBody, + createElement(tag) { return makeElement(tag); }, + getElementById(id) { + if (docElements[id]) return docElements[id]; + function find(n) { + if (n.id === id) return n; + for (const c of (n.children || [])) { const r = find(c); if (r) return r; } + return null; + } + return find(docRoot); + }, + querySelector(sel) { return docRoot.querySelector(sel) || docBody.querySelector(sel); }, + querySelectorAll(sel) { return docRoot.querySelectorAll(sel).concat(docBody.querySelectorAll(sel)); }, + addEventListener(type, fn) { (documentListeners[type] = documentListeners[type] || []).push(fn); }, + dispatchEvent(ev) { (documentListeners[ev.type] || []).forEach(fn => fn(ev)); return true; }, + readyState: 'complete', + }; + + const ctx = { + console, + setTimeout, clearTimeout, + JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map, + document: documentMock, + window: { + innerWidth: opts.innerWidth || 390, + addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); }, + dispatchEvent(ev) { (windowListeners[ev.type] || []).forEach(fn => fn(ev)); return true; }, + }, + location: { hash: opts.hash || '#/packets' }, + }; + ctx.globalThis = ctx; + ctx.window.document = documentMock; + ctx.window.location = ctx.location; + vm.createContext(ctx); + ctx._docRoot = docRoot; + ctx._docBody = docBody; + ctx._documentListeners = documentListeners; + ctx._windowListeners = windowListeners; + ctx._registerElement = (el) => { if (el.id) docElements[el.id] = el; }; + return ctx; +} + +function loadMPA(ctx) { + const src = fs.readFileSync(path.join(__dirname, 'public', 'mobile-page-actions.js'), 'utf8'); + vm.runInContext(src, ctx); +} + +function buildMobileDOM(ctx, opts) { + opts = opts || {}; + const body = ctx._docBody; + + const nav = makeElement('div'); + nav.className = 'nav-left'; + body.appendChild(nav); + + const realFilter = makeElement('button'); + realFilter.id = 'filterToggleBtn'; + realFilter.setAttribute('class', 'filter-toggle-btn'); + body.appendChild(realFilter); + ctx._registerElement(realFilter); + + const realPause = makeElement('button'); + realPause.id = 'pktPauseBtn'; + body.appendChild(realPause); + ctx._registerElement(realPause); + + if (opts.bottomNav !== false) { + const sheet = makeElement('div'); + sheet.setAttribute('data-bottom-nav-sheet', ''); + body.appendChild(sheet); + + ['favToggle', 'searchToggle', 'customizeToggle'].forEach(id => { + const btn = makeElement('button'); + btn.id = id; + body.appendChild(btn); + ctx._registerElement(btn); + }); + const sep = makeElement('div'); + sep.setAttribute('class', 'bottom-nav-sheet-sep'); + sheet.appendChild(sep); + } + + const detailSheet = makeElement('div'); + detailSheet.id = 'mobileDetailSheet'; + detailSheet.classList.add('open'); + body.appendChild(detailSheet); + ctx._registerElement(detailSheet); + + return { nav, realFilter, realPause }; +} + +console.log('── #1461 mobile-page-actions behavioral tests ──'); + +test('mobile + /#/packets: hashchange injects ⏸ + Filters ▾ buttons under .nav-left', () => { + const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' }); + const { nav, realFilter, realPause } = buildMobileDOM(ctx); + loadMPA(ctx); + (ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' })); + + const slot = ctx.document.getElementById('navPageActions'); + assert.ok(slot, 'navPageActions slot was created'); + assert.strictEqual(slot.parentNode, nav, 'slot is appended under .nav-left'); + + const btnTexts = slot.children.map(c => c.textContent); + assert.ok(btnTexts.includes('⏸'), 'pause button (⏸) injected — got: ' + JSON.stringify(btnTexts)); + assert.ok(btnTexts.some(t => /Filters/.test(t)), 'Filters button injected — got: ' + JSON.stringify(btnTexts)); + + const pauseBtn = slot.children.find(c => c.textContent === '⏸'); + pauseBtn.click(); + assert.strictEqual(realPause._clickCount, 1, 'pause-mirror delegates click to #pktPauseBtn'); + + const filtBtn = slot.children.find(c => /Filters/.test(c.textContent)); + filtBtn.click(); + assert.strictEqual(realFilter._clickCount, 1, 'Filters-mirror delegates click to #filterToggleBtn'); +}); + +test('mobile: 3 More-sheet mirrors (Favorites/Search/Customize) injected', () => { + const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' }); + buildMobileDOM(ctx); + loadMPA(ctx); + + const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]'); + assert.ok(sheet, 'sheet found'); + const mirrors = sheet.querySelectorAll('[data-mpa-mirror]'); + assert.strictEqual(mirrors.length, 3, 'exactly 3 mirrors injected — got ' + mirrors.length); + const ids = mirrors.map(m => m.getAttribute('data-mpa-mirror')).sort(); + assert.deepStrictEqual(ids, ['customizeToggle', 'favToggle', 'searchToggle']); + + const favReal = ctx.document.getElementById('favToggle'); + const favMirror = mirrors.find(m => m.getAttribute('data-mpa-mirror') === 'favToggle'); + favMirror.click(); + assert.strictEqual(favReal._clickCount, 1, 'Favorites mirror delegates click to #favToggle'); +}); + +test('mobile: idempotent — re-init does not duplicate More-sheet mirrors', () => { + const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' }); + buildMobileDOM(ctx); + loadMPA(ctx); + (ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' })); + const sheet = ctx.document.querySelector('[data-bottom-nav-sheet]'); + const mirrors = sheet.querySelectorAll('[data-mpa-mirror]'); + assert.strictEqual(mirrors.length, 3, 'still 3 mirrors after re-init — got ' + mirrors.length); +}); + +test('desktop viewport (innerWidth=1280): slot stays empty', () => { + const ctx = makeSandbox({ innerWidth: 1280, hash: '#/packets' }); + buildMobileDOM(ctx); + loadMPA(ctx); + (ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' })); + const slot = ctx.document.getElementById('navPageActions'); + if (slot) { + assert.strictEqual(slot.children.length, 0, 'desktop slot empty — got ' + slot.children.length); + } +}); + +test('mobile + non-packets route: hashchange clears the slot', () => { + const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' }); + buildMobileDOM(ctx); + loadMPA(ctx); + ctx.location.hash = '#/map'; + (ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' })); + const slot = ctx.document.getElementById('navPageActions'); + if (slot) { + assert.strictEqual(slot.children.length, 0, 'slot cleared off /packets — got ' + slot.children.length); + } +}); + +test('mobile detail sheet closes on route change away from /packets', () => { + const ctx = makeSandbox({ innerWidth: 390, hash: '#/packets' }); + buildMobileDOM(ctx); + const sheet = ctx.document.getElementById('mobileDetailSheet'); + assert.ok(sheet.classList.contains('open'), 'pre: sheet open'); + loadMPA(ctx); + ctx.location.hash = '#/map'; + (ctx._windowListeners.hashchange || []).forEach(fn => fn({ type: 'hashchange' })); + assert.ok(!sheet.classList.contains('open'), 'sheet closed after route change off /packets'); +}); + +console.log(`\n#1461 mobile-page-actions: ${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1); diff --git a/test-issue-1470-node-tile-helper.js b/test-issue-1470-node-tile-helper.js new file mode 100644 index 00000000..7ff2854a --- /dev/null +++ b/test-issue-1470-node-tile-helper.js @@ -0,0 +1,226 @@ +/* test-issue-1470-node-tile-helper.js — behavioral test for the + * _applyTilesToNodeMap helper shipped in #1471 and roles.js + * getActiveTileProvider() / getTileUrl() integration with the + * MC_TILE_PROVIDERS registry. + * + * Strategy: + * - vm-load public/map-tile-providers.js + public/roles.js into a single + * sandbox with mocked localStorage + document(theme=dark). + * - Extract the _applyTilesToNodeMap function source from public/nodes.js + * by regex and run it in the same sandbox. + * - Mock L.tileLayer / L.map to capture the URL + options the helper passes + * and the .addTo() target. Assert that selecting voyager-inverted in the + * customizer ends up calling tileLayer with the voyager URL AND setting + * the tile pane filter to the provider.invertFilter string. + * + * No source-grep on the production fix. The assertions exercise the helper + * code path end-to-end. Reverting the fix (re-hardcoding OSM) breaks this. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +function makeStorage() { + const store = {}; + return { + getItem(k) { return 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]; }, + }; +} + +function makeLeafletMock() { + const calls = []; + const tilePane = { style: { filter: '' } }; + const fakeMap = { + _tileLayers: [], + getPane: (name) => name === 'tilePane' ? tilePane : null, + }; + const L = { + tileLayer(url, opts) { + const layer = { + url, opts, + addTo(map) { + calls.push({ kind: 'tileLayer', url, opts, map }); + map._tileLayers.push(this); + return this; + }, + }; + return layer; + }, + map() { return fakeMap; }, + marker() { return { addTo: () => ({ bindPopup: () => {} }) }; }, + }; + return { L, calls, fakeMap, tilePane }; +} + +function makeSandbox(opts) { + opts = opts || {}; + const ctx = { + console, + setTimeout, clearTimeout, + JSON, Date, Math, Object, Array, String, Number, Boolean, Set, Map, + fetch: () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }), + localStorage: makeStorage(), + document: { + documentElement: { + getAttribute: () => opts.theme || 'dark', + style: { getPropertyValue: () => '' }, + }, + querySelector: () => null, + querySelectorAll: () => [], + getElementById: () => null, + createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {} }), + addEventListener: () => {}, + body: { appendChild: () => {}, style: {} }, + head: { appendChild: () => {} }, + readyState: 'complete', + }, + window: { + addEventListener: () => {}, + dispatchEvent: () => true, + matchMedia: () => ({ matches: opts.prefersDark !== false, addEventListener: () => {} }), + }, + CustomEvent: function (type, init) { this.type = type; this.detail = (init && init.detail) || null; }, + }; + ctx.window.localStorage = ctx.localStorage; + ctx.window.document = ctx.document; + ctx.globalThis = ctx; + vm.createContext(ctx); + // After context creation, make the sandbox global object also be `window` + // so `window.X = ...` in roles.js makes X a bare global the same file + // can reference. We do this by copying assignments back — simpler: install + // a Proxy. Cheapest practical fix: shadow common globals after load. + return ctx; +} + +function loadInto(ctx, relPath) { + const src = fs.readFileSync(path.join(__dirname, relPath), 'utf8'); + vm.runInContext(src, ctx, { filename: relPath }); + // Mirror window.* back to sandbox globals so code that uses bare names + // (which in a browser are window.X) can still resolve them in vm. We do + // this AFTER each file load so window.getTileUrl, window.TILE_DARK, etc. + // become bare refs `getTileUrl` / `TILE_DARK` for callers in the same + // sandbox. + for (const k of Object.keys(ctx.window)) { + if (!(k in ctx)) ctx[k] = ctx.window[k]; + } +} + +function extractApplyTilesHelper() { + const nodesSrc = fs.readFileSync(path.join(__dirname, 'public', 'nodes.js'), 'utf8'); + // Match the function _applyTilesToNodeMap(map) { ... } block. Brace-count. + const startMatch = nodesSrc.match(/function\s+_applyTilesToNodeMap\s*\(map\)\s*\{/); + if (!startMatch) throw new Error('_applyTilesToNodeMap definition not found in public/nodes.js'); + const start = startMatch.index; + let depth = 0, i = start; + for (; i < nodesSrc.length; i++) { + const ch = nodesSrc[i]; + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { i++; break; } + } + } + return nodesSrc.slice(start, i); +} + +console.log('── #1470 node-detail inset-map tile-provider routing ──'); + +test('roles.js + providers: getActiveTileProvider returns null in light mode', () => { + const ctx = makeSandbox({ theme: 'light', prefersDark: false }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + const p = ctx.window.getActiveTileProvider(); + assert.strictEqual(p, null, 'expected null in light mode, got ' + JSON.stringify(p)); +}); + +test('roles.js + providers: getActiveTileProvider returns selected provider in dark mode', () => { + const ctx = makeSandbox({ theme: 'dark' }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + ctx.window.MC_setDarkTileProvider('voyager-inverted'); + const p = ctx.window.getActiveTileProvider(); + assert.ok(p, 'provider returned in dark mode'); + assert.ok(typeof p.url === 'string' && /voyager/.test(p.url), + 'provider url is voyager — got ' + JSON.stringify(p.url)); + assert.ok(typeof p.invertFilter === 'string' && /invert\(/.test(p.invertFilter), + 'voyager-inverted invertFilter present — got ' + JSON.stringify(p.invertFilter)); +}); + +test('roles.js: getTileUrl returns voyager URL in dark mode when voyager-inverted selected', () => { + const ctx = makeSandbox({ theme: 'dark' }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + ctx.window.MC_setDarkTileProvider('voyager-inverted'); + const url = ctx.window.getTileUrl(); + assert.ok(/voyager/.test(url), 'getTileUrl returns voyager URL — got ' + url); +}); + +test('_applyTilesToNodeMap: dark + voyager-inverted → tileLayer(voyagerURL) + invert filter', () => { + const ctx = makeSandbox({ theme: 'dark' }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + ctx.window.MC_setDarkTileProvider('voyager-inverted'); + + const mock = makeLeafletMock(); + ctx.L = mock.L; + ctx._fakeMap = mock.fakeMap; + vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' }); + vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx); + + assert.ok(mock.calls.length >= 1, 'tileLayer was called — got ' + mock.calls.length + ' calls'); + const firstCall = mock.calls[0]; + assert.ok(/voyager/.test(firstCall.url), + 'first tileLayer URL is voyager — got ' + firstCall.url); + assert.ok(/invert\(/.test(mock.tilePane.style.filter), + 'tile pane filter set to invert(...) — got ' + JSON.stringify(mock.tilePane.style.filter)); +}); + +test('_applyTilesToNodeMap: dark + carto-dark (non-inverted) → no invert filter applied', () => { + const ctx = makeSandbox({ theme: 'dark' }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + ctx.window.MC_setDarkTileProvider('carto-dark'); + + const mock = makeLeafletMock(); + ctx.L = mock.L; + ctx._fakeMap = mock.fakeMap; + vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' }); + vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx); + + assert.ok(mock.calls.length >= 1, 'tileLayer was called'); + assert.ok(/cartocdn|basemaps\.cartocdn|dark_all/.test(mock.calls[0].url), + 'carto-dark URL — got ' + mock.calls[0].url); + assert.strictEqual(mock.tilePane.style.filter, '', + 'no invert filter for carto-dark — got ' + JSON.stringify(mock.tilePane.style.filter)); +}); + +test('_applyTilesToNodeMap: light mode → uses TILE_LIGHT (carto light_all), no invert', () => { + const ctx = makeSandbox({ theme: 'light', prefersDark: false }); + loadInto(ctx, 'public/map-tile-providers.js'); + loadInto(ctx, 'public/roles.js'); + + const mock = makeLeafletMock(); + ctx.L = mock.L; + ctx._fakeMap = mock.fakeMap; + vm.runInContext(extractApplyTilesHelper(), ctx, { filename: 'nodes.js#_applyTilesToNodeMap' }); + vm.runInContext('_applyTilesToNodeMap(_fakeMap);', ctx); + + assert.ok(mock.calls.length >= 1, 'tileLayer called'); + assert.ok(/light_all|openstreetmap/.test(mock.calls[0].url), + 'light mode uses light tile URL — got ' + mock.calls[0].url); + assert.strictEqual(mock.tilePane.style.filter, '', 'no invert filter in light mode'); +}); + +console.log(`\n#1470 node-detail tile helper: ${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1);