mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 17:23:38 +00:00
## 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 <bot@openclaw.dev> Co-authored-by: clawbot <bot@openclaw.local>
This commit is contained in:
co-authored by
openclaw-bot
clawbot
parent
dd2b3d2e21
commit
79cf453660
@@ -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
|
||||
|
||||
+17
-3
@@ -1973,13 +1973,27 @@
|
||||
|
||||
function renderTable(data, title) {
|
||||
if (!data.subpaths.length) return `<h4>${title}</h4><div class="text-muted">No data</div>`;
|
||||
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 `<h4>${title}</h4><div class="text-muted">No data (all matching routes contained 1-byte hops — toggle off in customizer to see)</div>`;
|
||||
const maxCount = subpaths[0]?.count || 1;
|
||||
return `<h4>${title}</h4>
|
||||
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops</p>
|
||||
<p class="text-muted" style="margin:4px 0 8px">From ${data.totalPaths.toLocaleString()} paths with 2+ hops${_hide1 ? ` · showing ${subpaths.length} of ${data.subpaths.length} (1-byte filtered)` : ''}</p>
|
||||
<table class="analytics-table"><thead><tr>
|
||||
<th scope="col">#</th><th scope="col">Route</th><th scope="col">Occurrences</th><th scope="col">% of paths</th><th scope="col">Frequency</th>
|
||||
</tr></thead><tbody>
|
||||
${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 || [];
|
||||
|
||||
@@ -1567,10 +1567,31 @@
|
||||
'<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Re-show first-visit gesture discoverability hints (swipe rows, swipe tabs, edge-swipe drawer, pull-to-refresh).</p>' +
|
||||
'<button type="button" class="cust-dl-btn" data-cv2-reset-hints data-reset-gesture-hints>↺ Reset gesture hints</button>' +
|
||||
_renderChannelsShowEncryptedToggle() +
|
||||
_renderHide1ByteHopsToggle() +
|
||||
_renderTileProviderSelector() +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #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 '<p class="cust-section-title" style="font-size:14px;margin:16px 0 8px">Path Display</p>' +
|
||||
'<p class="cust-hint" style="font-size:12px;color:var(--text-muted);margin-bottom:8px">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.</p>' +
|
||||
'<div class="cust-field" style="display:flex;align-items:center;gap:8px">' +
|
||||
'<input type="checkbox" id="cv2-hide-1byte-hops" data-cv2-hide-1byte-hops' +
|
||||
(on ? ' checked' : '') +
|
||||
' style="width:16px;height:16px;cursor:pointer">' +
|
||||
'<label for="cv2-hide-1byte-hops" style="cursor:pointer;margin:0">Hide short (1-byte) path-hash hops</label>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── #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++) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -172,6 +172,7 @@
|
||||
<script src="region-filter.js?v=__BUST__"></script>
|
||||
<script src="area-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-resolver.js?v=__BUST__"></script>
|
||||
<script src="hop-filter.js?v=__BUST__"></script>
|
||||
<script src="hop-display.js?v=__BUST__"></script>
|
||||
<script src="warmup-banner.js?v=__BUST__"></script>
|
||||
<script src="app.js?v=__BUST__"></script>
|
||||
|
||||
+37
-1
@@ -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 `<div style="padding:3px 0;font-size:11px;line-height:1.4;color:var(--text-muted)">— (1-byte filtered) <span style="color:var(--text-muted)">(${p.count}×)</span></div>`;
|
||||
}
|
||||
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 `<strong style="color:var(--accent)">${name}</strong>`;
|
||||
@@ -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)) {
|
||||
|
||||
+76
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,14 @@ window.ObserverDetailNaiveBanner = {
|
||||
<thead><tr><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Hash</th><th scope="col">SNR</th><th scope="col">RSSI</th><th scope="col">Hops</th></tr></thead>
|
||||
<tbody>${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 `<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/${p.hash || p.id}">
|
||||
<td>${timeAgo(p.timestamp)}</td>
|
||||
|
||||
+20
-1
@@ -963,7 +963,14 @@
|
||||
|
||||
function renderPath(hops, observerId) {
|
||||
if (!hops || !hops.length) return '—';
|
||||
return hops.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
|
||||
// #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 '— <span class="text-muted" title="All path hops were 1-byte and are hidden by the customizer toggle">(1-byte filtered)</span>';
|
||||
return filtered.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
|
||||
}
|
||||
|
||||
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('?');
|
||||
|
||||
+28
-4
@@ -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 = '<span class="mc-route-redacted-badge" title="Hidden by customizer: 1-byte path hops are filtered. Toggle off in Customize to see the full path.">' +
|
||||
hopsHiddenCount + ' hop' + (hopsHiddenCount === 1 ? '' : 's') + ' hidden (1-byte)' +
|
||||
'</span>';
|
||||
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 <path> element to add aria-label + marker-end.
|
||||
// Leaflet builds it on the next animation frame, so defer.
|
||||
(function (polyRef, lbl, col) {
|
||||
|
||||
@@ -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. */
|
||||
}
|
||||
|
||||
+38
-6
@@ -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 '<li class="mc-rt-path-row" data-path-key="' + escapeHtml(g.key) + '" data-obs-count="' + g.count + '" tabindex="0" role="button" aria-label="Isolate path with ' + hops.length + ' hops, seen by ' + g.count + ' of ' + totalObservers + ' observers">' +
|
||||
// #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 = '<span class="text-muted">— (' + hopsCount + ' × 1-byte filtered)</span>';
|
||||
} else {
|
||||
var hops = g.key.split('→').filter(function(s){return s.length>0;});
|
||||
hopsCount = hops.length;
|
||||
hopsHtml = hops.map(escapeHtml).join(' → ');
|
||||
}
|
||||
return '<li class="mc-rt-path-row" data-path-key="' + escapeHtml(g.key) + '" data-obs-count="' + g.count + '" tabindex="0" role="button" aria-label="Isolate path with ' + hopsCount + ' hops, seen by ' + g.count + ' of ' + totalObservers + ' observers">' +
|
||||
'<span class="mc-rt-path-count">' + g.count + '/' + totalObservers + '</span>' +
|
||||
'<span class="mc-rt-path-hops">' + hops.map(escapeHtml).join(' → ') + '</span>' +
|
||||
'<span class="mc-rt-path-hops">' + hopsHtml + '</span>' +
|
||||
'<span class="mc-rt-path-obs" title="' + escapeHtml(g.observers.join(', ')) + '">' + escapeHtml(sample) + moreSuffix + '</span>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user