mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 07:12:28 +00:00
feat(mobile): packets UX overhaul + nav surface + map inset + channel synthesis fixes (#1471)
## 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 <bot@openclaw.local>
This commit is contained in:
1 parent
fe997fefb2
commit
d964c27964
10 files changed
+1079
-4
No files matched your search
+7
-1
@@ -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:<name>") so we route the
|
||||
// message to the correct sidebar row and to the open chat view.
|
||||
|
||||
@@ -170,5 +170,6 @@
|
||||
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="mobile-page-actions.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
+25
-2
@@ -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 {}
|
||||
|
||||
+29
-1
@@ -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 ───
|
||||
|
||||
@@ -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 <dt> 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; }
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user