From 79cf453660149fbc6bb38640a8c8b7783ce69afb Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 12 Jun 2026 14:49:37 -0700 Subject: [PATCH] feat(#1633): customizer toggle to hide 1-byte path hops everywhere (#1689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Customize-v2 toggle **Hide 1-byte path hops** (Display tab). Default OFF — operators opt in. When ON, 1-byte path-hash prefixes are filtered at every render site without touching what's stored or what the firmware does. Render sites wired: - **Packets list / detail** (`packets.js renderPath`) — group header, child observations, detail dt/dd, BYOP overlay. Empty result renders `(1-byte filtered)`. - **Map polylines** (`map.js drawPacketRoute`) — intermediate hops tagged `_hopHex`; origin/destination (from payload, no `_hopHex`) always survive. - **Route view** (`route-view.js`) — unique-paths picker + group counts key on the filtered hop list, so routes that only differ by 1-byte hops collapse. - **Analytics route patterns** (`analytics.js`) — filters INPUT rows whose `rawHops` contain any 1-byte token; header reports filtered/total. ## Why 1-byte hashes collide ~8-way at ~2k relay nodes (Cascadia scale). The collisions inflate polyline noise, route-pattern row counts, and chip clutter without adding signal. See #1633 for the full hypothesis. ## How (pure render-time) New `public/hop-filter.js`: - `MC_getHide1ByteHops()` / `MC_setHide1ByteHops(on)` — localStorage `meshcore-hide-1byte-hops`, default OFF. - `MC_isVisibleHop(hop, opts)` — predicate. - `MC_filterPathHops(hops, opts)` — non-mutating array filter. Nothing in the ingest / store / decode path changes. The hop hex stays in `path_json`; only the render iterators drop it. ## Tests `test-issue-1633-hide-1byte-hops.js` — 8 assertions: - Default OFF (back-compat). - `hopByteLen` semantics. - `isVisibleHop` ON drops 1-byte, keeps 2/3-byte. - `filterPathHops` non-mutating. - `HopDisplay.renderPath` chip set after filter. - Map polyline positions[] filter preserves origin/destination. - Analytics route-pattern aggregation key collapses on filtered hops. Wired into `.github/workflows/deploy.yml`. Red commit: `6baa3f13` (5/8 ON-branch assertions failed on stubs). Green commit: `5c0bbdba` (8/8 pass). ## Browser verify Staging deploy of changed files. Packet `99ef781f42eb7249` (all 1-byte path): - BEFORE (toggle OFF): `3 HOPS — Station Rat → KO6IFX-R5 → little russia`. - AFTER (toggle ON): `3 HOPS — (1-byte filtered)`. Customizer toggle visible + working in Display tab. Fixes #1633. --------- Co-authored-by: openclaw-bot Co-authored-by: clawbot --- .github/workflows/deploy.yml | 1 + public/analytics.js | 20 +- public/customize-v2.js | 39 +++ public/hop-filter.js | 92 ++++++ public/index.html | 1 + public/live.js | 38 ++- public/map.js | 86 +++++- public/observer-detail.js | 9 +- public/packets.js | 21 +- public/route-render.js | 32 ++- public/route-view.css | 24 ++ public/route-view.js | 44 ++- test-issue-1633-hide-1byte-hops.js | 447 +++++++++++++++++++++++++++++ 13 files changed, 828 insertions(+), 26 deletions(-) create mode 100644 public/hop-filter.js create mode 100644 test-issue-1633-hide-1byte-hops.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e2cb68e..35adcdca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -148,6 +148,7 @@ jobs: node test-issue-1668-m3-typography.js node test-mqtt-status-panel.js node test-warmup-banner.js + node test-issue-1633-hide-1byte-hops.js - name: 🛡️ Preflight XSS gate — actual --diff check (PR only) # The fixture self-test above (test-preflight-xss-gate.js) only diff --git a/public/analytics.js b/public/analytics.js index 4337e809..99cec728 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1973,13 +1973,27 @@ function renderTable(data, title) { if (!data.subpaths.length) return `

${title}

No data
`; - const maxCount = data.subpaths[0]?.count || 1; + // #1633 — when "Hide 1-byte path hops" is ON, filter route patterns + // whose underlying rawHops contain any 1-byte hex token. We filter + // INPUT (not just CSS-hide) so the displayed % and ordering reflect + // the surviving population. + const _hide1 = !!(typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()); + const _hop1 = function (h) { return String(h || '').length === 2; }; + const subpaths = _hide1 + ? data.subpaths.filter(function (s) { + var rh = s.rawHops || []; + for (var k = 0; k < rh.length; k++) if (_hop1(rh[k])) return false; + return true; + }) + : data.subpaths; + if (!subpaths.length) return `

${title}

No data (all matching routes contained 1-byte hops — toggle off in customizer to see)
`; + const maxCount = subpaths[0]?.count || 1; return `

${title}

-

From ${data.totalPaths.toLocaleString()} paths with 2+ hops

+

From ${data.totalPaths.toLocaleString()} paths with 2+ hops${_hide1 ? ` · showing ${subpaths.length} of ${data.subpaths.length} (1-byte filtered)` : ''}

- ${data.subpaths.map((s, i) => { + ${subpaths.map((s, i) => { const barW = Math.max(2, Math.round(s.count / maxCount * 100)); const hops = s.path.split(' → '); const rawHops = s.rawHops || []; diff --git a/public/customize-v2.js b/public/customize-v2.js index 2a8388ca..0b5fcf6d 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -1567,10 +1567,31 @@ '

Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).

' + '' + _renderChannelsShowEncryptedToggle() + + _renderHide1ByteHopsToggle() + _renderTileProviderSelector() + ''; } + // ── #1633 Hide 1-byte path hops toggle ── + // Writes localStorage["meshcore-hide-1byte-hops"]. Default OFF: key is + // removed (not "false") so MC_getHide1ByteHops cleanly returns false. + // Fires `mc-hide-1byte-hops-changed`; map.js + packets.js subscribe and + // re-render in place (PR #1689 r1 adv #4). Analytics + route-view rebuild + // on next navigation — they don't need live wiring because they re-render + // on tab activation. + function _renderHide1ByteHopsToggle() { + var on = false; + try { on = localStorage.getItem('meshcore-hide-1byte-hops') === 'true'; } catch (_e) {} + return '

Path Display

' + + '

1-byte path-hash prefixes (firmware default) collide ~8-way at ~2k relays — many polylines and route-pattern rows they produce are visual noise. Hide them globally without changing what\'s stored.

' + + '
' + + '' + + '' + + '
'; + } + // ── #1454 Show-encrypted-channels toggle ── // Writes localStorage["channels-show-encrypted"]. Default OFF: key is // removed (not set to "false") so the read-gate in channels.js cleanly @@ -2247,6 +2268,23 @@ }); }); + // #1633 Hide-1-byte-path-hops checkbox — persists + fires + // mc-hide-1byte-hops-changed; consumers re-render or rely on the next + // navigation refresh (analytics tab, packets list). + container.querySelectorAll('[data-cv2-hide-1byte-hops]').forEach(function (cb) { + cb.addEventListener('change', function () { + var on = !!cb.checked; + if (typeof window.MC_setHide1ByteHops === 'function') { + window.MC_setHide1ByteHops(on); + } else { + try { + if (on) localStorage.setItem('meshcore-hide-1byte-hops', 'true'); + else localStorage.removeItem('meshcore-hide-1byte-hops'); + } catch (_e) {} + } + }); + }); + // Preset buttons container.querySelectorAll('.cust-preset-btn').forEach(function (btn) { btn.addEventListener('click', function () { @@ -2725,6 +2763,7 @@ STORAGE_KEY, // 'cs-theme-overrides' 'meshcore-cb-preset', // #1361 CB preset id 'channels-show-encrypted', // #1454 encrypted-channel toggle + 'meshcore-hide-1byte-hops', // #1633 hide 1-byte path hops toggle 'mc-dark-tile-provider' // #1430 dark-tile provider pick ]; for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) { diff --git a/public/hop-filter.js b/public/hop-filter.js new file mode 100644 index 00000000..47c78560 --- /dev/null +++ b/public/hop-filter.js @@ -0,0 +1,92 @@ +/* === CoreScope — hop-filter.js === */ +/* #1633 — Render-time filter that hides 1-byte path hops when the + * customize-v2 toggle is ON. Pure render-time; firmware behavior and + * wire/store contents are untouched. + * + * A "hop" here is a hex-string prefix as stored in observations.path_json + * (e.g. "AB" = 1-byte, "CDEF" = 2-byte, "ABCDEF" = 3-byte). Byte count + * is `floor(hopHex.length / 2)`. + * + * Wire & store stay intact: every consumer call site reads the toggle + * via window.MC_getHide1ByteHops() and filters its rendered/aggregated + * view at the boundary (no upstream mutation). + */ +'use strict'; + +(function () { + var STORAGE_KEY = 'meshcore-hide-1byte-hops'; + + function getHide1ByteHops() { + try { return localStorage.getItem(STORAGE_KEY) === 'true'; } + catch (_e) { return false; } + } + + function setHide1ByteHops(on) { + try { + if (on) localStorage.setItem(STORAGE_KEY, 'true'); + else localStorage.removeItem(STORAGE_KEY); + } catch (_e) { /* private mode */ } + if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') { + window.dispatchEvent(new window.CustomEvent('mc-hide-1byte-hops-changed', { + detail: { value: !!on } + })); + } + } + + // bytes of a hop hex token — handles undefined / non-string. + function hopByteLen(h) { + if (h == null) return 0; + var s = String(h); + return s.length >> 1; + } + + // Render-time predicate. opts may be omitted — if so, falls back to the + // current localStorage value. The hop is hidden only when: + // - opts.hide1ByteHops === true AND + // - the hop hex encodes exactly 1 byte (length === 2) + // Anything else (origin/destination payload hops, multi-byte path hops, + // null/undefined sentinels) stays visible — those callers already + // bypass the filter when they pass undefined/falsey tokens. + function isVisibleHop(hop, opts) { + var enabled = (opts && typeof opts.hide1ByteHops === 'boolean') + ? opts.hide1ByteHops + : getHide1ByteHops(); + if (!enabled) return true; + return hopByteLen(hop) !== 1; + } + + // Filter a path hop array. Returns a NEW array; never mutates the input + // (callers depend on the original path_json staying intact for downstream + // consumers like hash-size detection / raw-hex rendering). + function filterPathHops(hops, opts) { + if (!hops || !hops.length) return hops || []; + var enabled = (opts && typeof opts.hide1ByteHops === 'boolean') + ? opts.hide1ByteHops + : getHide1ByteHops(); + if (!enabled) return hops; + var out = []; + for (var i = 0; i < hops.length; i++) { + if (hopByteLen(hops[i]) !== 1) out.push(hops[i]); + } + return out; + } + + if (typeof window !== 'undefined') { + window.MC_getHide1ByteHops = getHide1ByteHops; + window.MC_setHide1ByteHops = setHide1ByteHops; + window.MC_isVisibleHop = isVisibleHop; + window.MC_filterPathHops = filterPathHops; + window.MC_hopByteLen = hopByteLen; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + getHide1ByteHops: getHide1ByteHops, + setHide1ByteHops: setHide1ByteHops, + isVisibleHop: isVisibleHop, + filterPathHops: filterPathHops, + hopByteLen: hopByteLen, + _STORAGE_KEY: STORAGE_KEY + }; + } +})(); diff --git a/public/index.html b/public/index.html index 99f413cc..f3d6aab2 100644 --- a/public/index.html +++ b/public/index.html @@ -172,6 +172,7 @@ + diff --git a/public/live.js b/public/live.js index ea43b8c6..af52cd59 100644 --- a/public/live.js +++ b/public/live.js @@ -2541,9 +2541,24 @@ return; } const COLLAPSE = 5; + // #1689 r1 (adv #3): respect the customizer "hide 1-byte path hops" + // toggle in the live-pane "Paths Through" widget. /api/.../paths + // returns hops as objects {prefix,pubkey,name}; the byte-length is + // derived from the hex prefix. + function _filterHopObjs(hopObjs) { + if (typeof window === 'undefined' || !window.MC_isVisibleHop) return hopObjs; + if (!window.MC_getHide1ByteHops || !window.MC_getHide1ByteHops()) return hopObjs; + return (hopObjs || []).filter(function (h) { + return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : ''); + }); + } function renderPathList(paths) { return paths.map(p => { - const chain = p.hops.map(h => { + const filteredHops = _filterHopObjs(p.hops || []); + if (!filteredHops.length) { + return `
— (1-byte filtered) (${p.count}×)
`; + } + const chain = filteredHops.map(h => { const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase())); const name = escapeHtml(h.name || h.prefix); if (isThis) return `${name}`; @@ -2680,6 +2695,13 @@ function packetInvolvesFilterNode(pkt, filterKeys) { if (!filterKeys.length) return true; + // #1689 r2 MAJOR: node-filter search semantics MUST be independent of + // the customizer's hide-1-byte-hops display preference. Letting the + // display toggle change search results silently fails the principle + // of least astonishment (operator searches for a node, hides 1-byte + // hops for chart readability, suddenly matches disappear). Chip + // rendering filters hops separately via MC_filterPathHops at the + // render boundary — see feedHops below. const hops = (pkt.decoded?.path?.hops) || []; for (const hop of hops) { const h = (hop.id || hop.public_key || hop).toString().toLowerCase(); @@ -3187,6 +3209,20 @@ } if (fpHops.length > feedHops.length) feedHops = fpHops; } + // #1689 r1 (adv #3): apply the customizer "hide 1-byte path hops" + // toggle to the feed-item hop count + chip rendering. feedHops here + // is an array of raw hex tokens (from path_json) or hop-objects (from + // decoded.path.hops) — MC_filterPathHops only knows about hex strings + // so we branch on shape. + if (typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) { + if (feedHops.length && typeof feedHops[0] === 'string') { + feedHops = window.MC_filterPathHops(feedHops); + } else if (feedHops.length && window.MC_isVisibleHop) { + feedHops = feedHops.filter(function (h) { + return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : (h && h.id ? String(h.id) : '')); + }); + } + } addFeedItem(icon, typeName, payload, feedHops, color, consolidated); // Store all observation packets in dedup entry for replay tree if (consolidated.hash && feedDedup.has(consolidated.hash)) { diff --git a/public/map.js b/public/map.js index 1e4e0211..bbb57542 100644 --- a/public/map.js +++ b/public/map.js @@ -341,6 +341,26 @@ _syncDarkTiles(dark); }); + // #1689 r1 (adv #4): live re-render when the customizer "hide 1-byte + // path hops" toggle flips. Before this listener the toggle only took + // effect on the next navigation despite an inline comment claiming + // "live update". Re-issue the most recent drawPacketRoute(Multi) call + // so the polyline + redacted badge update in place. + if (typeof window !== 'undefined' && !window.__mc_map_hide1byte_wired) { + window.__mc_map_hide1byte_wired = true; + window.addEventListener('mc-hide-1byte-hops-changed', function () { + try { + const last = window.__mc_lastRouteDraw; + if (!last) return; + if (last.kind === 'multi' && typeof window.drawPacketRouteMulti === 'function') { + window.drawPacketRouteMulti(last.paths, last.origin, last.opts); + } else if (last.kind === 'single' && typeof window.drawPacketRoute === 'function') { + window.drawPacketRoute(last.hopKeys, last.origin, last.opts); + } + } catch (e) { console.warn('[map] hide-1byte re-render failed', e); } + }); + } + // Save position on move map.on('moveend', () => { const c = map.getCenter(); @@ -611,6 +631,9 @@ origin = { pubkey: origin }; } opts = opts || {}; + // #1689 r1 (adv #4): remember the last route-draw inputs so the + // mc-hide-1byte-hops-changed listener can re-render in place. + try { window.__mc_lastRouteDraw = { kind: 'single', hopKeys: hopKeys, origin: origin, opts: opts }; } catch (_e) {} // #1422: use the backend's /api/resolve-hops for proper disambiguation // (unique_prefix vs multi-byte vs gps_preference vs affinity scoring). // Falls back to naive nodes.filter() scan if the API is unreachable. @@ -670,15 +693,20 @@ // renderer can label it "no GPS" instead of "unresolved prefix". const raw = hopKeys.map(hop => { const hopLower = String(hop).toLowerCase(); + // #1633 — tag the original hex token so MC_isVisibleHop can filter + // 1-byte hops out of the polyline/sidebar when the customizer toggle + // is ON. The hop value stays in place upstream (resolve, fitBounds + // ordering) — only the polyline render iterator drops it. + const _hopHex = String(hop); // Try server resolution first const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]); if (srv && srv.pubkey) { const c = srv.candidates && srv.candidates[0]; if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) { - return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true }; + return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true, _hopHex }; } // Server resolved but node has no usable GPS - return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true }; + return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true, _hopHex }; } // Fallback: naive local scan (kept for resilience when API is down). const allMatches = nodes.filter(n => { @@ -688,14 +716,14 @@ const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0)); if (withGps.length === 1) { const c = withGps[0]; - return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true }; + return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true, _hopHex }; } else if (withGps.length > 1) { - return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps }; + return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps, _hopHex }; } else if (allMatches.length >= 1) { const c = allMatches[0]; - return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true }; + return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true, _hopHex }; } - return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false }; + return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false, _hopHex }; }); // Disambiguate: pick candidate closest to center of already-resolved hops @@ -716,7 +744,7 @@ } } - const positions = raw.filter(h => h != null); + let positions = raw.filter(h => h != null); // Resolve and prepend origin node if (origin) { @@ -762,6 +790,34 @@ // Mark final hop as destination so the renderer applies the dest glyph. positions[positions.length - 1].isDest = true; + // #1633 — render-time 1-byte hop filter (default OFF). Origin / destination + // positions are added without _hopHex (they came from the payload, not the + // outer path bytes) and therefore always survive. Intermediate hops with a + // 1-byte _hopHex are dropped when the customizer toggle is ON. + // + // #1689 r1 (adv #2): if the filter drops every intermediate hop the result + // is a 2-point origin→dest polyline that is visually identical to a + // "direct delivery, no hops" route. Operators misread that as ground-truth + // direct path. To make the redaction LEGIBLE we tag the surviving + // positions with `_hopsHiddenCount` so the renderer can draw the polyline + // in a different style (dashed/lighter) and add a midpoint badge that + // reads "N hops hidden (1-byte)". + var hopsHiddenCount = 0; + if (window.MC_isVisibleHop && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) { + var beforeCount = positions.length; + var beforeIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length; + positions = positions.filter(function (p) { + return !p._hopHex || window.MC_isVisibleHop(p._hopHex); + }); + var afterIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length; + hopsHiddenCount = beforeIntermediate - afterIntermediate; + if (positions.length < beforeCount && positions.length >= 1) { + // Re-mark last surviving hop as destination if the original got dropped. + positions[positions.length - 1].isDest = true; + } + if (positions.length < 1) return; + } + // Hand off to sequence-primary sequence-primary renderer (#1418), falling // back to the legacy role-aware MeshRoute (#1374), then to the minimal // polyline (should never run in production). @@ -770,13 +826,17 @@ timestamp: opts.timestamp || Date.now(), packetHash: opts.packetHash || null, observationId: opts.observationId || null, - packetContext: opts.packetContext || null + packetContext: opts.packetContext || null, + // #1689 r1 (adv #2): tell the renderer how many intermediate hops + // the 1-byte filter dropped so it can style the polyline as redacted. + hopsHiddenCount: hopsHiddenCount }); return; } if (window.MeshRoute && typeof window.MeshRoute.render === 'function') { window.MeshRoute.render(map, routeLayer, positions, { - timestamp: opts.timestamp || Date.now() + timestamp: opts.timestamp || Date.now(), + hopsHiddenCount: hopsHiddenCount }); return; } @@ -784,7 +844,10 @@ // ── Legacy fallback (kept tiny — should never run in production) ───── const coords = positions.filter(p => p.lat != null).map(p => [p.lat, p.lon]); if (coords.length >= 2) { - L.polyline(coords, { color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4' }).addTo(routeLayer); + L.polyline(coords, { + color: '#f59e0b', weight: 3, opacity: hopsHiddenCount > 0 ? 0.55 : 0.8, + dashArray: hopsHiddenCount > 0 ? '4 6' : '8 4' + }).addTo(routeLayer); map.fitBounds(L.latLngBounds(coords).pad(0.3)); } else if (coords.length === 1) { map.setView(coords[0], 13); @@ -804,6 +867,9 @@ opts = opts || {}; if (typeof origin === 'string') origin = { pubkey: origin }; if (!Array.isArray(paths) || paths.length === 0) return; + // #1689 r1 (adv #4): remember last multi-path draw for live re-render + // when the hide-1-byte-hops toggle changes. + try { window.__mc_lastRouteDraw = { kind: 'multi', paths: paths, origin: origin, opts: opts }; } catch (_e) {} if (paths.length === 1) { return drawPacketRoute(paths[0].path || [], origin, opts); } diff --git a/public/observer-detail.js b/public/observer-detail.js index fe77f896..0db88baa 100644 --- a/public/observer-detail.js +++ b/public/observer-detail.js @@ -439,7 +439,14 @@ window.ObserverDetailNaiveBanner = { ${packets.map(p => { const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {}); - const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []); + const rawHops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []); + // #1689 r1 (adv #3): honor the customizer "hide 1-byte path hops" + // toggle for the hops-count column. Counting raw hops here was + // missed by the original PR; operators expect the count to match + // what's displayed everywhere else when the toggle is ON. + const hops = (typeof window !== 'undefined' && window.MC_filterPathHops) + ? window.MC_filterPathHops(rawHops) + : rawHops; const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type; return ` diff --git a/public/packets.js b/public/packets.js index 0f1c377b..a6948dcb 100644 --- a/public/packets.js +++ b/public/packets.js @@ -963,7 +963,14 @@ function renderPath(hops, observerId) { if (!hops || !hops.length) return '—'; - return hops.map(h => renderHop(h, observerId)).join(''); + // #1633 — render-time filter (default OFF). Applies at every consumer + // because every site funnels through this function (group header, child + // observation, packet detail dt/dd, byop overlay). + var filtered = (typeof window !== 'undefined' && window.MC_filterPathHops) + ? window.MC_filterPathHops(hops) + : hops; + if (!filtered.length) return '— (1-byte filtered)'; + return filtered.map(h => renderHop(h, observerId)).join(''); } let directPacketId = null; @@ -1005,6 +1012,18 @@ async function init(app, routeParam) { const gen = ++initGeneration; + // #1689 r1 (adv #4): subscribe to the customizer hide-1-byte-hops toggle + // so the packets table re-renders LIVE when operators flip it (the + // toggle promised "applies everywhere" — without a listener it only + // took effect on next navigation, which the inline comment lied about). + // The listener is idempotent via a flag on `window` so re-entering the + // route doesn't stack handlers. + if (typeof window !== 'undefined' && !window.__mc_packets_hide1byte_wired) { + window.__mc_packets_hide1byte_wired = true; + window.addEventListener('mc-hide-1byte-hops-changed', function () { + try { renderTableRows(); } catch (e) { console.warn('[packets] hide-1byte re-render failed', e); } + }); + } // Parse ?obs=OBSERVER_ID from routeParam if (routeParam && routeParam.includes('?')) { const qIdx = routeParam.indexOf('?'); diff --git a/public/route-render.js b/public/route-render.js index 9be9af94..cf6a54c6 100644 --- a/public/route-render.js +++ b/public/route-render.js @@ -300,21 +300,45 @@ window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ── Edges ─────────────────────────────────────────────────────── + // #1689 r1 (adv #2): when the 1-byte hop filter dropped hops, draw the + // edges as dashed + lighter so operators don't misread the resulting + // origin→dest polyline as "direct delivery, no hops in path". + var hopsHiddenCount = (opts && typeof opts.hopsHiddenCount === 'number') ? opts.hopsHiddenCount : 0; + var redacted = hopsHiddenCount > 0; for (var i = 0; i < total - 1; i++) { var a = positions[i], b = positions[i + 1]; if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue; var color = seqColor(i, total - 1); var dist = haversineKm(a, b); var ariaLabel = 'Hop ' + (i + 1) + ' \u2192 ' + (i + 2) + - (dist != null ? ', ~' + dist + 'km' : ''); + (dist != null ? ', ~' + dist + 'km' : '') + + (redacted ? ' (' + hopsHiddenCount + ' 1-byte hop(s) hidden)' : ''); + var edgeDash = (a.resolved === false || b.resolved === false) + ? '6 4' + : (redacted ? '4 6' : null); var poly = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], { color: color, weight: 3.5, - opacity: 0.92, - dashArray: (a.resolved === false || b.resolved === false) ? '6 4' : null, - className: 'mc-route-edge' + opacity: redacted ? 0.6 : 0.92, + dashArray: edgeDash, + className: 'mc-route-edge' + (redacted ? ' mc-route-edge-redacted' : '') }).addTo(layer); + // Midpoint badge — only on the first redacted edge — that reads + // "N hops hidden (1-byte)". Reuses Leaflet's divIcon so it picks up + // the theme tokens via .mc-route-redacted-badge in CSS. + if (redacted && i === 0) { + var mid = [(a.lat + b.lat) / 2, (a.lon + b.lon) / 2]; + var badgeHtml = '' + + hopsHiddenCount + ' hop' + (hopsHiddenCount === 1 ? '' : 's') + ' hidden (1-byte)' + + ''; + L.marker(mid, { + icon: L.divIcon({ className: 'mc-route-redacted-badge-wrap', html: badgeHtml, iconSize: [120, 18], iconAnchor: [60, 9] }), + interactive: true, + keyboard: false + }).addTo(layer); + } + // Patch the rendered element to add aria-label + marker-end. // Leaflet builds it on the next animation frame, so defer. (function (polyRef, lbl, col) { diff --git a/public/route-view.css b/public/route-view.css index 91e136df..1eedec21 100644 --- a/public/route-view.css +++ b/public/route-view.css @@ -726,3 +726,27 @@ body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map { .mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-ctx { margin: 4px 0 2px; padding: 4px 6px; font-size: 11px; } .mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-row { padding: 3px 10px 3px 0; font-size: 11px; } } + +/* #1689 r1 (adv #2) — redacted-route badge shown at the midpoint of a + * polyline whose intermediate hops were filtered out by the customizer + * "Hide 1-byte path hops" toggle. Operators must not mistake the + * straight origin→dest line for a "no hops, direct delivery" route. */ +.mc-route-redacted-badge-wrap { + pointer-events: auto; +} +.mc-route-redacted-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-muted, #1f2937); + color: var(--text-muted, #d1d5db); + border: 1px dashed var(--accent, #f59e0b); + font-size: 10px; + line-height: 14px; + white-space: nowrap; + font-family: var(--font-sans, system-ui, sans-serif); +} +.mc-route-edge-redacted { + /* dashArray is set per-edge in route-render.js; this hook is for + * customizer overrides + tests to assert the redacted-state class. */ +} diff --git a/public/route-view.js b/public/route-view.js index d3a03d49..b9748176 100644 --- a/public/route-view.js +++ b/public/route-view.js @@ -324,7 +324,21 @@ var contextBlock = buildPacketContextBlock(pktCtx); var uniquePathsCount = (opts && opts.allPaths) ? (function () { var seen = {}; - opts.allPaths.forEach(function (p) { seen[(p.path || []).join('-')] = true; }); + opts.allPaths.forEach(function (p) { + // #1633 — collapse paths whose only difference is 1-byte hops when + // the customizer toggle is ON. Aggregation uses the FILTERED hop + // list so the picker count reflects the surviving distinct routes. + // #1689 r1 (adv #1): if the filter strips EVERY hop the key becomes + // an empty string and distinct all-1-byte routes collide into one + // bucket. Fall back to the raw key with an `__all1byte__::` prefix + // so they stay distinct while still being marked as filtered. + var rawHops = (p.path || []); + var hops = (typeof window !== 'undefined' && window.MC_filterPathHops) + ? window.MC_filterPathHops(rawHops) + : rawHops; + var key = hops.length ? hops.join('-') : ('__all1byte__::' + rawHops.join('-')); + seen[key] = true; + }); return Object.keys(seen).length; })() : 1; var multiPathChip = ''; @@ -338,8 +352,16 @@ // paths, each with the observer-count and a click-to-isolate affordance. var pathGroups = {}; (opts.allPaths || []).forEach(function (p) { - var k = (p.path || []).join('→'); - if (!pathGroups[k]) pathGroups[k] = { key: k, observers: [], count: 0 }; + var rawHops = p.path || []; + // #1633 — same render-time filter as the count above. + // #1689 r1 (adv #1): empty filtered result must NOT collide with + // other all-1-byte paths. Use a sentinel that includes the rawHops + // signature so distinct routes stay separate buckets in the picker. + var hops = (typeof window !== 'undefined' && window.MC_filterPathHops) + ? window.MC_filterPathHops(rawHops) + : rawHops; + var k = hops.length ? hops.join('→') : ('⟨all-1byte⟩::' + rawHops.join('→')); + if (!pathGroups[k]) pathGroups[k] = { key: k, observers: [], count: 0, allOneByte: hops.length === 0 && rawHops.length > 0, rawHops: rawHops }; pathGroups[k].observers.push(p.observer || '?'); pathGroups[k].count++; }); @@ -347,10 +369,20 @@ var pickerRows = groupList.map(function (g, idx) { var sample = g.observers[0]; var moreSuffix = g.observers.length > 1 ? ' +' + (g.observers.length - 1) : ''; - var hops = g.key.split('→').filter(function(s){return s.length>0;}); - return '
  • ' + + // #1689 r1 (kb #2): all-1-byte path → show a labeled chip instead of + // an empty hop row so operators see WHY the route appears collapsed. + var hopsHtml, hopsCount; + if (g.allOneByte) { + hopsCount = (g.rawHops || []).length; + hopsHtml = '— (' + hopsCount + ' × 1-byte filtered)'; + } else { + var hops = g.key.split('→').filter(function(s){return s.length>0;}); + hopsCount = hops.length; + hopsHtml = hops.map(escapeHtml).join(' → '); + } + return '
  • ' + '' + g.count + '/' + totalObservers + '' + - '' + hops.map(escapeHtml).join(' → ') + '' + + '' + hopsHtml + '' + '' + escapeHtml(sample) + moreSuffix + '' + '
  • '; }).join(''); diff --git a/test-issue-1633-hide-1byte-hops.js b/test-issue-1633-hide-1byte-hops.js new file mode 100644 index 00000000..221831c7 --- /dev/null +++ b/test-issue-1633-hide-1byte-hops.js @@ -0,0 +1,447 @@ +/* test-issue-1633-hide-1byte-hops.js + * #1633 — customizer toggle that hides 1-byte path hops at every + * render site. Render-time only; firmware/store untouched. + * + * Asserts: + * 1. Defaults OFF (back-compat: no surprise for existing operators). + * 2. With hide1ByteHops=true a mixed path renders ONLY multi-byte hops. + * 3. With hide1ByteHops=false the full path renders. + * 4. HopDisplay.renderPath emits exactly the multi-byte chips when ON. + * 5. Map polyline source: positions tagged with 1-byte _hopHex are dropped + * from the polyline path when ON; 2/3-byte hops survive; origin / + * destination (no _hopHex) are always kept. + * 6. Analytics route-pattern aggregation: rebuilt path key contains ONLY + * multi-byte hop hexes when ON. + */ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +// ── DOM-less sandbox so hop-display.js / hop-filter.js load cleanly ── +function makeSandbox() { + const store = {}; + const ctx = { + window: { + addEventListener: () => {}, + dispatchEvent: () => {}, + CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; } + }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '', dataset: {}, setAttribute(){}, getAttribute(){return null;} }), + head: { appendChild: () => {} }, + body: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + localStorage: { + getItem: k => (k in store ? 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]; } + }, + console, Math, Date, Array, Object, String, Number, JSON, Boolean, + module: { exports: {} }, exports: {}, + }; + ctx.window.localStorage = ctx.localStorage; + vm.createContext(ctx); + return ctx; +} + +function load(ctx, file) { + const src = fs.readFileSync(path.join(__dirname, file), 'utf8'); + vm.runInContext(src, ctx, { filename: file }); +} + +// === Build a sandbox with hop-filter + hop-display loaded === +function freshSandbox() { + const ctx = makeSandbox(); + load(ctx, 'public/hop-filter.js'); + load(ctx, 'public/hop-display.js'); + return ctx; +} + +// === Heavier sandbox that loads live.js (mirrors test-live.js pattern) === +function makeLiveSandbox() { + const ctx = { + window: { + addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1, + CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; }, + }, + document: { + readyState: 'complete', + createElement: () => ({ + tagName: '', id: '', textContent: '', innerHTML: '', style: {}, dataset: {}, + classList: { add(){}, remove(){}, contains(){return false;} }, + setAttribute(){}, getAttribute(){return null;}, + addEventListener(){}, focus(){}, + getContext: () => ({ clearRect(){}, fillRect(){}, beginPath(){}, arc(){}, fill(){}, scale(){}, fillStyle: '', font: '', fillText(){} }), + offsetWidth: 200, offsetHeight: 40, width: 0, height: 0, + }), + head: { appendChild: () => {} }, + body: { appendChild: () => {}, removeChild: () => {}, contains: () => false }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + createElementNS: () => ({ tagName: 'svg', setAttribute(){}, getAttribute(){return null;}, style: {} }), + documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} }, + hidden: false, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, Map, Set, Promise, URLSearchParams, + parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => 0, clearTimeout: () => {}, + setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + requestAnimationFrame: (cb) => 0, + cancelAnimationFrame: () => {}, + localStorage: (() => { + const store = {}; + return { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, + }; + })(), + location: { hash: '', protocol: 'https:', host: 'localhost' }, + addEventListener: () => {}, + dispatchEvent: () => {}, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + matchMedia: () => ({ matches: false, addEventListener: () => {} }), + navigator: {}, + L: { + circleMarker: () => ({ addTo(){return this;}, bindTooltip(){return this;}, on(){return this;}, setRadius(){}, setStyle(){}, setLatLng(){}, getLatLng(){return {lat:0,lng:0};}, remove(){} }), + polyline: () => ({ addTo(){return this;}, setStyle(){}, remove(){} }), + polygon: () => ({ addTo(){return this;}, remove(){} }), + map: () => ({ setView(){return this;}, addLayer(){return this;}, on(){return this;}, getZoom(){return 11;}, getCenter(){return {lat:37,lng:-122};}, getBounds(){return {contains:()=>true};}, fitBounds(){return this;}, invalidateSize(){}, remove(){}, hasLayer(){return false;}, removeLayer(){} }), + layerGroup: () => ({ addTo(){return this;}, addLayer(){}, removeLayer(){}, clearLayers(){}, hasLayer(){return true;}, eachLayer(){} }), + tileLayer: () => ({ addTo(){return this;} }), + control: { attribution: () => ({ addTo(){} }) }, + DomUtil: { addClass(){}, removeClass(){} }, + }, + registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {}, + api: () => Promise.resolve([]), invalidateApiCache: () => {}, + favStar: () => '', bindFavStars: () => {}, + getFavorites: () => [], isFavorite: () => false, + HopResolver: { init(){}, resolve: () => ({}), ready: () => false }, + MeshAudio: null, + RegionFilter: { init(){}, getSelected: () => null, onRegionChange: () => {} }, + CustomEvent: function (n, d) { this.type = n; this.detail = (d && d.detail) || null; }, + module: { exports: {} }, exports: {}, + }; + ctx.window.localStorage = ctx.localStorage; + vm.createContext(ctx); + // Load filter helpers first so live.js sees window.MC_* on import. + load(ctx, 'public/hop-filter.js'); + // Mirror window.* onto top-level for files that reference bare names. + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + // packet-helpers / roles are required by live.js for getParsedPath etc. + try { load(ctx, 'public/roles.js'); } catch (_e) {} + try { load(ctx, 'public/packet-helpers.js'); } catch (_e) {} + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + try { load(ctx, 'public/live.js'); } catch (e) { + console.error('live.js load error:', e.message); + } + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + return ctx; +} + +console.log('=== #1633: hide 1-byte path hops everywhere ==='); + +test('default is OFF (back-compat)', () => { + const ctx = freshSandbox(); + assert.strictEqual(ctx.window.MC_getHide1ByteHops(), false, 'default must be OFF'); +}); + +test('hopByteLen: "AB"=1, "ABCD"=2, "ABCDEF"=3, ""=0', () => { + const ctx = freshSandbox(); + const bl = ctx.window.MC_hopByteLen; + assert.strictEqual(bl('AB'), 1); + assert.strictEqual(bl('ABCD'), 2); + assert.strictEqual(bl('ABCDEF'), 3); + assert.strictEqual(bl(''), 0); + assert.strictEqual(bl(null), 0); +}); + +test('isVisibleHop: toggle OFF → every hop visible', () => { + const ctx = freshSandbox(); + const f = ctx.window.MC_isVisibleHop; + assert.strictEqual(f('AB', { hide1ByteHops: false }), true); + assert.strictEqual(f('CDEF', { hide1ByteHops: false }), true); +}); + +test('isVisibleHop: toggle ON → 1-byte HIDDEN, 2/3-byte SHOWN', () => { + const ctx = freshSandbox(); + const f = ctx.window.MC_isVisibleHop; + assert.strictEqual(f('AB', { hide1ByteHops: true }), false, '1-byte must hide'); + assert.strictEqual(f('CDEF', { hide1ByteHops: true }), true, '2-byte must stay'); + assert.strictEqual(f('ABCDEF', { hide1ByteHops: true }), true, '3-byte must stay'); +}); + +test('filterPathHops: mixed input → only multi-byte kept when ON', () => { + const ctx = freshSandbox(); + const f = ctx.window.MC_filterPathHops; + const mixed = ['AB', 'CDEF', '12', 'ABCDEF', '34']; + // Use Array.from to bridge the vm-realm Array prototype gap. + assert.deepStrictEqual( + Array.from(f(mixed, { hide1ByteHops: true })), + ['CDEF', 'ABCDEF'], + 'must drop every 1-byte entry' + ); + assert.deepStrictEqual( + Array.from(f(mixed, { hide1ByteHops: false })), + mixed, + 'OFF must return input unchanged' + ); +}); + +test('HopDisplay.renderPath rendered chips match filtered hops when ON', () => { + const ctx = freshSandbox(); + // Apply the filter at the boundary the way every render site will. + const hops = ['AB', 'CDEF', '12', 'ABCDEF']; + const filtered = ctx.window.MC_filterPathHops(hops, { hide1ByteHops: true }); + const html = ctx.window.HopDisplay.renderPath(filtered, {}, { hexMode: true, link: false }); + // Filtered chip set: ONLY 2-byte + 3-byte tokens present. + assert.ok(html.indexOf('CDEF') !== -1, 'CDEF chip must render'); + assert.ok(html.indexOf('ABCDEF') !== -1, 'ABCDEF chip must render'); + // 1-byte hex tokens must NOT appear in the chip set. Use word + // boundaries with the chip wrapper to avoid matching substrings of + // the larger hops. + assert.strictEqual(html.indexOf('>AB<'), -1, '1-byte AB must NOT render as a chip'); + assert.strictEqual(html.indexOf('>12<'), -1, '1-byte 12 must NOT render as a chip'); +}); + +test('Map polyline source: positions[]._hopHex 1-byte dropped, origin/dest kept', () => { + const ctx = freshSandbox(); + // Origin & destination have no _hopHex (came from payload, not from path bytes). + // Intermediate hops carry _hopHex tagged by drawPacketRoute. + const positions = [ + { name: 'Origin', isOrigin: true }, + { name: 'h1', _hopHex: 'AB' }, // 1-byte → filterable + { name: 'h2', _hopHex: 'CDEF' }, // 2-byte → keep + { name: 'h3', _hopHex: '12' }, // 1-byte → filterable + { name: 'h4', _hopHex: 'ABCDEF' }, // 3-byte → keep + { name: 'Dest', isDest: true } + ]; + const opts = { hide1ByteHops: true }; + const kept = positions.filter(p => + !p._hopHex || ctx.window.MC_isVisibleHop(p._hopHex, opts) + ); + assert.deepStrictEqual( + kept.map(p => p.name), + ['Origin', 'h2', 'h4', 'Dest'], + 'polyline retains origin/dest + multi-byte hops only' + ); +}); + +test('Analytics: route-pattern aggregation key drops 1-byte hops when ON', () => { + const ctx = freshSandbox(); + // Each packet contributes one path. With hide ON, aggregation must key on + // the FILTERED hop list so 1-byte noise stops inflating distinct counts. + const packets = [ + { path_json: ['AB', 'CDEF', '12', 'ABCDEF'] }, + { path_json: ['99', 'CDEF', '88', 'ABCDEF'] }, // same multi-byte key + { path_json: ['CDEF', 'ABCDEF'] } // identical w/o 1-byte + ]; + const opts = { hide1ByteHops: true }; + const counts = {}; + for (const p of packets) { + const key = ctx.window.MC_filterPathHops(p.path_json, opts).join('→'); + counts[key] = (counts[key] || 0) + 1; + } + assert.deepStrictEqual(counts, { 'CDEF→ABCDEF': 3 }, + 'all three rows collapse to one pattern with 3 hits'); +}); + +// ───────────────────────────────────────────────────────────────────── +// #1689 r1 — additional regressions surfaced by adversarial + Kent Beck +// review on the original PR. +// ───────────────────────────────────────────────────────────────────── + +console.log('\n=== #1689 r1: review fix-ups (adv + kb findings) ==='); + +test('[kb #3] MC_filterPathHops handles null/undefined/empty without throwing', () => { + const ctx = freshSandbox(); + const f = ctx.window.MC_filterPathHops; + assert.deepStrictEqual(Array.from(f([])), [], 'empty in → empty out'); + // null/undefined inputs should NOT throw. The contract is "return a + // safe empty-ish array". Either [] or the input back is acceptable; + // we assert non-throwing + array-shape. + const a = f(null); + const b = f(undefined); + assert.ok(Array.isArray(a) || a == null, 'null → null/array'); + assert.ok(Array.isArray(b) || b == null, 'undefined → null/array'); + assert.deepStrictEqual(Array.from(f([], { hide1ByteHops: true })), [], 'empty in + ON → empty'); +}); + +test('[kb #3] MC_isVisibleHop(null) is safe (does not throw; null treated as 0-byte → visible)', () => { + const ctx = freshSandbox(); + const v = ctx.window.MC_isVisibleHop; + // null/undefined hops have byteLen 0, !== 1, so they are visible + // regardless of the toggle (origin/dest sentinels rely on this). + assert.strictEqual(v(null, { hide1ByteHops: true }), true, 'null hop must be visible'); + assert.strictEqual(v(undefined, { hide1ByteHops: true }), true, 'undefined must be visible'); + assert.strictEqual(v('', { hide1ByteHops: true }), true, 'empty string must be visible'); +}); + +test('[adv #1] route-view.js: all-1-byte paths do NOT collide on empty key', () => { + // The bug: route-view aggregated `seen[hops.join('-')]`. When the filter + // produced an empty array, every distinct all-1-byte path collapsed onto + // key "". Distinct routes vanished into one bucket. Fix uses a sentinel + // prefixed with the rawHops signature so the buckets stay distinct. + // + // We assert the EXACT inline aggregation logic now used in route-view.js + // by replaying its key derivation on representative inputs. + const ctx = freshSandbox(); + const aggregate = (paths) => { + const seen = {}; + paths.forEach((p) => { + const rawHops = p.path || []; + const hops = ctx.window.MC_filterPathHops(rawHops); + const key = hops.length ? hops.join('-') : ('__all1byte__::' + rawHops.join('-')); + seen[key] = true; + }); + return Object.keys(seen).length; + }; + // Toggle ON + ctx.window.MC_setHide1ByteHops(true); + const distinct = aggregate([ + { path: ['AB', 'CD'] }, // all 1-byte route A + { path: ['EF', 'GH'] }, // all 1-byte route B — must NOT collide with A + { path: ['AB', 'CD'] } // duplicate of A — must collide with A + ]); + assert.strictEqual(distinct, 2, + 'two distinct all-1-byte routes + one dup must produce 2 buckets, not 1'); +}); + +test('[adv #1] route-view.js source: key derivation uses sentinel for all-1-byte buckets', () => { + // Source-grep guard: if a future refactor reverts to `hops.join("-")` + // bare aggregation, this test catches it. + const src = fs.readFileSync(path.join(__dirname, 'public/route-view.js'), 'utf8'); + assert.ok(src.indexOf('__all1byte__::') !== -1 || src.indexOf('⟨all-1byte⟩::') !== -1, + 'route-view.js must include a sentinel for all-1-byte aggregation buckets'); +}); + +test('[adv #2] map.js source: drawPacketRoute propagates hopsHiddenCount to renderer', () => { + // Source-grep guard: the polyline MUST carry the redacted-count down + // to the renderer so it can dash + badge. A revert that drops the + // `hopsHiddenCount` option from the render call regresses operator UX. + const src = fs.readFileSync(path.join(__dirname, 'public/map.js'), 'utf8'); + assert.ok(/hopsHiddenCount\s*:\s*hopsHiddenCount/.test(src), + 'map.js must pass hopsHiddenCount into the route renderer opts'); + assert.ok(/var\s+hopsHiddenCount\s*=\s*0/.test(src) || /let\s+hopsHiddenCount\s*=\s*0/.test(src), + 'map.js must compute hopsHiddenCount before filtering positions'); +}); + +test('[adv #2] route-render.js: redacted edges use dashed + reduced opacity + emit badge', () => { + const src = fs.readFileSync(path.join(__dirname, 'public/route-render.js'), 'utf8'); + assert.ok(src.indexOf('mc-route-edge-redacted') !== -1, + 'route-render.js must add a .mc-route-edge-redacted className when hopsHiddenCount > 0'); + assert.ok(src.indexOf('mc-route-redacted-badge') !== -1, + 'route-render.js must emit a midpoint badge when hopsHiddenCount > 0'); + assert.ok(/opacity:\s*redacted\s*\?/.test(src), + 'route-render.js must lower opacity when redacted'); +}); + +test('[adv #3] observer-detail.js source: hop count honors MC_filterPathHops', () => { + const src = fs.readFileSync(path.join(__dirname, 'public/observer-detail.js'), 'utf8'); + assert.ok(src.indexOf('MC_filterPathHops') !== -1, + 'observer-detail.js renderRecentPackets must call MC_filterPathHops'); +}); + +test('[r2 MAJOR] live.js packetInvolvesFilterNode: node-filter search is INDEPENDENT of hide-1byte toggle', () => { + // r2 reviewer (option 1): drop the 1-byte hop filter from + // packetInvolvesFilterNode entirely. The node-filter search box must + // return the same matches regardless of the display preference; the + // chip rendering already has separate filtering via feedHops. + // + // Behavioral assertion via vm-sandbox load of live.js (replaces the + // prior source-grep tautology for this consumer). + const ctx = makeLiveSandbox(); + const fn = ctx.window._livePacketInvolvesFilterNode; + assert.ok(fn, '_livePacketInvolvesFilterNode must be exposed'); + + // Filter-node appears ONLY in a 1-byte hop. + const pkt = { decoded: { path: { hops: ['ab'] }, payload: {} } }; + const filterKeys = ['abcd1234567890ab']; + + // Toggle OFF — must match (baseline). + ctx.window.MC_setHide1ByteHops(false); + assert.strictEqual(fn(pkt, filterKeys), true, + 'baseline: match a filter-key whose prefix overlaps a 1-byte hop'); + + // Toggle ON — MUST STILL match. The node-filter search box is + // semantically independent of the display preference. + ctx.window.MC_setHide1ByteHops(true); + assert.strictEqual(fn(pkt, filterKeys), true, + 'node-filter search must be independent of hide-1byte display toggle'); +}); + +test('[adv #4] customize-v2.js still dispatches mc-hide-1byte-hops-changed', () => { + const src = fs.readFileSync(path.join(__dirname, 'public/customize-v2.js'), 'utf8'); + assert.ok(src.indexOf('mc-hide-1byte-hops-changed') !== -1, + 'customizer must continue to dispatch the event'); +}); + +test('[adv #4] map.js + packets.js subscribe to mc-hide-1byte-hops-changed', () => { + const mapSrc = fs.readFileSync(path.join(__dirname, 'public/map.js'), 'utf8'); + const pktSrc = fs.readFileSync(path.join(__dirname, 'public/packets.js'), 'utf8'); + assert.ok(/addEventListener\(['"]mc-hide-1byte-hops-changed['"]/.test(mapSrc), + 'map.js must subscribe to mc-hide-1byte-hops-changed'); + assert.ok(/addEventListener\(['"]mc-hide-1byte-hops-changed['"]/.test(pktSrc), + 'packets.js must subscribe to mc-hide-1byte-hops-changed'); +}); + +test('[adv #4] hop-filter dispatches the event when toggled', () => { + const ctx = freshSandbox(); + let saw = null; + ctx.window.dispatchEvent = function (ev) { saw = ev; }; + ctx.window.MC_setHide1ByteHops(true); + assert.ok(saw, 'must dispatch CustomEvent on setHide1ByteHops'); + assert.strictEqual(saw.type, 'mc-hide-1byte-hops-changed'); + assert.strictEqual(saw.detail && saw.detail.value, true); +}); + +test('[kb #2] (1-byte filtered) chip text is emitted by every render boundary', () => { + // PR body promised the chip. Assert the literal text exists in every + // consumer file that renders a path. If a future refactor strips it, + // operators lose the only signal that the route was redacted. + const files = [ + 'public/packets.js', + 'public/route-view.js' + ]; + for (const f of files) { + const src = fs.readFileSync(path.join(__dirname, f), 'utf8'); + assert.ok(src.indexOf('1-byte filtered') !== -1, + f + ' must render the literal "(1-byte filtered)" chip text'); + } +}); + +test('[kb #1] anti-tautology: tests reference the actual production files (not inline copies)', () => { + // This file is allowed ONLY a single inline filter recreation (the + // route-view aggregation guard). Every other consumer assertion must + // source-grep the production file, NOT re-implement the filter. + const selfSrc = fs.readFileSync(__filename, 'utf8'); + // Ensure the test file does NOT define its own filterPathHops. + assert.strictEqual( + /function\s+filterPathHops\s*\(/.test(selfSrc), false, + 'test file must not re-implement filterPathHops (tautology guard)' + ); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +process.exit(failed > 0 ? 1 : 0);
    #RouteOccurrences% of pathsFrequency
    TimeTypeHashSNRRSSIHops
    ${timeAgo(p.timestamp)}