mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-25 03:52:44 +00:00
Compare commits
14 Commits
fix/issue-1418
...
v3.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f0c69d5fe7 | |||
| 48717aaccb | |||
| 13ae0dd6aa | |||
| ec7ff4c597 | |||
| 5d8d857cfb | |||
| 8d702bdfd9 | |||
| 77d1925f30 | |||
| 306ac37ea0 | |||
| 50a1b1c6e8 | |||
| 0c52cf663a | |||
| be1b014269 | |||
| c796d48442 | |||
| 0986caaa44 | |||
| 89410d58b4 |
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"38.79%","color":"red"}
|
||||
{"schemaVersion":1,"label":"frontend coverage","message":"35.7%","color":"red"}
|
||||
|
||||
@@ -113,6 +113,13 @@ jobs:
|
||||
node test-issue-1375-scope-stats-fetch.js
|
||||
node test-issue-1361-cb-presets.js
|
||||
node test-issue-1407-cb-preset-propagation.js
|
||||
node test-issue-1412-customizer-no-override.js
|
||||
node test-issue-1418-raw-hex-extraction.js
|
||||
node test-issue-1418-edge-weights.js
|
||||
node test-issue-1418-cb-preset-ramp.js
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
node test-live.js
|
||||
|
||||
- name: 🧹 Frontend lint (eslint no-undef) — issue #1342
|
||||
@@ -268,6 +275,7 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1391-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1413-nav-overlap-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1400-nav-vertical-clip.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-more-floor-1139-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// repeaterEnrichTTL bounds how stale the per-page bulk enrichment caches
|
||||
// for handleNodes may be. Same 15s budget as GetNodeHashSizeInfo — the
|
||||
// numbers feed an at-a-glance status column, not an alerting path, so
|
||||
// up-to-15s freshness is fine and keeps the request path O(page) instead
|
||||
// of O(page × byPathHop[pk] × parsed timestamps).
|
||||
const repeaterEnrichTTL = 15 * time.Second
|
||||
// repeaterEnrichTTL is the safety-net TTL for the bulk enrichment caches.
|
||||
// Derived as 2× the recomputer's default tick so the cache is always valid
|
||||
// between background refreshes. Without a recomputer (tests, edge cases)
|
||||
// the cache rebuilds on-thread at most once per TTL window.
|
||||
// Note: if analytics.defaultIntervalSeconds is configured above 600 the
|
||||
// TTL will expire before the recomputer runs; keep that value < TTL/2.
|
||||
const repeaterEnrichTTL = 2 * repeaterEnrichmentRecomputerDefaultInterval
|
||||
|
||||
// GetRepeaterRelayInfoMap returns a cached pubkey → RepeaterRelayInfo
|
||||
// map covering EVERY pubkey that currently appears as a path hop in any
|
||||
@@ -29,9 +30,9 @@ const repeaterEnrichTTL = 15 * time.Second
|
||||
// The cached map is keyed by lowercase pubkey/hop key (same shape as
|
||||
// byPathHop). Lookups should use strings.ToLower(pk).
|
||||
//
|
||||
// The cache is invalidated by TTL only — never by ingest. With a 15s
|
||||
// budget that's acceptable for a status column; if a fresher signal is
|
||||
// ever needed for a non-status caller, expose a non-cached path.
|
||||
// The cache is invalidated by TTL only — never by ingest. Up-to-10min
|
||||
// freshness is fine for an at-a-glance status column; if a fresher
|
||||
// signal is ever needed for a non-status caller, expose a non-cached path.
|
||||
func (s *PacketStore) GetRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
|
||||
s.repeaterEnrichMu.Lock()
|
||||
if s.repeaterRelayCache != nil &&
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
// repeaterEnrichmentRecomputerInterval is the default tick interval
|
||||
// for the steady-state recompute of the repeater enrichment bulk
|
||||
// caches. The on-request 15s-TTL fallback in repeater_enrich_bulk.go
|
||||
// is kept as a safety net — the recomputer just makes sure the cache
|
||||
// is populated before any request arrives.
|
||||
// caches. The on-request TTL fallback in repeater_enrich_bulk.go is
|
||||
// kept as a safety net — the recomputer just makes sure the cache is
|
||||
// populated before any request arrives.
|
||||
//
|
||||
// 5min mirrors the analytics_recomputer default from #1240 and is
|
||||
// plenty fresh for an at-a-glance status column.
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
suspected: '#FFD966',
|
||||
unknown: '#FF8888'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'deut',
|
||||
@@ -73,6 +75,8 @@
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'prot',
|
||||
@@ -95,6 +99,8 @@
|
||||
suspected: '#FFB000',
|
||||
unknown: '#DC267F'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#0d0887', '#7e03a8', '#cc4778', '#f89540', '#f0f921']
|
||||
},
|
||||
{
|
||||
id: 'trit',
|
||||
@@ -125,6 +131,8 @@
|
||||
suspected: '#DDCC77',
|
||||
unknown: '#CC6677'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
||||
},
|
||||
{
|
||||
id: 'achromat',
|
||||
@@ -158,6 +166,8 @@
|
||||
suspected: '#808080',
|
||||
unknown: '#595959'
|
||||
}
|
||||
,
|
||||
routeRamp: ['#222222', '#555555', '#888888', '#bbbbbb', '#eeeeee']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -241,6 +251,13 @@
|
||||
Object.keys(p.mb).forEach(function (k) {
|
||||
style.setProperty('--mc-mb-' + k, p.mb[k]);
|
||||
});
|
||||
// #1418 — route-view sequence ramp (5 stops). route-view.js reads
|
||||
// --mc-rt-ramp-0..4 instead of hardcoded viridis/magma so a CB preset
|
||||
// changes the route edge colors live. Achromat uses a luminance ramp.
|
||||
var rr = p.routeRamp || ['#440154','#3b528b','#21918c','#5ec962','#fde725'];
|
||||
for (var ri = 0; ri < 5; ri++) {
|
||||
style.setProperty('--mc-rt-ramp-' + ri, rr[ri] || rr[rr.length - 1]);
|
||||
}
|
||||
// #1407 — ROLE_COLORS / ROLE_STYLE are now live getters in roles.js
|
||||
// that read --mc-role-* directly, so no explicit sync is needed. The
|
||||
// pre-#1407 code path kept them in sync as a workaround for the static
|
||||
|
||||
@@ -546,13 +546,16 @@
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
|
||||
// Node colors → CSS vars + global objects
|
||||
// Node colors → --node-X CSS var only (legacy compat).
|
||||
// #1412: do NOT push server-config nodeColors into window.ROLE_COLORS —
|
||||
// that defeats cb-presets propagation by trapping the legacy palette in
|
||||
// the _roleOverrides map (where the live getter prefers it over the
|
||||
// --mc-role-X CSS vars that presets actually write). User-chosen
|
||||
// overrides still flow through setRoleColorOverride() in customize.js.
|
||||
var nc = effectiveConfig.nodeColors;
|
||||
if (nc) {
|
||||
for (var role in nc) {
|
||||
root.setProperty('--node-' + role, nc[role]);
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = nc[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = nc[role];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2142,11 +2145,11 @@
|
||||
if (ovTheme.accentHover) root.setProperty('--logo-accent-hi', ovTheme.accentHover);
|
||||
if (themeSection.background) root.setProperty('--content-bg', themeSection.contentBg || themeSection.background);
|
||||
if (themeSection.surface1) root.setProperty('--card-bg', themeSection.cardBg || themeSection.surface1);
|
||||
// Apply node/type colors from overrides early
|
||||
// Apply node colors from overrides early — --node-X CSS var only.
|
||||
// #1412: do NOT write to window.ROLE_COLORS / ROLE_STYLE here.
|
||||
if (earlyOverrides.nodeColors) {
|
||||
for (var role in earlyOverrides.nodeColors) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = earlyOverrides.nodeColors[role];
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = earlyOverrides.nodeColors[role];
|
||||
root.setProperty('--node-' + role, earlyOverrides.nodeColors[role]);
|
||||
}
|
||||
}
|
||||
if (earlyOverrides.typeColors && window.TYPE_COLORS) {
|
||||
|
||||
+11
-3
@@ -1145,8 +1145,13 @@
|
||||
inp.addEventListener('input', function () {
|
||||
var key = inp.dataset.node;
|
||||
state.nodeColors[key] = inp.value;
|
||||
// Sync to global role colors used by map/packets/etc
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = inp.value;
|
||||
// #1412: route per-key user picks through setRoleColorOverride so
|
||||
// the explicit override map is the only place mutation happens.
|
||||
// (Direct subscript assignment would also work via the roles.js
|
||||
// proxy, but the explicit API is the documented contract.)
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, inp.value);
|
||||
}
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = inp.value;
|
||||
// Trigger re-render of current page
|
||||
window.dispatchEvent(new CustomEvent('theme-changed')); autoSave();
|
||||
@@ -1162,7 +1167,10 @@
|
||||
btn.addEventListener('click', function () {
|
||||
var key = btn.dataset.resetNode;
|
||||
state.nodeColors[key] = DEFAULTS.nodeColors[key];
|
||||
if (window.ROLE_COLORS) window.ROLE_COLORS[key] = DEFAULTS.nodeColors[key];
|
||||
// #1412: clearing the override lets cb-preset CSS var win again.
|
||||
if (typeof window.setRoleColorOverride === 'function') {
|
||||
window.setRoleColorOverride(key, DEFAULTS.nodeColors[key]);
|
||||
}
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[key]) window.ROLE_STYLE[key].color = DEFAULTS.nodeColors[key];
|
||||
render(container);
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="style.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="route-view.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="home.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="live.css?v=__BUST__">
|
||||
<link rel="stylesheet" href="bottom-nav.css?v=__BUST__">
|
||||
@@ -151,6 +152,7 @@
|
||||
<script src="geo-filter-overlay.js?v=__BUST__"></script>
|
||||
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
+523
-16
@@ -296,6 +296,9 @@
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mc_map = map;
|
||||
window.__mc_routeLayer = routeLayer;
|
||||
// Expose nodes array for route-view's path-picker isolation
|
||||
// (needs to resolve hop prefixes that aren't in the canonical path).
|
||||
Object.defineProperty(window, '__mc_nodes', { get: function () { return nodes; }, configurable: true });
|
||||
window.deconflictLabels = deconflictLabels;
|
||||
}
|
||||
|
||||
@@ -502,18 +505,29 @@
|
||||
sessionStorage.removeItem('map-route-hops');
|
||||
try {
|
||||
const parsed = JSON.parse(routeHopsJson);
|
||||
// Support new format {origin, hops} and legacy plain array
|
||||
if (Array.isArray(parsed)) {
|
||||
drawPacketRoute(parsed, null);
|
||||
} else if (parsed.paths && parsed.paths.length > 0) {
|
||||
drawPacketRouteMulti(parsed.paths, parsed.origin || null, {
|
||||
packetHash: parsed.packetHash,
|
||||
canonicalPath: parsed.hops || null,
|
||||
destination: parsed.destination || null
|
||||
});
|
||||
} else {
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null);
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null, { destination: parsed.destination || null });
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
// #1418/#1419: deep-link via URL params — #/map?packet=<hash>&obs=<id>
|
||||
// (or just packet=<hash> for first observation). Fetch from API, build
|
||||
// the same payload as the sessionStorage flow, dispatch to renderer.
|
||||
// This makes routes shareable / bookmarkable without sessionStorage state.
|
||||
loadRouteFromDeepLink();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawPacketRoute(hopKeys, origin, opts) {
|
||||
async function drawPacketRoute(hopKeys, origin, opts) {
|
||||
// Defensive: origin must be an object with pubkey/lat/lon/name. A bare
|
||||
// string slips through both branches at lines below and silently no-ops
|
||||
// the originator marker (caused PR #950's bug). Coerce string → object
|
||||
@@ -523,6 +537,21 @@
|
||||
origin = { pubkey: origin };
|
||||
}
|
||||
opts = opts || {};
|
||||
// #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.
|
||||
let serverResolved = null;
|
||||
try {
|
||||
const hopList = (hopKeys || []).join(',');
|
||||
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(hopList);
|
||||
const resp = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
serverResolved = json && json.resolved ? json.resolved : null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('resolve-hops API call failed, falling back to local nodes scan', e);
|
||||
}
|
||||
// Hide default markers so only the route is visible
|
||||
if (markerLayer) map.removeLayer(markerLayer);
|
||||
if (clusterGroup) map.removeLayer(clusterGroup);
|
||||
@@ -555,18 +584,42 @@
|
||||
// Unresolvable hops (no matching node) become {resolved:false} sentinels
|
||||
// so the modern renderer (#1374) can render dashed-gray placeholders + a
|
||||
// "X of N hops resolved" badge instead of silently dropping them.
|
||||
//
|
||||
// #1418/#1422: PREFER serverResolved from /api/resolve-hops which does
|
||||
// - unique_prefix matching (uses multi-byte adverts to disambiguate 1-byte hops)
|
||||
// - gps_preference (skips nodes with lat=0 sentinel)
|
||||
// - affinity scoring (neighbor-graph aware)
|
||||
// Fall back to a naive local nodes.filter() scan when the API failed or
|
||||
// the hop wasn't in the server response. When a node MATCHES by
|
||||
// prefix/pubkey but has no GPS coords, we still want its name/role/pubkey
|
||||
// for the sidebar — flag it as {resolved:false, gpsless:true} so the
|
||||
// renderer can label it "📍 no GPS" instead of "unresolved prefix".
|
||||
const raw = hopKeys.map(hop => {
|
||||
const hopLower = String(hop).toLowerCase();
|
||||
const candidates = nodes.filter(n => {
|
||||
// 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 };
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
// Fallback: naive local scan (kept for resilience when API is down).
|
||||
const allMatches = nodes.filter(n => {
|
||||
const pk = n.public_key.toLowerCase();
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk)) &&
|
||||
n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0);
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
|
||||
});
|
||||
if (candidates.length === 1) {
|
||||
const c = candidates[0];
|
||||
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 };
|
||||
} else if (candidates.length > 1) {
|
||||
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates };
|
||||
} else if (withGps.length > 1) {
|
||||
return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
|
||||
} 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: String(hop).slice(0, 8), pubkey: hop, resolved: false };
|
||||
});
|
||||
@@ -594,24 +647,59 @@
|
||||
// Resolve and prepend origin node
|
||||
if (origin) {
|
||||
let originPos = null;
|
||||
if (origin.lat != null && origin.lon != null) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true };
|
||||
const originHasRealGps = (lat, lon) => lat != null && lon != null && !(lat === 0 && lon === 0);
|
||||
if (originHasRealGps(origin.lat, origin.lon)) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, role: origin.role || 'companion', resolved: true, isOrigin: true, _fromPayload: true };
|
||||
} else if (origin.pubkey) {
|
||||
const pk = origin.pubkey.toLowerCase();
|
||||
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
|
||||
if (match && match.lat != null && match.lon != null) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: true, isOrigin: true };
|
||||
if (match) {
|
||||
if (originHasRealGps(match.lat, match.lon)) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: true, isOrigin: true, _fromPayload: true };
|
||||
} else {
|
||||
originPos = { name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role || 'companion', resolved: false, gpsless: true, isOrigin: true, _fromPayload: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (originPos) positions.unshift(originPos);
|
||||
}
|
||||
|
||||
// #1418 Phase D: append destination (recipient from decoded.destHash) so
|
||||
// the route view shows: sender → [intermediate hops] → recipient.
|
||||
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
|
||||
// — show the node in the sidebar as gpsless rather than drawing it at
|
||||
// (0,0) which would force fitBounds to span the visible route → Africa.
|
||||
// _fromPayload: true so the renderer can visually mark these as "from
|
||||
// payload" (different source-of-truth than path hops).
|
||||
if (opts.destination && opts.destination.pubkey) {
|
||||
const dpk = opts.destination.pubkey.toLowerCase();
|
||||
const dmatch = nodes.find(n => n.public_key.toLowerCase() === dpk || n.public_key.toLowerCase().startsWith(dpk));
|
||||
if (dmatch) {
|
||||
const hasGps = dmatch.lat != null && dmatch.lon != null && !(dmatch.lat === 0 && dmatch.lon === 0);
|
||||
if (hasGps) {
|
||||
positions.push({ lat: dmatch.lat, lon: dmatch.lon, name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: true, _fromPayload: true });
|
||||
} else {
|
||||
positions.push({ name: opts.destination.name || dmatch.name || 'Recipient', pubkey: dmatch.public_key, role: dmatch.role || 'companion', resolved: false, gpsless: true, _fromPayload: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (positions.length < 1) return;
|
||||
// Mark final hop as destination so the renderer applies the dest glyph.
|
||||
positions[positions.length - 1].isDest = true;
|
||||
|
||||
// Hand off to the modern role-aware renderer (#1374). Falls back to the
|
||||
// legacy minimal renderer only if MeshRoute hasn't loaded yet.
|
||||
// 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).
|
||||
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
|
||||
window.MeshRouteView.render(map, routeLayer, positions, {
|
||||
timestamp: opts.timestamp || Date.now(),
|
||||
packetHash: opts.packetHash || null,
|
||||
observationId: opts.observationId || null,
|
||||
packetContext: opts.packetContext || null
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
|
||||
window.MeshRoute.render(map, routeLayer, positions, {
|
||||
timestamp: opts.timestamp || Date.now()
|
||||
@@ -629,6 +717,425 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #1418 Phase C — multi-path renderer. Accepts an array of paths (each =
|
||||
// {path: [hopKeys], observer, snr, rssi}), aggregates into canonical hops +
|
||||
// per-edge observer-count (for stroke-width weighting), and dispatches to
|
||||
// the sequence renderer with multi-path metadata. Falls back to single-path
|
||||
// drawPacketRoute when only one observation is provided.
|
||||
//
|
||||
// opts.canonicalPath (optional): use this exact hop sequence as the canonical
|
||||
// spine — i.e. the observation the operator selected. Without it, longest-path
|
||||
// wins, which can show a totally different route than the user clicked.
|
||||
async function drawPacketRouteMulti(paths, origin, opts) {
|
||||
opts = opts || {};
|
||||
if (typeof origin === 'string') origin = { pubkey: origin };
|
||||
if (!Array.isArray(paths) || paths.length === 0) return;
|
||||
if (paths.length === 1) {
|
||||
return drawPacketRoute(paths[0].path || [], origin, opts);
|
||||
}
|
||||
|
||||
// Pick canonical: prefer caller-supplied (operator's chosen observation),
|
||||
// else fall back to longest path as the spine.
|
||||
var canonicalPath;
|
||||
if (opts.canonicalPath && Array.isArray(opts.canonicalPath) && opts.canonicalPath.length) {
|
||||
canonicalPath = opts.canonicalPath;
|
||||
} else {
|
||||
const sortedByLen = paths.slice().sort((a, b) => (b.path || []).length - (a.path || []).length);
|
||||
canonicalPath = sortedByLen[0].path || [];
|
||||
}
|
||||
const totalObservers = paths.length;
|
||||
|
||||
// Count hop & edge occurrences across all paths.
|
||||
const hopCounts = {};
|
||||
const edgeCounts = {};
|
||||
paths.forEach(p => {
|
||||
const hops = p.path || [];
|
||||
hops.forEach(h => { hopCounts[h] = (hopCounts[h] || 0) + 1; });
|
||||
for (let i = 0; i < hops.length - 1; i++) {
|
||||
const key = hops[i] + '\u2192' + hops[i + 1];
|
||||
edgeCounts[key] = (edgeCounts[key] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve canonical hops via /api/resolve-hops + naive fallback.
|
||||
let serverResolved = null;
|
||||
try {
|
||||
const apiUrl = '/api/resolve-hops?hops=' + encodeURIComponent(canonicalPath.join(','));
|
||||
const resp = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
serverResolved = json && json.resolved ? json.resolved : null;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const raw = canonicalPath.map(hop => {
|
||||
const hopLower = String(hop).toLowerCase();
|
||||
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
|
||||
// hopCounts is keyed on the SHORT prefix from observation paths (e.g. "37"),
|
||||
// but canonicalPath may carry full pubkeys when the operator's selected
|
||||
// observation went through the resolver. Compute hop coverage by checking
|
||||
// EVERY path[]: a hop in canonicalPath is "covered" by a path if the path
|
||||
// contains an entry that is either an exact match OR a prefix of the hop's
|
||||
// full pubkey.
|
||||
let coverage = 0;
|
||||
const hopFullKey = (srv && srv.pubkey) ? srv.pubkey.toLowerCase() : hopLower;
|
||||
paths.forEach(pth => {
|
||||
const phops = (pth.path || []).map(h => String(h).toLowerCase());
|
||||
const matches = phops.some(ph => ph === hopFullKey || hopFullKey.startsWith(ph) || ph.startsWith(hopFullKey));
|
||||
if (matches) coverage++;
|
||||
});
|
||||
const obsInfo = { observerCount: coverage || (hopCounts[hop] || 1), observerTotal: totalObservers };
|
||||
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 Object.assign({ lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true }, obsInfo);
|
||||
}
|
||||
return Object.assign({ name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true }, obsInfo);
|
||||
}
|
||||
const allMatches = nodes.filter(n => {
|
||||
const pk = n.public_key.toLowerCase();
|
||||
return (pk === hopLower || pk.startsWith(hopLower) || hopLower.startsWith(pk));
|
||||
});
|
||||
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 Object.assign({ lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true }, obsInfo);
|
||||
}
|
||||
if (allMatches.length >= 1) {
|
||||
const c = allMatches[0];
|
||||
return Object.assign({ name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true }, obsInfo);
|
||||
}
|
||||
return Object.assign({ name: String(hop).slice(0,8), pubkey: hop, resolved: false }, obsInfo);
|
||||
});
|
||||
|
||||
if (raw.length < 1) return;
|
||||
if (origin && origin.pubkey) {
|
||||
const op = nodes.find(n => n.public_key.toLowerCase() === origin.pubkey.toLowerCase() ||
|
||||
n.public_key.toLowerCase().startsWith(origin.pubkey.toLowerCase()));
|
||||
if (op) {
|
||||
const hasGps = op.lat != null && op.lon != null && !(op.lat === 0 && op.lon === 0);
|
||||
if (hasGps) {
|
||||
raw.unshift({ lat: op.lat, lon: op.lon, name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
} else {
|
||||
raw.unshift({ name: origin.name || op.name || 'Sender', pubkey: op.public_key, role: op.role || 'companion', resolved: false, gpsless: true, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
} else if (origin.name) {
|
||||
raw.unshift({ name: origin.name, pubkey: origin.pubkey, role: 'companion', resolved: false, isOrigin: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
}
|
||||
// #1418 Phase D: append destination (recipient from decoded.destHash) so
|
||||
// the route view shows: sender → [intermediate hops] → recipient.
|
||||
// GPS-sanity: a lat=0,lon=0 sentinel or null coords means "no real GPS"
|
||||
// — show the node in the sidebar as gpsless rather than drawing it at
|
||||
// (0,0) which would force fitBounds to span SF→Africa.
|
||||
if (opts.destination && opts.destination.pubkey) {
|
||||
const dp = nodes.find(n => n.public_key.toLowerCase() === opts.destination.pubkey.toLowerCase() ||
|
||||
n.public_key.toLowerCase().startsWith(opts.destination.pubkey.toLowerCase()));
|
||||
if (dp) {
|
||||
const hasGps = dp.lat != null && dp.lon != null && !(dp.lat === 0 && dp.lon === 0);
|
||||
if (hasGps) {
|
||||
raw.push({ lat: dp.lat, lon: dp.lon, name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
} else {
|
||||
raw.push({ name: opts.destination.name || dp.name || 'Recipient', pubkey: dp.public_key, role: dp.role || 'companion', resolved: false, gpsless: true, observerCount: totalObservers, observerTotal: totalObservers });
|
||||
}
|
||||
}
|
||||
}
|
||||
raw[raw.length - 1].isDest = true;
|
||||
if (raw[0]) raw[0].isOrigin = true;
|
||||
|
||||
if (routeLayer) routeLayer.clearLayers();
|
||||
|
||||
if (window.MeshRouteView && typeof window.MeshRouteView.render === 'function') {
|
||||
window.MeshRouteView.render(map, routeLayer, raw, {
|
||||
timestamp: opts.timestamp || Date.now(),
|
||||
multiPath: true,
|
||||
totalObservers: totalObservers,
|
||||
edgeCounts: edgeCounts,
|
||||
packetHash: opts.packetHash || null,
|
||||
observationId: opts.observationId || null,
|
||||
allPaths: paths,
|
||||
packetContext: opts.packetContext || null
|
||||
});
|
||||
}
|
||||
}
|
||||
window.drawPacketRouteMulti = drawPacketRouteMulti;
|
||||
|
||||
// #1418/#1419: deep-link loader. Reads URL params
|
||||
// #/map?packet=<hash>&obs=<observation_id>
|
||||
// fetches the packet+observations from /api/packets/<hash>, picks the
|
||||
// selected observation as canonical, and dispatches the same payload that
|
||||
// the packets-page 'View on map' button would have set in sessionStorage.
|
||||
// Without an obs param, the first observation is used.
|
||||
async function loadRouteFromDeepLink() {
|
||||
try {
|
||||
const hash = location.hash || '';
|
||||
const qs = hash.split('?')[1];
|
||||
if (!qs) return;
|
||||
const params = new URLSearchParams(qs);
|
||||
const packetHash = params.get('packet');
|
||||
const obsId = params.get('obs');
|
||||
if (!packetHash) return;
|
||||
// Wait for nodes to load (drawPacketRoute / Multi rely on `nodes` array
|
||||
// for the local-fallback resolver).
|
||||
if (!nodes || !nodes.length) {
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
}
|
||||
const resp = await fetch('/api/packets/' + encodeURIComponent(packetHash));
|
||||
if (!resp.ok) {
|
||||
console.warn('[deep-link] /api/packets/' + packetHash + ' returned ' + resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const pkt = data.packet || data;
|
||||
const observations = data.observations || pkt.observations || [];
|
||||
if (!observations.length) return;
|
||||
// Pick the user-chosen observation by id, fall back to first
|
||||
let chosen = null;
|
||||
if (obsId) chosen = observations.find(o => String(o.id) === String(obsId));
|
||||
if (!chosen) chosen = observations[0];
|
||||
// Parse decoded for src/dst.
|
||||
// Try observation first, fall back to packet-level decoded_json (GRP_TXT
|
||||
// / TRACE packets often have channel + content at the packet level, not
|
||||
// per-observation).
|
||||
let decoded = {};
|
||||
try {
|
||||
const obsDec = JSON.parse(chosen.decoded_json || '{}');
|
||||
const pktDec = JSON.parse(pkt.decoded_json || '{}');
|
||||
decoded = Object.keys(obsDec).length ? obsDec : pktDec;
|
||||
// If observation has some fields but missing channel/text, merge from packet.
|
||||
if (decoded === obsDec && pktDec.channel) {
|
||||
decoded = Object.assign({}, pktDec, obsDec);
|
||||
}
|
||||
} catch (_) {}
|
||||
const origin = {};
|
||||
if (decoded.pubKey) origin.pubkey = decoded.pubKey;
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
const destination = decoded.destHash ? { pubkey: decoded.destHash } : null;
|
||||
// Resolve the chosen observation's hops (canonical path).
|
||||
// Priority: 1) server-side `resolved_path` (authoritative, eyeball-
|
||||
// validated against packet detail), 2) client-side HopResolver (used
|
||||
// by packets.js — does observer-IATA-aware geographic disambiguation),
|
||||
// 3) raw prefixes (worst case, leaves naive lookup to drawPacketRoute).
|
||||
let chosenPath = [];
|
||||
let rawHops = [];
|
||||
try { rawHops = JSON.parse(chosen.path_json || '[]'); } catch (_) {}
|
||||
let resolvedHops = null;
|
||||
try {
|
||||
if (chosen.resolved_path) {
|
||||
resolvedHops = typeof chosen.resolved_path === 'string' ? JSON.parse(chosen.resolved_path) : chosen.resolved_path;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
|
||||
chosenPath = rawHops.map((h, i) => resolvedHops[i] || h);
|
||||
} else if (window.HopResolver && typeof window.HopResolver.resolve === 'function' && rawHops.length) {
|
||||
// Use the SAME resolver the packets page uses, so route view and
|
||||
// packet detail agree on hop identities.
|
||||
try {
|
||||
// Sender + observer hints help disambiguation
|
||||
const senderLat = decoded.lat || decoded.latitude || null;
|
||||
const senderLon = decoded.lon || decoded.longitude || null;
|
||||
let obsLat = null, obsLon = null;
|
||||
// Try to find observer's coords for geographic affinity scoring
|
||||
if (chosen.observer_id && Array.isArray(nodes)) {
|
||||
const obs = nodes.find(n => (n.public_key || '').toLowerCase() === String(chosen.observer_id).toLowerCase());
|
||||
if (obs && obs.lat != null && obs.lon != null) { obsLat = obs.lat; obsLon = obs.lon; }
|
||||
}
|
||||
// HopResolver.init may already have been done by packets.js; if not,
|
||||
// do a minimal init from window data we have.
|
||||
if (!window.HopResolver.ready || !window.HopResolver.ready()) {
|
||||
try {
|
||||
window.HopResolver.init(nodes || [], { observers: [], iataCoords: {} });
|
||||
} catch (_) {}
|
||||
}
|
||||
const resolveResult = window.HopResolver.resolve(rawHops, senderLat, senderLon, obsLat, obsLon, chosen.observer_id);
|
||||
chosenPath = rawHops.map(h => {
|
||||
const r = resolveResult ? resolveResult[h] : null;
|
||||
return r && r.pubkey ? r.pubkey : h;
|
||||
});
|
||||
} catch (_) {
|
||||
chosenPath = rawHops;
|
||||
}
|
||||
} else {
|
||||
chosenPath = rawHops;
|
||||
}
|
||||
// All observation paths for multi-path stroke weighting
|
||||
const allPaths = observations.map(o => {
|
||||
let p = [];
|
||||
try { p = JSON.parse(o.path_json || '[]'); } catch (_) {}
|
||||
return { path: p, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
|
||||
}).filter(p => p.path && p.path.length > 0);
|
||||
// #1418 Phase Y: derive packet context for the sidebar fact-list.
|
||||
// type comes from decoded.type or pkt.payload_type. Resolve src/dst
|
||||
// names from the nodes table when possible.
|
||||
function resolveNameByHash(h) {
|
||||
if (!h || !nodes || !nodes.length) return null;
|
||||
const hL = String(h).toLowerCase();
|
||||
const m = nodes.find(n => n.public_key.toLowerCase().startsWith(hL));
|
||||
return m ? m.name : null;
|
||||
}
|
||||
// Map payload_type byte → string. Source: cmd/ingestor/decoder.go.
|
||||
// 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT,
|
||||
// 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE, 10=MULTIPART,
|
||||
// 11=CONTROL, 12=RAW_CUSTOM.
|
||||
const PAYLOAD_TYPE_MAP = {
|
||||
0: 'REQ', 1: 'RESPONSE', 2: 'TXT_MSG', 3: 'ACK', 4: 'ADVERT',
|
||||
5: 'GRP_TXT', 6: 'GRP_DATA', 7: 'ANON_REQ', 8: 'PATH',
|
||||
9: 'TRACE', 10: 'MULTIPART', 11: 'CONTROL', 12: 'RAW_CUSTOM'
|
||||
};
|
||||
const inferredType = decoded.type || PAYLOAD_TYPE_MAP[pkt.payload_type] || 'OTHER';
|
||||
// Try to peek at raw_hex bytes to extract src/destHash when decoded is empty.
|
||||
// TXT_MSG/REQ/RESPONSE/ANON_REQ all have the same wire layout:
|
||||
// byte0=route+type, byte1=path_len, then path bytes, then destHash + srcHash + encrypted body.
|
||||
// ANON_REQ doesn't have a srcHash byte (sender is anonymous), only destHash.
|
||||
let inferredSrc = decoded.srcHash || null;
|
||||
let inferredDst = decoded.destHash || null;
|
||||
const TYPES_WITH_DST_SRC = [1, 2, 7, 8]; // RESPONSE=1, TXT_MSG=2, ANON_REQ=7, PATH=8 — all carry hashes
|
||||
if ((!inferredSrc || !inferredDst) && chosen.raw_hex && TYPES_WITH_DST_SRC.indexOf(pkt.payload_type) >= 0) {
|
||||
try {
|
||||
const hex = chosen.raw_hex;
|
||||
// MeshCore wire max for path length is 64 hops. Cap defensively:
|
||||
// a crafted ingest packet with pathLen=200 would slice random body
|
||||
// bytes into the srcHash/destHash UI fields. Guard before use.
|
||||
const pathLen = parseInt(hex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
throw new Error('pathLen out of range: ' + pathLen);
|
||||
}
|
||||
const destOff = 4 + pathLen * 2;
|
||||
if (hex.length >= destOff + 2) {
|
||||
inferredDst = inferredDst || hex.slice(destOff, destOff + 2).toUpperCase();
|
||||
// ANON_REQ has no srcHash
|
||||
if (pkt.payload_type !== 7 && hex.length >= destOff + 4) {
|
||||
inferredSrc = inferredSrc || hex.slice(destOff + 2, destOff + 4).toUpperCase();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// GRP_TXT: channel_hash byte sits right after path bytes in raw_hex.
|
||||
// Layout: byte0=route+type, byte1=path_len, path bytes, channel_hash, encrypted body.
|
||||
let inferredChannelHash = decoded.channelHashHex || null;
|
||||
if (!inferredChannelHash && chosen.raw_hex && pkt.payload_type === 5) {
|
||||
try {
|
||||
const hex = chosen.raw_hex;
|
||||
const pathLen = parseInt(hex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
throw new Error('pathLen out of range: ' + pathLen);
|
||||
}
|
||||
const chOff = 4 + pathLen * 2;
|
||||
if (hex.length >= chOff + 2) {
|
||||
inferredChannelHash = hex.slice(chOff, chOff + 2).toUpperCase();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
const pktCtx = {
|
||||
type: inferredType,
|
||||
decoded: Object.assign({}, decoded, { srcHash: inferredSrc, destHash: inferredDst, channelHashHex: inferredChannelHash || decoded.channelHashHex }),
|
||||
payloadType: pkt.payload_type || null,
|
||||
srcResolvedName: inferredSrc ? resolveNameByHash(inferredSrc) : null,
|
||||
destResolvedName: inferredDst ? resolveNameByHash(inferredDst) : null,
|
||||
observedHops: chosenPath.length,
|
||||
observationCount: observations.length
|
||||
};
|
||||
// GRP_TXT: try to resolve the channel name from /api/channels.
|
||||
if (inferredType === 'GRP_TXT' && inferredChannelHash) {
|
||||
try {
|
||||
const chResp = await fetch('/api/channels?includeEncrypted=true');
|
||||
if (chResp.ok) {
|
||||
const chData = await chResp.json();
|
||||
const chList = chData.channels || [];
|
||||
const wantUp = inferredChannelHash.toUpperCase();
|
||||
// Match by:
|
||||
// 1) hash field starts with target (full hex hash)
|
||||
// 2) hash == "enc_<HEX>" (no-key fallback channels)
|
||||
// 3) name contains "0x<HEX>" (encrypted placeholder)
|
||||
// 4) for keyed channels: compute SHA256(name)[0] === target byte
|
||||
// (browser SubtleCrypto — async)
|
||||
let match = chList.find(c => {
|
||||
const ch = String(c.hash || '').toUpperCase();
|
||||
const nm = String(c.name || '').toUpperCase();
|
||||
return ch.startsWith(wantUp) ||
|
||||
ch === 'ENC_' + wantUp ||
|
||||
nm.includes('0X' + wantUp);
|
||||
});
|
||||
// If not matched and we have SubtleCrypto, try SHA256 lookup
|
||||
if (!match && window.crypto && window.crypto.subtle) {
|
||||
for (const c of chList) {
|
||||
if (c.encrypted) continue; // skip the enc_ placeholders
|
||||
const name = c.name || '';
|
||||
if (!name) continue;
|
||||
try {
|
||||
const buf = new TextEncoder().encode(name);
|
||||
const hashBuf = await window.crypto.subtle.digest('SHA-256', buf);
|
||||
const arr = new Uint8Array(hashBuf);
|
||||
const byteHex = arr[0].toString(16).padStart(2, '0').toUpperCase();
|
||||
if (byteHex === wantUp) { match = c; break; }
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
const isEnc = !!match.encrypted || /^enc_/i.test(match.hash || '');
|
||||
pktCtx.channelName = isEnc ? ('Encrypted (0x' + inferredChannelHash + ')') : (match.name || '#' + inferredChannelHash);
|
||||
pktCtx.channelEncrypted = isEnc;
|
||||
if (match.lastMessage && match.lastMessage !== 'Encrypted — click to decrypt') {
|
||||
pktCtx.channelLastMessage = match.lastMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!pktCtx.channelName) {
|
||||
pktCtx.channelName = 'channel 0x' + inferredChannelHash;
|
||||
}
|
||||
// Fetch decrypted text for this specific packet (only if not encrypted-only)
|
||||
if (!pktCtx.channelEncrypted) {
|
||||
try {
|
||||
const cleanName = (pktCtx.channelName || '').replace(/^#/, '');
|
||||
const msgResp = await fetch('/api/channels/' + encodeURIComponent(cleanName) + '/messages?limit=10');
|
||||
if (msgResp.ok) {
|
||||
const msgs = await msgResp.json();
|
||||
const m = (msgs.messages || []).find(mm => mm.packet_hash === packetHash || mm.hash === packetHash);
|
||||
if (m && (m.text || m.plainText)) pktCtx.decryptedText = m.text || m.plainText;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (allPaths.length === 0) {
|
||||
// Polish review (doshi #1423): operator clicked a ?packet=<hash> URL
|
||||
// but every observation had an empty path. Previously this bailed
|
||||
// silently (map stayed where it was, no message). Surface a console
|
||||
// breadcrumb + a brief toast so the operator knows why nothing
|
||||
// rendered.
|
||||
console.warn('[deep-link] packet ' + packetHash + ' has no observed route data (all observations had empty path)');
|
||||
try {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'mc-rt-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.style.cssText = 'position:fixed;top:80px;left:50%;transform:translateX(-50%);' +
|
||||
'background:var(--mc-bg-secondary,#1a1a1a);color:var(--mc-text-primary,#e5e5e5);' +
|
||||
'padding:10px 16px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);' +
|
||||
'z-index:10000;font:13px/1.4 system-ui,sans-serif;max-width:80vw;text-align:center;';
|
||||
toast.textContent = 'Packet has no observed route data.';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { try { toast.remove(); } catch (_) {} }, 4000);
|
||||
} catch (e) { console.warn('[deep-link] toast failed:', e); }
|
||||
return;
|
||||
}
|
||||
if (allPaths.length === 1) {
|
||||
drawPacketRoute(chosenPath, origin, { destination: destination, packetHash: packetHash, observationId: obsId, packetContext: pktCtx });
|
||||
} else {
|
||||
drawPacketRouteMulti(allPaths, origin, {
|
||||
packetHash: packetHash,
|
||||
observationId: obsId,
|
||||
canonicalPath: chosenPath,
|
||||
destination: destination,
|
||||
packetContext: pktCtx
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[deep-link] route load failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
// Load regions from config + observed IATAs
|
||||
|
||||
+38
-5
@@ -3076,11 +3076,44 @@
|
||||
else if (decoded.srcHash) origin.pubkey = decoded.srcHash;
|
||||
if (decoded.adName || decoded.name) origin.name = decoded.adName || decoded.name;
|
||||
if (senderLat != null && senderLon != null) { origin.lat = senderLat; origin.lon = senderLon; }
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({
|
||||
origin: origin,
|
||||
hops: resolvedKeys
|
||||
}));
|
||||
window.location.hash = '#/map?route=1';
|
||||
// #1418 Phase D: also include the recipient (destHash) so the route
|
||||
// displays as: sender → [intermediate hops] → recipient. Without
|
||||
// this the destination node is invisible — operator only sees the
|
||||
// last intermediate repeater.
|
||||
const destination = {};
|
||||
if (decoded.destHash) destination.pubkey = decoded.destHash;
|
||||
// #1418 Phase C: include ALL observations as alternate paths so the
|
||||
// route view can render union-of-edges with stroke-width weighting.
|
||||
// Each observation contributes its own path_json array.
|
||||
const allPaths = (observations || []).map(o => {
|
||||
let path = [];
|
||||
try { path = JSON.parse(o.path_json || '[]'); } catch (_) {}
|
||||
return { path: path, observer: o.observer_name, observer_id: o.observer_id, snr: o.snr, rssi: o.rssi };
|
||||
}).filter(p => p.path && p.path.length > 0);
|
||||
// #1418/#1419: navigate via deep-link URL only. The map page's
|
||||
// loadRouteFromDeepLink() re-fetches the packet from the API and
|
||||
// builds the full payload (incl. packetContext) consistently.
|
||||
// SessionStorage was unreliable — the deep-link path includes
|
||||
// packetContext but the sessionStorage payload didn't, leading
|
||||
// to missing chip + facts when entered from the packets page.
|
||||
const obsId = currentObs ? currentObs.id : (observations[0] && observations[0].id);
|
||||
const pkHash = pkt.hash || pkt.packet_hash;
|
||||
const obsPart = obsId ? '&obs=' + encodeURIComponent(obsId) : '';
|
||||
// Tufte audit fix: close ALL mobile packet panels so operator lands
|
||||
// on the route view, not behind a still-visible detail sheet.
|
||||
// Three different panels exist depending on viewport + flow:
|
||||
// - #pktRight (desktop split-pane)
|
||||
// - .slide-over-panel (mid-width SlideOver)
|
||||
// - #mobileDetailSheet (small-mobile bottom sheet)
|
||||
if (window.innerWidth <= 767) {
|
||||
try { closeDetailPanel(); } catch (_) {}
|
||||
try { if (window.SlideOver && window.SlideOver.close) window.SlideOver.close(); } catch (_) {}
|
||||
try {
|
||||
const sheet = document.getElementById('mobileDetailSheet');
|
||||
if (sheet) sheet.classList.remove('open');
|
||||
} catch (_) {}
|
||||
}
|
||||
window.location.hash = '#/map?packet=' + encodeURIComponent(pkHash) + obsPart;
|
||||
} catch {
|
||||
window.location.hash = '#/map';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
/* route-view.css — minimal route view layout + styling. */
|
||||
|
||||
body.mc-route-active #leaflet-map { left: 320px !important; width: calc(100% - 320px) !important; }
|
||||
|
||||
/* Auto-collapse Map Controls panel when route view opens. The toggle button
|
||||
(.map-controls-toggle) stays visible — clicking it expands the panel.
|
||||
Map controls JS uses the `.collapsed` class for its own toggle state. */
|
||||
body.mc-route-active .map-controls.collapsed { display: none !important; }
|
||||
body.mc-route-active #pktRight,
|
||||
body.mc-route-active .slide-over-panel,
|
||||
body.mc-route-active .slide-over-backdrop,
|
||||
body.mc-route-active .mobile-detail-sheet { display: none !important; }
|
||||
|
||||
/* Hide regular node clusters and topology markers during route view so the
|
||||
route polyline + its own markers aren't lost in a 600-node mesh. The route
|
||||
layer's own markers use .mc-rt-marker-icon and are NOT hidden. */
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .meshcore-label-marker { display: none !important; }
|
||||
body.mc-route-active .leaflet-marker-pane .marker-cluster { display: none !important; }
|
||||
/* CoreScope custom cluster bubble wrappers (not the leaflet.markercluster ones) */
|
||||
body.mc-route-active .leaflet-marker-pane .mc-cluster-wrap { display: none !important; }
|
||||
/* Hide overlay-pane SVG paths that aren't part of the route. The route's
|
||||
own polylines have class="mc-rt-edge". */
|
||||
body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display: none !important; }
|
||||
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: 52px; /* below top-nav */
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
background: var(--surface, #1a1a1a);
|
||||
border-right: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7);
|
||||
font: 13px/1.4 system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 500;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.mc-rt-header {
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
position: relative;
|
||||
}
|
||||
.mc-rt-title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-right: 36px; /* leave room for close button */
|
||||
}
|
||||
.mc-rt-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-back-link:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
text-decoration: underline;
|
||||
}
|
||||
.mc-rt-back-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.mc-rt-title { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-meta { font-size: 11px; color: var(--text-muted, #94a3b8); margin-top: 2px; }
|
||||
.mc-rt-multipath-chip {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-multipath-chip b { color: var(--text, #fff); }
|
||||
.mc-rt-multipath-key {
|
||||
margin-top: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-style: italic;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Multi-path picker — Click a path to isolate it on the map. */
|
||||
.mc-rt-paths {
|
||||
margin-top: 6px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-paths[open] { overflow: auto; max-height: 180px; }
|
||||
.mc-rt-paths-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
list-style: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mc-rt-paths-header::-webkit-details-marker { display: none; }
|
||||
.mc-rt-paths-header::before {
|
||||
content: '▾';
|
||||
margin-right: 4px;
|
||||
font-size: 9px;
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.mc-rt-paths:not([open]) .mc-rt-paths-header::before { transform: rotate(-90deg); }
|
||||
.mc-rt-path-clear {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-path-clear:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
}
|
||||
.mc-rt-path-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
}
|
||||
.mc-rt-path-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-path-row:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
}
|
||||
.mc-rt-path-row.mc-rt-path-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.18));
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
padding-left: 5px;
|
||||
}
|
||||
.mc-rt-path-row:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.mc-rt-path-count {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-path-hops {
|
||||
font-size: 10px;
|
||||
color: var(--text, #cbd5e1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-path-obs {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 80px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-spark-wrap {
|
||||
margin: 8px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mc-rt-spark-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.mc-rt-spark-title b { color: var(--text, #e7e7e7); font-weight: 600; }
|
||||
.mc-rt-spark { display: block; cursor: pointer; }
|
||||
.mc-rt-spark-dot { cursor: pointer; }
|
||||
.mc-rt-spark-dot:hover { r: 3; }
|
||||
.mc-rt-spark-tooltip {
|
||||
position: absolute;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font: 11px ui-monospace, Menlo, monospace;
|
||||
color: var(--text, #e7e7e7);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-close {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
background: transparent; border: 1px solid var(--border, #333);
|
||||
color: var(--text, #e7e7e7); border-radius: 4px;
|
||||
width: 26px; height: 26px; cursor: pointer; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.mc-rt-close:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.mc-rt-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
|
||||
.mc-rt-pinned { padding: 2px 0; }
|
||||
.mc-rt-pinned-top { border-bottom: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned-bottom { border-top: 1px solid var(--border, #333); }
|
||||
.mc-rt-pinned .mc-rt-row { background: var(--surface-2, #232323); font-weight: 600; }
|
||||
.mc-rt-pinned .mc-rt-row::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
font-size: 9px; letter-spacing: 1px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mc-rt-pinned-top .mc-rt-row::before { content: 'SRC'; padding-right: 4px; }
|
||||
.mc-rt-pinned-bottom .mc-rt-row::before { content: 'DST'; padding-right: 4px; }
|
||||
|
||||
.mc-rt-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 22px 18px 1fr auto;
|
||||
grid-template-rows: auto 4px;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
padding: 4px 12px 4px 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.02);
|
||||
}
|
||||
.mc-rt-row:hover,
|
||||
.mc-rt-row:focus,
|
||||
.mc-rt-row.mc-rt-row-active {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.08));
|
||||
outline: none;
|
||||
}
|
||||
.mc-rt-stripe {
|
||||
grid-column: 1; grid-row: 1 / -1;
|
||||
width: 4px; height: 100%;
|
||||
background: var(--mc-rt-row-color, transparent);
|
||||
}
|
||||
.mc-rt-seq {
|
||||
grid-column: 2; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-align: right;
|
||||
}
|
||||
.mc-rt-glyph {
|
||||
grid-column: 3; grid-row: 1;
|
||||
text-align: center; font-size: 12px;
|
||||
}
|
||||
.mc-rt-name {
|
||||
grid-column: 4; grid-row: 1;
|
||||
font-size: 12px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-obs-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
background: var(--surface-2, #2a2a2a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
}
|
||||
.mc-rt-status-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px; padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mc-rt-status-nogps {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.mc-rt-status-unknown {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
.mc-rt-status-payload {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #67e8f9;
|
||||
border: 1px solid rgba(6, 182, 212, 0.35);
|
||||
font-style: italic;
|
||||
}
|
||||
.mc-rt-distlabel {
|
||||
grid-column: 5; grid-row: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-distbar-wrap {
|
||||
grid-column: 2 / -1; grid-row: 2;
|
||||
height: 3px;
|
||||
background: transparent;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-distbar {
|
||||
height: 100%;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.mc-rt-unresolved .mc-rt-name { color: var(--text-muted, #94a3b8); font-style: italic; }
|
||||
|
||||
/* Drill-in expanding panel (hop detail) */
|
||||
.mc-rt-row.mc-rt-row-expanded {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
}
|
||||
.mc-rt-detail-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
padding: 8px 10px 10px;
|
||||
background: var(--surface-2, #1d1d1d);
|
||||
border-top: 1px solid var(--border, #333);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text, #e7e7e7);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mc-rt-row { grid-template-rows: auto 4px auto; }
|
||||
.mc-rt-detail-loading,
|
||||
.mc-rt-detail-na { color: var(--text-muted, #94a3b8); font-style: italic; font-size: 10px; }
|
||||
.mc-rt-detail-row1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; margin-bottom: 4px; }
|
||||
.mc-rt-detail-name { font-weight: 700; color: var(--text, #fff); font-size: 12px; }
|
||||
.mc-rt-detail-warn {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mc-rt-detail-meta {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-label {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
.mc-rt-detail-snr,
|
||||
.mc-rt-detail-relay,
|
||||
.mc-rt-detail-also { margin: 2px 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.mc-rt-detail-spark { vertical-align: middle; color: var(--text, #cbd5e1); }
|
||||
.mc-rt-detail-spark-meta { font-size: 9px; color: var(--text-muted, #94a3b8); font-family: ui-monospace, Menlo, monospace; }
|
||||
.mc-rt-detail-link { color: var(--accent, #06b6d4); text-decoration: none; }
|
||||
.mc-rt-detail-link:hover { text-decoration: underline; }
|
||||
.mc-rt-detail-link:focus {
|
||||
outline: 2px solid var(--accent, #06b6d4);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.mc-rt-detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
.mc-rt-detail-action:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
border-color: var(--accent, #06b6d4);
|
||||
text-decoration: none;
|
||||
}
|
||||
.mc-rt-route-badge {
|
||||
background: var(--surface, #1a1a1a);
|
||||
border: 1px solid var(--border, #444);
|
||||
border-radius: 8px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Marker styles — no chips, just shape. */
|
||||
.mc-rt-marker-icon { background: transparent !important; border: none !important; }
|
||||
.mc-rt-marker { position: relative; line-height: 0; cursor: pointer; transition: transform 120ms ease-out; }
|
||||
.mc-rt-marker:hover,
|
||||
.mc-rt-marker.mc-rt-hover { transform: scale(1.5); z-index: 1000 !important; }
|
||||
.mc-rt-marker:focus { outline: 2px solid #06b6d4; outline-offset: 2px; border-radius: 50%; }
|
||||
|
||||
/* packet-context block (type chip + 3-5 facts). Above multi-path. */
|
||||
.mc-rt-ctx {
|
||||
margin: 6px 0 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2, #1f1f1f);
|
||||
border-left: 3px solid var(--accent, #06b6d4);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.mc-rt-ctx-chip {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mc-rt-ctx-glyph {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mc-rt-ctx-facts { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mc-rt-ctx-line { color: var(--text, #cbd5e1); }
|
||||
.mc-rt-ctx-line b { color: var(--text, #fff); font-weight: 600; }
|
||||
.mc-rt-ctx-arrow { color: var(--text-muted, #94a3b8); margin: 0 4px; }
|
||||
.mc-rt-ctx-meta { color: var(--text-muted, #94a3b8); font-size: 10px; }
|
||||
.mc-rt-ctx-mono { font-family: ui-monospace, Menlo, monospace; font-size: 10px; color: var(--text-muted, #94a3b8); }
|
||||
.mc-rt-ctx-quote {
|
||||
font-style: italic;
|
||||
color: var(--text, #fff);
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--border, #444);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
1,2,3… on the map without scrubbing the sidebar. Origin (square) + dest
|
||||
(triangle) are shape-differentiated and don't need numeric labels. */
|
||||
.mc-rt-marker-seq {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 14px; /* to the right of the marker dot, no overlap */
|
||||
background: var(--surface, #1a1a1a);
|
||||
color: var(--text, #fff);
|
||||
border: 1px solid var(--border, #666);
|
||||
border-radius: 5px;
|
||||
min-width: 13px;
|
||||
height: 11px;
|
||||
padding: 0 2px;
|
||||
font: 700 8px/11px ui-monospace, Menlo, monospace;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
z-index: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Hover/focus on a marker pops its seq label so it's never hidden by
|
||||
neighbors at high density. */
|
||||
.mc-rt-marker:hover .mc-rt-marker-seq,
|
||||
.mc-rt-marker:focus .mc-rt-marker-seq,
|
||||
.mc-rt-marker.mc-rt-hover .mc-rt-marker-seq {
|
||||
z-index: 1000;
|
||||
transform: scale(1.4);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* Mobile: map dominates (75vh), sidebar is a compact bottom strip (25vh)
|
||||
showing just packet type + hop count + distance + summary. Operator's job:
|
||||
see the route on the map first, scroll the strip for hop list. */
|
||||
/* Mobile bottom-sheet handle (visible only on mobile) — hidden by default */
|
||||
.mc-rt-mobile-handle { display: none; }
|
||||
.mc-rt-collapsed-label { display: none; }
|
||||
|
||||
/* Desktop resize handle on the right edge of the sidebar */
|
||||
.mc-rt-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.mc-rt-resize-handle:hover,
|
||||
.mc-rt-resize-handle:focus {
|
||||
background: var(--accent, #06b6d4);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Desktop collapse button — sits on the RIGHT edge of the sidebar,
|
||||
vertically centered. Standard Material/Drive-style affordance: chevron
|
||||
pointing into the panel = collapse, out of the panel = expand. */
|
||||
.mc-rt-collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--surface-2, #232323);
|
||||
border: 1px solid var(--border, #333);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-shadow: 1px 0 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.mc-rt-collapse-btn:hover {
|
||||
background: var(--bg-hover, rgba(120,160,255,0.12));
|
||||
color: var(--text, #fff);
|
||||
border-color: var(--accent, #06b6d4);
|
||||
}
|
||||
|
||||
/* Vertical "ROUTE" label shown only when collapsed */
|
||||
.mc-rt-collapsed-label {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-90deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed state on desktop */
|
||||
.mc-rt-sidebar.mc-rt-collapsed {
|
||||
width: 36px !important;
|
||||
min-width: 36px;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-header,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-list,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-pinned,
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-resize-handle { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapsed-label { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-collapsed .mc-rt-collapse-btn {
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* When sidebar is collapsed, expand map to fill */
|
||||
body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
|
||||
left: 36px !important;
|
||||
width: calc(100% - 36px) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: hide desktop collapse + resize affordances (mobile uses bottom-sheet) */
|
||||
body.mc-route-active .mc-rt-collapse-btn,
|
||||
body.mc-route-active .mc-rt-resize-handle,
|
||||
body.mc-route-active .mc-rt-collapsed-label { display: none !important; }
|
||||
body.mc-route-active #leaflet-map {
|
||||
position: fixed !important;
|
||||
top: 52px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100% !important;
|
||||
/* iOS Safari/Edge: use dvh (dynamic viewport) so URL bar collapse
|
||||
doesn't leave a stale layout. Fall back to vh for browsers that
|
||||
don't support dvh yet. */
|
||||
bottom: calc(116px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
height: auto !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
body.mc-route-active.mc-rt-mobile-sheet-expanded #leaflet-map {
|
||||
bottom: calc(75vh + 56px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
body.mc-route-active .map-controls-toggle {
|
||||
position: fixed !important;
|
||||
top: 60px !important;
|
||||
right: 8px !important;
|
||||
z-index: 1100 !important;
|
||||
/* Force overlay — no normal-flow row consumption */
|
||||
margin: 0 !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
body.mc-route-active .map-controls {
|
||||
position: fixed !important;
|
||||
top: 100px !important;
|
||||
right: 8px !important;
|
||||
left: 8px !important;
|
||||
width: auto !important;
|
||||
max-height: 60vh !important;
|
||||
z-index: 1090 !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mc-rt-sidebar {
|
||||
position: fixed;
|
||||
top: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0;
|
||||
/* Sit ABOVE the bottom-nav (56px) + iOS safe-area inset */
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
max-height: 60px;
|
||||
transition: height 240ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
z-index: 1190; /* below bottom-nav (1200) but above content */
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded {
|
||||
height: 75vh;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Bigger touch target — full sheet header tappable, large chevron */
|
||||
.mc-rt-mobile-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 14px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--surface, #1a1a1a);
|
||||
user-select: none;
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
/* Prevent the page from scrolling when swiping on this area */
|
||||
touch-action: none;
|
||||
}
|
||||
.mc-rt-mobile-grip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--text-muted, #94a3b8);
|
||||
border-radius: 3px;
|
||||
opacity: 0.6;
|
||||
/* Large tap target around the grip */
|
||||
cursor: grab;
|
||||
}
|
||||
.mc-rt-mobile-grip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -12px;
|
||||
}
|
||||
.mc-rt-mobile-chevron {
|
||||
font-size: 22px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
transition: transform 240ms;
|
||||
/* Make the chevron itself a generous tap target */
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-rt-mobile-summary {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text, #cbd5e1);
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
margin-top: 10px;
|
||||
line-height: 1.3;
|
||||
max-height: 36px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-rt-mobile-hex {
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 9px;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-mobile-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
/* Hide full content when collapsed; show when expanded */
|
||||
.mc-rt-sidebar .mc-rt-header,
|
||||
.mc-rt-sidebar .mc-rt-list,
|
||||
.mc-rt-sidebar .mc-rt-pinned { display: none; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-header { display: block; padding: 8px 12px 6px; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-list { display: block; }
|
||||
.mc-rt-sidebar.mc-rt-mobile-expanded .mc-rt-pinned { display: block; }
|
||||
.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; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -505,6 +505,14 @@ input[type="week"] {
|
||||
position: sticky; top: 0; z-index: 1100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
flex-wrap: nowrap; overflow: hidden; min-width: 0;
|
||||
/* #1413: enforce a minimum horizontal gap between flex children of
|
||||
.top-nav (.nav-left and .nav-right). Without this the only thing
|
||||
pushing nav-right away from nav-left was justify-content: space-
|
||||
between, which collapses to zero whenever nav-left's intrinsic
|
||||
content fills the row — including the historic vw~1101..1599 band
|
||||
where .nav-stats reappears (display:flex) and shoves nav-right
|
||||
leftward to overlap the .nav-more-btn. */
|
||||
column-gap: 16px;
|
||||
}
|
||||
/* #1403: removed overflow:hidden — was clipping flex children offscreen (vertical) AND clipping the More dropdown contents. The original purpose was to prevent horizontal overflow during Priority+ measurement (#1066) — that purpose is served by .top-nav itself which still has overflow:hidden. */
|
||||
.nav-left { display: flex; align-items: center; gap: var(--space-lg); min-width: 0; flex-shrink: 1; }
|
||||
@@ -594,7 +602,7 @@ input[type="week"] {
|
||||
}
|
||||
}
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: var(--space-xs); }
|
||||
.nav-links { display: flex; align-items: center; gap: var(--space-xs); flex: 1 1 auto; min-width: 0; }
|
||||
.nav-link {
|
||||
color: var(--nav-text-muted); text-decoration: none;
|
||||
padding: 14px clamp(8px, 0.6vw + 4px, 14px); font-size: var(--fs-sm);
|
||||
|
||||
@@ -29,6 +29,14 @@ node test-observers-headings.js
|
||||
node test-marker-outline-weight.js
|
||||
node test-traces.js
|
||||
|
||||
# #1418 — route-view v2 (Tufte) coverage
|
||||
node test-issue-1418-raw-hex-extraction.js
|
||||
node test-issue-1418-edge-weights.js
|
||||
node test-issue-1418-cb-preset-ramp.js
|
||||
node test-issue-1418-spider-fan.js
|
||||
node test-issue-1418-deeplink-hops-channels.js
|
||||
node test-issue-1418-polish-review.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " All tests passed"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* #1412 — customizer nodeColors must NOT auto-push server config into
|
||||
* ROLE_COLORS overrides, or it defeats CB-preset propagation.
|
||||
*
|
||||
* Bug (CDP-verified on staging): PR #1408 made window.ROLE_COLORS a live
|
||||
* getter that reads --mc-role-* CSS vars. cb-presets.applyPreset() writes
|
||||
* those vars, so consumers SHOULD see new colors. But customize-v2.js:553
|
||||
* runs early on every page load and pushes effectiveConfig.nodeColors
|
||||
* (server config, legacy April palette) into the override map, which the
|
||||
* getter prefers over CSS vars. Net effect: ROLE_COLORS is frozen on the
|
||||
* legacy palette forever; presets only update the CSS, not the JS.
|
||||
*
|
||||
* Fix: server-config nodeColors must only write --node-* CSS var (legacy
|
||||
* compat for anything still reading --node-*). It must NOT touch the
|
||||
* override map. User-chosen colors in the customizer continue to win via
|
||||
* setRoleColorOverride() (explicit, intentional override).
|
||||
*
|
||||
* Test strategy: extract the actual code block from customize-v2.js that
|
||||
* processes effectiveConfig.nodeColors, run it in a vm sandbox with a
|
||||
* legacy-palette config, apply preset "deut", assert ROLE_COLORS reflects
|
||||
* the preset (not the server config).
|
||||
*
|
||||
* Mutation guard: re-introducing the `window.ROLE_COLORS[role] = nc[role]`
|
||||
* write to customize-v2.js makes the first test fail.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const cv2Src = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
|
||||
|
||||
// Browser-ish sandbox (CSS var setProperty/getPropertyValue).
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; },
|
||||
dataset: {}
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root,
|
||||
body: body,
|
||||
readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; },
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {},
|
||||
},
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
addEventListener() {},
|
||||
dispatchEvent() { return true; },
|
||||
fetch: function () { return { then: function () { return { then: function () { return { catch: function () {} }; }, catch: function () {} }; } }; },
|
||||
matchMedia: function () { return { matches: false }; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return (root.style._vars[k] || ''); } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body };
|
||||
}
|
||||
|
||||
// ─── Extract the two nodeColors-processing blocks from customize-v2.js. ───
|
||||
// We want to execute the REAL source so reverting the fix breaks the test.
|
||||
// Block 1: the effective-config apply path (≈ line 550).
|
||||
// Block 2: the early-overrides apply path (≈ line 2146).
|
||||
function extractBlock(src, anchor) {
|
||||
const idx = src.indexOf(anchor);
|
||||
if (idx === -1) throw new Error('anchor not found: ' + anchor);
|
||||
// Walk forward to the matching closing brace of the surrounding `if (nc) { ... }`.
|
||||
// Slice forward a generous window then balance braces from the first '{' after anchor.
|
||||
const start = src.indexOf('{', idx);
|
||||
if (start === -1) throw new Error('open brace not found after anchor');
|
||||
let depth = 0, end = -1;
|
||||
for (let i = start; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end === -1) throw new Error('matching close brace not found');
|
||||
return src.slice(idx, end + 1);
|
||||
}
|
||||
|
||||
// Block A — main effective-config push: `var nc = effectiveConfig.nodeColors;`
|
||||
const blockA = extractBlock(cv2Src, 'var nc = effectiveConfig.nodeColors;');
|
||||
// Block B — early overrides: `if (earlyOverrides.nodeColors) {`
|
||||
const blockB = extractBlock(cv2Src, 'if (earlyOverrides.nodeColors) {');
|
||||
|
||||
console.log('\n=== #1412 A: server-config nodeColors does NOT clobber preset ROLE_COLORS ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
|
||||
// Simulate user choosing the "deut" preset.
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
// CSS var should be IBM orange now.
|
||||
assert(env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase() === '#fe6100',
|
||||
'precondition: --mc-role-repeater is #FE6100 after applyPreset("deut")');
|
||||
|
||||
// Now simulate customize-v2 picking up the server config (legacy palette).
|
||||
const setupBlockA =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var effectiveConfig = { nodeColors: { repeater: "#dc2626", companion: "#2563eb", room: "#16a34a", sensor: "#d97706", observer: "#8b5cf6" } };\n' +
|
||||
blockA + '\n';
|
||||
vm.runInContext(setupBlockA, env.sandbox);
|
||||
|
||||
// The --node-* CSS vars should still be written for legacy consumers.
|
||||
assert(env.root.style.getPropertyValue('--node-repeater') === '#dc2626',
|
||||
'--node-repeater CSS var is still written (legacy compat preserved)');
|
||||
|
||||
// The KEY assertion: ROLE_COLORS must still reflect the preset, NOT the
|
||||
// server-config legacy palette.
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after server-config push (got ' + got + ')');
|
||||
|
||||
const gotCompanion = String(env.sandbox.window.ROLE_COLORS.companion).toLowerCase();
|
||||
assert(gotCompanion !== '#2563eb',
|
||||
'ROLE_COLORS.companion is NOT the server-config legacy #2563eb (got ' + gotCompanion + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 B: early-overrides path also stays out of ROLE_COLORS override map ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
const setupBlockB =
|
||||
'var root = document.documentElement.style;\n' +
|
||||
'var earlyOverrides = { nodeColors: { repeater: "#dc2626", companion: "#2563eb" } };\n' +
|
||||
blockB + '\n';
|
||||
// earlyOverrides path also writes --node-* and (per fix) only --node-*.
|
||||
// The extracted block may not write --node-* — that's fine; we only care
|
||||
// it does NOT push into the override map.
|
||||
try { vm.runInContext(setupBlockB, env.sandbox); }
|
||||
catch (e) { /* if the block touches APIs we didn't stub, ignore — the
|
||||
override-map assertion below is what matters */ }
|
||||
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#fe6100',
|
||||
'ROLE_COLORS.repeater === #FE6100 after early-overrides push (got ' + got + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 C: explicit setRoleColorOverride() still wins (user customizer pick) ===');
|
||||
{
|
||||
const env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
env.sandbox.window.MeshCorePresets.applyPreset('deut');
|
||||
|
||||
// User manually picks a node color in the customizer.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff');
|
||||
const got = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got === '#ff00ff',
|
||||
'after setRoleColorOverride("repeater","#ff00ff") ROLE_COLORS.repeater === #ff00ff (got ' + got + ')');
|
||||
|
||||
// Clearing the override lets the preset show through again.
|
||||
env.sandbox.window.setRoleColorOverride('repeater', '');
|
||||
const got2 = String(env.sandbox.window.ROLE_COLORS.repeater).toLowerCase();
|
||||
assert(got2 === '#fe6100',
|
||||
'after clearing override, ROLE_COLORS.repeater reverts to preset #FE6100 (got ' + got2 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1412 D: customize.js per-key node-color picker uses setRoleColorOverride ===');
|
||||
{
|
||||
// Static guard: the legacy customizer (customize.js) handlers for the node
|
||||
// color pickers must call setRoleColorOverride(key, value) — NOT mutate
|
||||
// ROLE_COLORS directly. The proxy-on-read trick in roles.js handles direct
|
||||
// assignment, but going through the explicit API keeps semantics obvious
|
||||
// and lets us delete the proxy layer later.
|
||||
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize.js'), 'utf8');
|
||||
|
||||
// Grep for the two affected handlers (data-node input handler + reset).
|
||||
// Locate the input[data-node] handler — slice forward through the inner forEach callback.
|
||||
const nodeInputStart = customizeSrc.indexOf("querySelectorAll('input[data-node]')");
|
||||
const nodeInputHandler = nodeInputStart >= 0 ? [customizeSrc.slice(nodeInputStart, nodeInputStart + 800)] : null;
|
||||
assert(nodeInputHandler, 'node color input handler block found in customize.js');
|
||||
if (nodeInputHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeInputHandler[0]),
|
||||
'node color input handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\s*\[[^\]]+\]\s*=/.test(nodeInputHandler[0]),
|
||||
'node color input handler does NOT assign window.ROLE_COLORS[key] = … directly');
|
||||
}
|
||||
|
||||
const nodeResetStart = customizeSrc.indexOf("querySelectorAll('[data-reset-node]')");
|
||||
const nodeResetHandler = nodeResetStart >= 0 ? [customizeSrc.slice(nodeResetStart, nodeResetStart + 800)] : null;
|
||||
assert(nodeResetHandler, 'node color reset handler block found in customize.js');
|
||||
if (nodeResetHandler) {
|
||||
assert(/setRoleColorOverride\s*\(/.test(nodeResetHandler[0]),
|
||||
'node color reset handler calls setRoleColorOverride()');
|
||||
assert(!/window\.ROLE_COLORS\[/.test(nodeResetHandler[0]),
|
||||
'node color reset handler does NOT write window.ROLE_COLORS[key] directly');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/* Issue #1413 — More button overlaps nav-stats badge at vw~1200px.
|
||||
*
|
||||
* Symptom: at viewport ~1101..1599px on a non-mobile page (e.g.
|
||||
* /#/packets), the ".nav-more-btn" (in .nav-left) and ".nav-stats"
|
||||
* (in .nav-right) overlap horizontally. CDP-confirmed: at vw=1200,
|
||||
* .nav-more-btn rect (x=499..556) sat on top of .nav-stats (x=502..961),
|
||||
* a ~54px x-axis overlap. Visually the stats badge number rendered on
|
||||
* top of the "More" text and the chevron.
|
||||
*
|
||||
* Acceptance (from issue #1413):
|
||||
* - At vw=1101..1920 (sample step), .nav-more-btn.right + GAP <=
|
||||
* .nav-stats.left, where GAP >= 8px.
|
||||
* - At vw <= 1100, .nav-stats is display:none (no change).
|
||||
* - Nav doesn't horizontally scroll at any viewport.
|
||||
*
|
||||
* Root cause: .top-nav uses display:flex with justify-content:
|
||||
* space-between, but .nav-left had no flex-grow and .nav-links had no
|
||||
* flex-grow either, so .nav-left only consumed its content's intrinsic
|
||||
* width. .nav-right (flex-shrink:0) then sat at its natural position
|
||||
* computed from total content — and the JS Priority+ fits() check
|
||||
* succeeded based on intrinsic widths that under-reported the real
|
||||
* collision because .top-nav has overflow:hidden masking it.
|
||||
*
|
||||
* Fix (verified via CDP at vw 1101..1920): `.nav-links { flex: 1 1
|
||||
* auto; min-width: 0 }` + `.top-nav { column-gap: 16px }`. Reverting
|
||||
* either part of the fix reintroduces overlap at vw=1200.
|
||||
*
|
||||
* Mutation guard: revert the CSS fix → this test fails at vw=1200.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
const WIDTHS = [1101, 1200, 1366, 1440, 1600, 1920];
|
||||
const HEIGHT = 800;
|
||||
const MIN_GAP_PX = 8;
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
} catch (err) {
|
||||
if (process.env.CHROMIUM_REQUIRE === '1') {
|
||||
console.error(`test-issue-1413-nav-overlap-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-issue-1413-nav-overlap-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
|
||||
for (const w of WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: HEIGHT });
|
||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.top-nav .nav-links');
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
|
||||
// Settle layout: two consecutive frames identical for nav-right.
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.top-nav .nav-right');
|
||||
if (!el) return false;
|
||||
const r1 = el.getBoundingClientRect();
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const r2 = el.getBoundingClientRect();
|
||||
resolve(r1.right === r2.right && r1.left === r2.left);
|
||||
}));
|
||||
});
|
||||
}, null, { timeout: 5000 });
|
||||
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
const data = await page.evaluate(() => {
|
||||
const more = document.querySelector('.nav-more-btn');
|
||||
const stats = document.querySelector('.nav-stats');
|
||||
const moreVisible = more && getComputedStyle(more).display !== 'none' &&
|
||||
getComputedStyle(more.parentElement).display !== 'none' &&
|
||||
!more.parentElement.classList.contains('is-hidden');
|
||||
const statsVisible = stats && getComputedStyle(stats).display !== 'none';
|
||||
const mb = more ? more.getBoundingClientRect() : null;
|
||||
const sb = stats ? stats.getBoundingClientRect() : null;
|
||||
const topNav = document.querySelector('.top-nav');
|
||||
const tnScrollW = topNav ? topNav.scrollWidth : 0;
|
||||
const tnClientW = topNav ? topNav.clientWidth : 0;
|
||||
return {
|
||||
moreVisible, statsVisible,
|
||||
more: mb ? { x: mb.x, right: mb.right, w: mb.width } : null,
|
||||
stats: sb ? { x: sb.x, right: sb.right, w: sb.width } : null,
|
||||
tnScrollW, tnClientW,
|
||||
};
|
||||
});
|
||||
|
||||
let status = 'PASS';
|
||||
const reasons = [];
|
||||
|
||||
// Acceptance: if both visible, more.right + 8 <= stats.left.
|
||||
if (data.moreVisible && data.statsVisible && data.more && data.stats) {
|
||||
const gap = data.stats.x - data.more.right;
|
||||
if (gap < MIN_GAP_PX) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`overlap: more.right=${data.more.right.toFixed(1)} stats.left=${data.stats.x.toFixed(1)} gap=${gap.toFixed(1)} (need >= ${MIN_GAP_PX})`);
|
||||
}
|
||||
}
|
||||
// No horizontal scroll in nav.
|
||||
if (data.tnScrollW > data.tnClientW + 1) {
|
||||
status = 'FAIL';
|
||||
reasons.push(`top-nav h-scroll: scrollW=${data.tnScrollW} clientW=${data.tnClientW}`);
|
||||
}
|
||||
|
||||
if (status === 'FAIL') {
|
||||
failures++;
|
||||
console.error(`vw=${w} #/packets ${status}: ${reasons.join('; ')}`);
|
||||
} else {
|
||||
passes++;
|
||||
console.log(`vw=${w} #/packets PASS (more.right=${data.more && data.more.right.toFixed(1)} stats.left=${data.stats && data.stats.x.toFixed(1)})`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\ntest-issue-1413-nav-overlap-e2e.js: ${passes} pass, ${failures} fail`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('test-issue-1413-nav-overlap-e2e.js: ERROR', err); process.exit(1); });
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* #1418 — cb-presets.js writes --mc-rt-ramp-0..4 + fires cb-preset-changed.
|
||||
*
|
||||
* `route-view.js` reads CSS vars --mc-rt-ramp-0..4 to color the edge gradient
|
||||
* via getComputedStyle. When the user switches color-blind preset,
|
||||
* applyPreset() must:
|
||||
* 1. Write 5 ramp stops from preset.routeRamp (or fallback viridis).
|
||||
* 2. Fire a cb-preset-changed CustomEvent so route-view.js recolorRoute
|
||||
* can walk .mc-rt-edge / .mc-rt-row / .mc-rt-spark-dot live.
|
||||
*
|
||||
* Pattern mirrors test-issue-1407-cb-preset-propagation.js sandbox shape.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
|
||||
const presetsSrc = fs.readFileSync(path.join(__dirname, 'public', 'cb-presets.js'), 'utf8');
|
||||
const routeSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 ramp A: route-view reads --mc-rt-ramp-* CSS vars ===');
|
||||
assert(/--mc-rt-ramp-/.test(routeSrc),
|
||||
'route-view.js references --mc-rt-ramp-* CSS vars');
|
||||
assert(/cb-preset-changed/.test(routeSrc),
|
||||
'route-view.js listens for cb-preset-changed event');
|
||||
// Selectors recolorRoute touches
|
||||
assert(/mc-rt-edge|mc-rt-spark-dot|mc-rt-row/.test(routeSrc),
|
||||
'recolorRoute touches mc-rt-edge / mc-rt-spark-dot / mc-rt-row classes');
|
||||
|
||||
console.log('\n=== #1418 ramp B: cb-presets writes ramp stops ===');
|
||||
function makeSandbox() {
|
||||
const root = {
|
||||
style: {
|
||||
_vars: {},
|
||||
setProperty(k, v) { this._vars[k] = String(v); },
|
||||
getPropertyValue(k) { return this._vars[k] || ''; },
|
||||
removeProperty(k) { delete this._vars[k]; }
|
||||
},
|
||||
getAttribute() { return null; },
|
||||
setAttribute() {}
|
||||
};
|
||||
const body = {
|
||||
_attrs: {},
|
||||
setAttribute(k, v) { this._attrs[k] = v; },
|
||||
getAttribute(k) { return this._attrs[k] || null; },
|
||||
removeAttribute(k) { delete this._attrs[k]; }
|
||||
};
|
||||
const listeners = {};
|
||||
const storage = {
|
||||
_data: {},
|
||||
getItem(k) { return Object.prototype.hasOwnProperty.call(this._data, k) ? this._data[k] : null; },
|
||||
setItem(k, v) { this._data[k] = String(v); },
|
||||
removeItem(k) { delete this._data[k]; }
|
||||
};
|
||||
const sandbox = {
|
||||
window: null,
|
||||
document: {
|
||||
documentElement: root, body: body, readyState: 'complete',
|
||||
getElementById() { return null; },
|
||||
createElement() {
|
||||
return { _children: [], style: {}, textContent: '', id: '',
|
||||
setAttribute() {}, appendChild(c) { this._children.push(c); } };
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
addEventListener() {}
|
||||
},
|
||||
localStorage: storage,
|
||||
console: console,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
fetch: function () { return Promise.resolve({ ok: false }); },
|
||||
matchMedia: function () { return { matches: false, addEventListener() {}, addListener() {} }; },
|
||||
addEventListener(ev, cb) { (listeners[ev] = listeners[ev] || []).push(cb); },
|
||||
dispatchEvent(ev) { (listeners[ev.type] || []).forEach(function (cb) { cb(ev); }); return true; },
|
||||
CustomEvent: function (type, opts) { this.type = type; this.detail = opts && opts.detail; },
|
||||
Event: function (type) { this.type = type; },
|
||||
getComputedStyle: function () {
|
||||
return { getPropertyValue: function (k) { return root.style._vars[k] || ''; } };
|
||||
}
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
return { sandbox, root, body, storage, listeners };
|
||||
}
|
||||
|
||||
let env;
|
||||
try {
|
||||
env = makeSandbox();
|
||||
vm.createContext(env.sandbox);
|
||||
vm.runInContext(rolesSrc, env.sandbox);
|
||||
vm.runInContext(presetsSrc, env.sandbox);
|
||||
} catch (e) {
|
||||
assert(false, 'sandbox load failed: ' + e.message);
|
||||
}
|
||||
|
||||
const MCP = env && env.sandbox.window.MeshCorePresets;
|
||||
assert(!!MCP, 'MeshCorePresets exported');
|
||||
|
||||
if (MCP) {
|
||||
console.log('\n --- ramp-stop count for every preset ---');
|
||||
['default', 'deut', 'prot', 'trit', 'achromat'].forEach(function (id) {
|
||||
MCP.applyPreset(id);
|
||||
let stopsSet = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const v = env.root.style.getPropertyValue('--mc-rt-ramp-' + i);
|
||||
if (/^#[0-9a-f]{6}$/i.test(v)) stopsSet++;
|
||||
}
|
||||
assert(stopsSet === 5,
|
||||
'preset "' + id + '" sets all 5 ramp stops (--mc-rt-ramp-0..4) — got ' + stopsSet);
|
||||
});
|
||||
|
||||
console.log('\n --- preset routeRamp values land in CSS vars ---');
|
||||
MCP.applyPreset('default');
|
||||
const preset0 = MCP.list.find(p => p.id === 'default');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const expected = preset0.routeRamp[i].toLowerCase();
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
assert(actual === expected,
|
||||
'default --mc-rt-ramp-' + i + ' = ' + expected + ' (got ' + actual + ')');
|
||||
}
|
||||
|
||||
console.log('\n --- switching preset rewrites all 5 stops ---');
|
||||
MCP.applyPreset('deut');
|
||||
const deut = MCP.list.find(p => p.id === 'deut');
|
||||
let allRewritten = true;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const actual = env.root.style.getPropertyValue('--mc-rt-ramp-' + i).toLowerCase();
|
||||
if (actual !== deut.routeRamp[i].toLowerCase()) allRewritten = false;
|
||||
}
|
||||
assert(allRewritten, 'switching to deut overwrites every ramp stop');
|
||||
|
||||
console.log('\n --- achromat ramp is luminance (B/W) ---');
|
||||
MCP.applyPreset('achromat');
|
||||
const achr = MCP.list.find(p => p.id === 'achromat');
|
||||
// Achromat ramp is the gray luminance ramp per cb-presets.js line 170.
|
||||
const stop0 = env.root.style.getPropertyValue('--mc-rt-ramp-0').toLowerCase();
|
||||
const stop4 = env.root.style.getPropertyValue('--mc-rt-ramp-4').toLowerCase();
|
||||
assert(stop0 === '#222222', 'achromat ramp[0] === #222222 (got ' + stop0 + ')');
|
||||
assert(stop4 === '#eeeeee', 'achromat ramp[4] === #eeeeee (got ' + stop4 + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== #1418 ramp C: applyPreset fires cb-preset-changed event ===');
|
||||
if (MCP) {
|
||||
let fired = false, detailId = null;
|
||||
env.sandbox.addEventListener('cb-preset-changed', function (ev) {
|
||||
fired = true;
|
||||
detailId = ev.detail && ev.detail.id;
|
||||
});
|
||||
MCP.applyPreset('prot');
|
||||
assert(fired === true, 'cb-preset-changed event fired on applyPreset()');
|
||||
assert(detailId === 'prot', 'event detail.id === applied preset id (got ' + detailId + ')');
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink:
|
||||
* - Hop resolution priority (server resolved_path > HopResolver > raw).
|
||||
* - GRP_TXT channel hash → name resolution (enc_ placeholder, SHA-256 byte
|
||||
* match for keyed channels, fallback to "channel 0x<HEX>").
|
||||
*
|
||||
* The deep-link loader is a giant async function; we don't run it end-to-end.
|
||||
* Instead we verify:
|
||||
* 1. Source invariants: priority order is unambiguous in code.
|
||||
* 2. Replica of the chosen-path resolution logic, exercised on fixtures.
|
||||
* 3. Replica of the channel-match predicate (the same `find` callback).
|
||||
* 4. Live SubtleCrypto comparison: SHA-256(name)[0] === target byte
|
||||
* reproduced via node's built-in crypto.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 hop-priority A: source invariants (3-tier priority) ===');
|
||||
// Priority comment is documented; assert the structural keywords are in order.
|
||||
const priorityBlock = mapSrc.match(/Priority:[\s\S]{0,800}rawHops/);
|
||||
assert(!!priorityBlock,
|
||||
'priority block documented in map.js');
|
||||
if (priorityBlock) {
|
||||
const blk = priorityBlock[0];
|
||||
const iResolved = blk.indexOf('resolved_path');
|
||||
const iHopRes = blk.indexOf('HopResolver');
|
||||
const iRaw = blk.indexOf('raw');
|
||||
assert(iResolved >= 0 && iHopRes >= 0 && iRaw >= 0,
|
||||
'priority block mentions all three: resolved_path, HopResolver, raw');
|
||||
assert(iResolved < iHopRes && iHopRes < iRaw,
|
||||
'priority order in comment: resolved_path → HopResolver → raw');
|
||||
}
|
||||
// Structural code path: resolved_path branch checked first, then HopResolver,
|
||||
// then naked rawHops fallback.
|
||||
assert(/if\s*\(\s*Array\.isArray\(resolvedHops\)[^\)]*\)\s*\{[\s\S]{0,200}\}\s*else if\s*\(\s*window\.HopResolver/.test(mapSrc),
|
||||
'code structure: if (resolvedHops valid) else if (window.HopResolver) else (rawHops)');
|
||||
|
||||
console.log('\n=== #1418 hop-priority B: replica of chosen-path selection ===');
|
||||
|
||||
// Replicate the chooseChosenPath logic exactly. window.HopResolver shim
|
||||
// returns a per-pubkey dict; resolveResult[h] is consulted per raw hop.
|
||||
function chooseChosenPath(rawHops, resolvedHopsRaw, hopResolver) {
|
||||
let resolvedHops = null;
|
||||
try {
|
||||
if (resolvedHopsRaw) {
|
||||
resolvedHops = typeof resolvedHopsRaw === 'string' ? JSON.parse(resolvedHopsRaw) : resolvedHopsRaw;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (Array.isArray(resolvedHops) && resolvedHops.length === rawHops.length) {
|
||||
return rawHops.map((h, i) => resolvedHops[i] || h);
|
||||
}
|
||||
if (hopResolver && typeof hopResolver.resolve === 'function' && rawHops.length) {
|
||||
try {
|
||||
const result = hopResolver.resolve(rawHops);
|
||||
return rawHops.map(h => {
|
||||
const r = result ? result[h] : null;
|
||||
return r && r.pubkey ? r.pubkey : h;
|
||||
});
|
||||
} catch (_) { return rawHops; }
|
||||
}
|
||||
return rawHops;
|
||||
}
|
||||
|
||||
const rawHops = ['AA', 'BB', 'CC'];
|
||||
|
||||
// Tier 1: server resolved_path takes priority over HopResolver
|
||||
const serverResolved = ['AAFULL1', 'BBFULL2', 'CCFULL3'];
|
||||
const naiveResolver = { resolve: () => ({ AA: { pubkey: 'WRONG_A' }, BB: { pubkey: 'WRONG_B' }, CC: { pubkey: 'WRONG_C' }}) };
|
||||
let chosen = chooseChosenPath(rawHops, serverResolved, naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path wins over HopResolver (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// Tier 1 with JSON string input (server returns it stringified sometimes)
|
||||
chosen = chooseChosenPath(rawHops, JSON.stringify(serverResolved), naiveResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(serverResolved),
|
||||
'server resolved_path accepts JSON-string input (parses it)');
|
||||
|
||||
// Tier 2: no resolved_path → use HopResolver
|
||||
const smartResolver = { resolve: () => ({ AA: { pubkey: 'AAFULL_DIFF' }, BB: { pubkey: 'BBFULL_DIFF' }, CC: { pubkey: 'CCFULL_DIFF' }}) };
|
||||
chosen = chooseChosenPath(rawHops, null, smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'no resolved_path → HopResolver result used (returns ' + JSON.stringify(chosen) + ')');
|
||||
|
||||
// HopResolver returns different from naive prefix → values change
|
||||
chosen = chooseChosenPath(['AB'], null, { resolve: () => ({ AB: { pubkey: 'ABcorrect123' } }) });
|
||||
assert(chosen[0] === 'ABcorrect123',
|
||||
'HopResolver overrides naive prefix when it returns a longer pubkey');
|
||||
|
||||
// HopResolver throws → fallback to raw
|
||||
chosen = chooseChosenPath(rawHops, null, { resolve: () => { throw new Error('boom'); } });
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'HopResolver throw → fallback to rawHops');
|
||||
|
||||
// Tier 3: no resolved_path, no HopResolver → raw prefixes
|
||||
chosen = chooseChosenPath(rawHops, null, null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(rawHops),
|
||||
'no resolved_path AND no HopResolver → raw prefixes returned as-is');
|
||||
|
||||
// Length mismatch: resolved_path is wrong length → falls through to HopResolver
|
||||
chosen = chooseChosenPath(rawHops, ['only_one'], smartResolver);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL_DIFF', 'BBFULL_DIFF', 'CCFULL_DIFF']),
|
||||
'resolved_path with mismatched length → falls through to HopResolver');
|
||||
|
||||
// Per-element falsy in resolved_path → falls back to raw for THAT index
|
||||
chosen = chooseChosenPath(rawHops, ['AAFULL1', null, 'CCFULL3'], null);
|
||||
assert(JSON.stringify(chosen) === JSON.stringify(['AAFULL1', 'BB', 'CCFULL3']),
|
||||
'per-index null in resolved_path → falls back to raw for that index only');
|
||||
|
||||
console.log('\n=== #1418 channel A: GRP_TXT match predicate (sync part) ===');
|
||||
|
||||
// Replica of the channel-find predicate from loadRouteFromDeepLink.
|
||||
function findChannelSync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
return chList.find(c => {
|
||||
const ch = String(c.hash || '').toUpperCase();
|
||||
const nm = String(c.name || '').toUpperCase();
|
||||
return ch.startsWith(wantUp) ||
|
||||
ch === 'ENC_' + wantUp ||
|
||||
nm.includes('0X' + wantUp);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ hash: 'public_full_hash_AB...', name: 'Public' },
|
||||
{ hash: 'enc_77', name: 'Encrypted (0x77)', encrypted: true },
|
||||
{ hash: 'unknown', name: 'channel 0xCD' }
|
||||
];
|
||||
|
||||
// hash starts with target hex
|
||||
let m = findChannelSync([{ hash: 'AB1234', name: 'Test' }], 'AB');
|
||||
assert(m && m.name === 'Test', 'finds channel where hash starts with target hex');
|
||||
|
||||
// enc_<HEX> placeholder
|
||||
m = findChannelSync(channels, '77');
|
||||
assert(m && m.name === 'Encrypted (0x77)',
|
||||
'matches enc_<HEX> placeholder ("enc_77") for encrypted channel');
|
||||
|
||||
// name contains "0x<HEX>"
|
||||
m = findChannelSync(channels, 'CD');
|
||||
assert(m && m.name === 'channel 0xCD',
|
||||
'matches name containing "0x<HEX>" placeholder');
|
||||
|
||||
// Case-insensitive
|
||||
m = findChannelSync([{ hash: 'enc_ff', name: 'lower' }], 'FF');
|
||||
assert(m && m.name === 'lower', 'case-insensitive match on enc_<HEX>');
|
||||
|
||||
// No match → null (caller falls back to "channel 0x<HEX>")
|
||||
m = findChannelSync(channels, 'XX');
|
||||
assert(m === null, 'no match → null (so caller renders "channel 0x<HEX>" fallback)');
|
||||
|
||||
console.log('\n=== #1418 channel B: SHA-256(name)[0] keyed-channel match ===');
|
||||
|
||||
// The async fallback (SubtleCrypto) computes SHA-256(name)[0] and checks
|
||||
// it against the target byte. Reproduce in node and verify the formula
|
||||
// matches the firmware/decoder convention (first byte of SHA-256).
|
||||
function sha256Byte0(name) {
|
||||
const buf = crypto.createHash('sha256').update(name, 'utf8').digest();
|
||||
return buf[0].toString(16).padStart(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
// Known channel name → its derived byte
|
||||
const wellKnown = ['Public', 'Test Channel', 'mesh-control', 'general'];
|
||||
wellKnown.forEach(name => {
|
||||
const byte = sha256Byte0(name);
|
||||
assert(/^[0-9A-F]{2}$/.test(byte),
|
||||
'SHA-256("' + name + '")[0] = 0x' + byte + ' (valid 2-hex)');
|
||||
});
|
||||
|
||||
// Construct a fixture where we deliberately want to match channel "Public"
|
||||
const target = sha256Byte0('Public');
|
||||
// Simulate the async match loop: walk the channel list, hash each name,
|
||||
// return the one whose first byte === target.
|
||||
function findChannelAsync(chList, wantHex) {
|
||||
const wantUp = String(wantHex).toUpperCase();
|
||||
for (const c of chList) {
|
||||
if (c.encrypted) continue;
|
||||
if (!c.name) continue;
|
||||
if (sha256Byte0(c.name) === wantUp) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = findChannelAsync([
|
||||
{ name: 'Public' },
|
||||
{ name: 'Other' },
|
||||
{ name: 'Public', encrypted: true } // would match but encrypted → skipped
|
||||
], target);
|
||||
assert(result && result.name === 'Public' && !result.encrypted,
|
||||
'SHA-256 match: returns first non-encrypted channel whose name SHA-256[0] === target byte');
|
||||
|
||||
// Source invariants: the async block exists in map.js
|
||||
assert(/window\.crypto\.subtle/.test(mapSrc), 'map.js uses window.crypto.subtle for SHA-256 fallback');
|
||||
assert(/'SHA-256'/.test(mapSrc), 'map.js requests SHA-256 specifically');
|
||||
assert(/if\s*\(c\.encrypted\)\s*continue/.test(mapSrc),
|
||||
'async loop skips already-known encrypted/placeholder channels');
|
||||
assert(/byteHex\s*===\s*wantUp/.test(mapSrc),
|
||||
'async loop compares first-byte hex to target (byteHex === wantUp)');
|
||||
|
||||
console.log('\n=== #1418 channel C: fallback label format ===');
|
||||
// When no match found, caller renders "Encrypted (0x<HEX>)" for encrypted,
|
||||
// "channel 0x<HEX>" otherwise. Just guard the literal templates exist.
|
||||
assert(/Encrypted \(0x/.test(mapSrc),
|
||||
'encrypted-channel fallback label "Encrypted (0x..." present in map.js');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* #1418 — route-view.js edgeWeight() scales + boundary fix.
|
||||
*
|
||||
* Edge-stroke-width logic (route-view.js `edgeWeight()`):
|
||||
* - Single-path mode → flat 5
|
||||
* - Multi-path interior edge → 3 + ratio*6 (range 3..9)
|
||||
* - Multi-path BOUNDARY edge (origin→hop1 or last-hop→dest) → proxy via
|
||||
* max adjacent edgeCount. Before the recent fix, boundary edges with no
|
||||
* matching prefix returned 1.5 (the floor for unknown interior edges),
|
||||
* visually shrinking origin/dest edges to hairlines.
|
||||
* - Union-of-edges view (in isolatePath/restoreAllPaths) → 2 + ratio*6
|
||||
* (range 2..8).
|
||||
*
|
||||
* Strategy: extract the edgeWeight() function from route-view.js with regex,
|
||||
* eval it into a sandbox seeded with `positions` + `edgeCounts` + `multiPath`
|
||||
* + `totalObservers`, and assert on returns. This exercises the SHIPPING
|
||||
* function — if route-view.js drifts, the test breaks.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight A: source invariants ===');
|
||||
assert(/function\s+edgeWeight\s*\(\s*idx\s*\)/.test(src),
|
||||
'edgeWeight(idx) function exists in route-view.js');
|
||||
assert(/if\s*\(!multiPath\)\s+return\s+5/.test(src),
|
||||
'single-path mode returns flat 5');
|
||||
// Boundary fix invariant: an isOriginEdge / isDestEdge code path exists
|
||||
// and computes a proxy from max adjacent count instead of returning 1.5.
|
||||
assert(/isOriginEdge\s*\|\|\s*isDestEdge/.test(src),
|
||||
'boundary-edge branch present (isOriginEdge || isDestEdge)');
|
||||
assert(/3\s*\+\s*bRatio\s*\*\s*6/.test(src),
|
||||
'boundary branch uses 3 + bRatio*6 scale (not 1.5)');
|
||||
assert(/3\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'interior multi-path uses 3 + ratio*6 (range 3..9)');
|
||||
assert(/2\s*\+\s*ratio\s*\*\s*6/.test(src),
|
||||
'union/isolate view uses 2 + ratio*6 (range 2..8)');
|
||||
|
||||
console.log('\n=== #1418 edgeWeight B: extract + exercise the real function ===');
|
||||
|
||||
// Extract the edgeWeight function body verbatim. The function is declared
|
||||
// inside the IIFE; we regex it out and run it in a sandbox with the closure
|
||||
// variables it expects (positions, edgeCounts, multiPath, totalObservers).
|
||||
const fnMatch = src.match(/function\s+edgeWeight\s*\(\s*idx\s*\)\s*\{[\s\S]*?\n {4}\}/);
|
||||
assert(!!fnMatch, 'edgeWeight() function body extracted from route-view.js');
|
||||
|
||||
function runEdgeWeight(positions, edgeCounts, totalObservers, multiPath, idx) {
|
||||
const ctx = { positions, edgeCounts, totalObservers, multiPath };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fnMatch[0] + '; result = edgeWeight(' + idx + ');', ctx);
|
||||
return ctx.result;
|
||||
}
|
||||
|
||||
// --- Single-path mode: always 5 ---
|
||||
const singlePos = [
|
||||
{ pubkey: 'AABB', isOrigin: true },
|
||||
{ pubkey: 'CCDD' },
|
||||
{ pubkey: 'EEFF', isDest: true }
|
||||
];
|
||||
assert(runEdgeWeight(singlePos, {}, 1, false, 0) === 5,
|
||||
'single-path mode: edgeWeight(0) === 5');
|
||||
assert(runEdgeWeight(singlePos, { 'AA→CC': 99 }, 50, false, 1) === 5,
|
||||
'single-path mode: edgeWeight(1) === 5 regardless of edgeCounts');
|
||||
|
||||
// --- Multi-path INTERIOR edge: 3 + ratio*6 ---
|
||||
const mPos = [
|
||||
{ pubkey: 'AABB', isOrigin: true }, // origin
|
||||
{ pubkey: 'CCDD' }, // hop 1 (interior start)
|
||||
{ pubkey: 'EEFF' }, // hop 2 (interior end)
|
||||
{ pubkey: 'GG00', isDest: true } // dest
|
||||
];
|
||||
// Edge 1: CC→EE. edgeCounts has CC→EE: 5 of 10 observers → ratio 0.5
|
||||
// expected = 3 + 0.5*6 = 6
|
||||
let w = runEdgeWeight(mPos, { 'CC→EE': 5 }, 10, true, 1);
|
||||
assert(Math.abs(w - 6) < 0.001,
|
||||
'multi-path interior: ratio 0.5 → weight 6 (got ' + w + ')');
|
||||
// Full coverage: ratio 1.0 → weight 9
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 10 }, 10, true, 1);
|
||||
assert(Math.abs(w - 9) < 0.001,
|
||||
'multi-path interior: ratio 1.0 → weight 9 (got ' + w + ')');
|
||||
// No matching count: falls through to 1.5 floor
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 1);
|
||||
assert(w === 1.5,
|
||||
'multi-path interior: no matching edge → 1.5 hairline floor (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: origin→hop1 ---
|
||||
// idx=0: AA(isOrigin) → CC. edgeCounts has CC→EE: 8 of 10
|
||||
// Boundary proxy: look for edges where a==CC (the next-to-boundary node)
|
||||
// 8/10 → weight = 3 + 0.8*6 = 7.8
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 8 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.8) < 0.001,
|
||||
'boundary edge (origin→hop1): proxied by adjacent CC→EE count 8/10 → 7.8 (got ' + w + ')');
|
||||
|
||||
// --- BOUNDARY edge fix: last-hop→dest ---
|
||||
// idx=2: EE → GG(isDest). Look for edges where b==EE (the from-boundary node)
|
||||
// edgeCounts CC→EE: 7 of 10 → 3 + 0.7*6 = 7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 7 }, 10, true, 2);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge (last-hop→dest): proxied by adjacent CC→EE count 7/10 → 7.2 (got ' + w + ')');
|
||||
|
||||
// --- REGRESSION GUARD: boundary edge with NO adjacent edgeCount must NOT
|
||||
// return 1.5 (the old bug). It returns 5 as the documented fallback. ---
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 0);
|
||||
assert(w === 5,
|
||||
'boundary edge with no adjacent edgeCount returns 5 (NOT the old 1.5 bug) — got ' + w);
|
||||
w = runEdgeWeight(mPos, { 'XX→YY': 5 }, 10, true, 2);
|
||||
assert(w === 5,
|
||||
'boundary edge (last-hop→dest) with no adjacent count → 5 (NOT 1.5) — got ' + w);
|
||||
|
||||
// --- Multiple matching adjacent edges: use MAX, not sum ---
|
||||
// idx=0: AA(origin)→CC. edgeCounts has CC→EE:3 and CC→FF:7. Max is 7 → 3+0.7*6=7.2
|
||||
w = runEdgeWeight(mPos, { 'CC→EE': 3, 'CC→FF': 7 }, 10, true, 0);
|
||||
assert(Math.abs(w - 7.2) < 0.001,
|
||||
'boundary edge: picks MAX adjacent count (max of 3,7 = 7 → 7.2) — got ' + w);
|
||||
|
||||
console.log('\n=== #1418 edgeWeight C: isolated-path union weight (2 + ratio*6) ===');
|
||||
// The 2+ratio*6 formula is in the isolatePath() block. Source-grep guarantees
|
||||
// its presence. Verify the literal expression is unique (not stripped).
|
||||
const occurrences2 = (src.match(/2\s*\+\s*ratio\s*\*\s*6/g) || []).length;
|
||||
assert(occurrences2 >= 1, 'isolatePath union weight formula (2 + ratio*6) present at least once');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* #1418 / PR #1423 polish-review guards.
|
||||
*
|
||||
* Source-grep guards for the polish-review findings addressed on the
|
||||
* route-view feature branch. Each guard pins one finding so future edits
|
||||
* can't silently regress the fix.
|
||||
*
|
||||
* Findings covered (see PR #1423 review comments for full context):
|
||||
* - resize listener leak (carmack/munger)
|
||||
* - 5-staggered-timer fit storm (tufte/doshi)
|
||||
* - empty catch {} swallowing errors (torvalds)
|
||||
* - _detailCache unbounded (carmack) — LRU(50)
|
||||
* - recolorRoute walks document.querySelectorAll (torvalds) — scoped
|
||||
* - deep-link silent failure (doshi) — toast on empty paths
|
||||
* - innerHTML row re-wire factored (dijkstra) — wireRow helper
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const rvSrc = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== A. resize listener leak fix (carmack/munger) ===');
|
||||
// Single resize listener attached via window.__mc_routeResizeRefit stash,
|
||||
// torn down on next render() + on teardownIfNavigatedAway.
|
||||
assert(/window\.__mc_routeResizeRefit/.test(rvSrc),
|
||||
'resize handler stashed on window.__mc_routeResizeRefit for dedupe');
|
||||
assert(/removeEventListener\(['"]resize['"],\s*window\.__mc_routeResizeRefit\)/.test(rvSrc),
|
||||
'prior resize handler removed before attaching new one');
|
||||
// Old buggy pattern (anonymous resize listener with no removal) must be gone.
|
||||
const anonResize = rvSrc.match(/window\.addEventListener\(['"]resize['"]\s*,\s*function/g) || [];
|
||||
assert(anonResize.length === 0,
|
||||
'no anonymous window.resize listeners (all go via __mc_routeResizeRefit) — found ' + anonResize.length);
|
||||
|
||||
console.log('\n=== B. fit-storm collapse to rAF (tufte/doshi) ===');
|
||||
// The 5-staggered (0/300/800/1600/2800) and 3-staggered (0/200/600/1400)
|
||||
// timers MUST be gone. Single requestAnimationFrame is the replacement.
|
||||
const bigFitStorm = /setTimeout\(\s*refit\s*,\s*(?:300|800|1600|2800)\s*\)/.test(rvSrc);
|
||||
assert(!bigFitStorm, 'no setTimeout(refit, 300|800|1600|2800) staggered fit storm');
|
||||
const isoFitStorm = /setTimeout\(\s*doFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!isoFitStorm, 'no setTimeout(doFit, 200|600|1400) staggered isolate-fit storm');
|
||||
const restoreFitStorm = /setTimeout\(\s*_restoreFit\s*,\s*(?:200|600|1400)\s*\)/.test(rvSrc);
|
||||
assert(!restoreFitStorm, 'no setTimeout(_restoreFit, 200|600|1400) staggered restore-fit storm');
|
||||
assert(/requestAnimationFrame\(\s*refit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(refit) is the new initial-settle path');
|
||||
assert(/requestAnimationFrame\(\s*doFit\s*\)/.test(rvSrc),
|
||||
'requestAnimationFrame(doFit) replaces isolate-path staggered timers');
|
||||
assert(/new ResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver attached to map container for layout-settle re-fit');
|
||||
|
||||
console.log('\n=== C. ResizeObserver lifecycle (carmack) ===');
|
||||
assert(/window\.__mc_routeResizeObserver/.test(rvSrc),
|
||||
'ResizeObserver stashed on window.__mc_routeResizeObserver for dedupe');
|
||||
assert(/__mc_routeResizeObserver[^;]*\.disconnect\(\)/.test(rvSrc),
|
||||
'ResizeObserver disconnected on render() re-entry + teardown');
|
||||
|
||||
console.log('\n=== D. _detailCache LRU bound (carmack) ===');
|
||||
assert(/_detailCache\s*=\s*new\s+Map\(\)/.test(rvSrc),
|
||||
'_detailCache is a Map (LRU-capable) not a plain object');
|
||||
assert(/DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'DETAIL_CACHE_MAX constant defined (LRU bound)');
|
||||
assert(/_detailCache\.size\s*>=?\s*DETAIL_CACHE_MAX/.test(rvSrc),
|
||||
'LRU eviction guard checks _detailCache.size against DETAIL_CACHE_MAX');
|
||||
|
||||
console.log('\n=== E. catch {} silent swallow → console.warn (torvalds) ===');
|
||||
// Empty `catch (e) {}` (no body) count should be near zero. A handful may
|
||||
// remain where the catch is genuinely a "best-effort" no-op — but the
|
||||
// review flagged 20+ silent swallows; we should be down to ≤5 after the pass.
|
||||
// Empty `catch (e) {}` (no body) count for full-block catches (e). The
|
||||
// inline `} catch (_) {}` no-op removers are intentional (marker may
|
||||
// already be detached). The review flagged 20+ silent block swallows;
|
||||
// after the pass the remaining ones must be legitimately benign
|
||||
// (localStorage may be disabled, marker may have been removed in a race).
|
||||
const blockEmptyCatches = (rvSrc.match(/\}\s*catch\s*\(\s*e\s*\)\s*\{\s*\}/g) || []).length;
|
||||
assert(blockEmptyCatches <= 8,
|
||||
'block-style silent `} catch (e) {}` reduced to ≤8 (was 20+) — current: ' + blockEmptyCatches);
|
||||
assert(/console\.warn\(['"]\[route-view\]/.test(rvSrc),
|
||||
'at least one [route-view] console.warn breadcrumb present');
|
||||
|
||||
console.log('\n=== F. recolorRoute scoped to sidebar (torvalds) ===');
|
||||
// The walks must be scoped to the active sidebar root, not document-wide.
|
||||
// We allow document.querySelectorAll for `.mc-rt-sidebar` (the tear-down)
|
||||
// but NOT for `.mc-rt-edge` / `.mc-rt-row` / `.mc-rt-spark-dot`.
|
||||
const docEdges = /document\.querySelectorAll\(['"]\.mc-rt-edge['"]\)/.test(rvSrc);
|
||||
assert(!docEdges, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-edge)');
|
||||
const docRows = /document\.querySelectorAll\(['"]\.mc-rt-row['"]\)/.test(rvSrc);
|
||||
assert(!docRows, 'recolorRoute no longer walks document.querySelectorAll(.mc-rt-row)');
|
||||
|
||||
console.log('\n=== G. deep-link empty-paths toast (doshi) ===');
|
||||
// When allPaths.length === 0, surface a sidebar/console message instead of
|
||||
// silently bailing.
|
||||
assert(/allPaths\.length\s*===\s*0[\s\S]{0,400}(?:console\.warn|alert|toast|showToast|notif)/i.test(mapSrc),
|
||||
'deep-link empty-paths path emits a console.warn / toast (no silent return)');
|
||||
|
||||
console.log('\n=== H. wireRow row-wireup helper (dijkstra) ===');
|
||||
assert(/function\s+wireRow\s*\(\s*row\s*\)/.test(rvSrc),
|
||||
'wireRow(row) helper centralizes row event wiring');
|
||||
assert(/sidebar\._wireRow\s*=\s*wireRow/.test(rvSrc),
|
||||
'wireRow stashed on sidebar so restoreAllPaths can reuse');
|
||||
assert(/newRowEls\.forEach\(\s*sidebar\._wireRow/.test(rvSrc),
|
||||
'restoreAllPaths re-wires rows via sidebar._wireRow (not inline duplicate)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* #1418 — map.js loadRouteFromDeepLink raw_hex byte extraction.
|
||||
*
|
||||
* The deep-link loader peeks at chosen.raw_hex when decoded JSON is empty,
|
||||
* to extract src/destHash and (for GRP_TXT) channel_hash. Wire layout per
|
||||
* cmd/ingestor/decoder.go:
|
||||
* byte0=route+type, byte1=path_len, then path bytes, then ...
|
||||
*
|
||||
* TXT_MSG (type 2): destHash + srcHash bytes after path
|
||||
* RESPONSE (type 1): destHash + srcHash bytes after path
|
||||
* ANON_REQ (type 7): destHash ONLY (no srcHash byte — sender anonymous)
|
||||
* PATH (type 8): destHash + srcHash bytes after path
|
||||
* GRP_TXT (type 5): channel_hash byte after path
|
||||
*
|
||||
* This test asserts behavior by replicating the exact extraction logic
|
||||
* from public/map.js and exercising it on hand-built raw_hex fixtures
|
||||
* built to mirror real wire packets.
|
||||
*
|
||||
* Source invariants (string grep on map.js) also guarded so any code-move
|
||||
* that drops the extraction is caught.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 raw_hex A: source invariants in map.js ===');
|
||||
assert(/TYPES_WITH_DST_SRC\s*=\s*\[\s*1\s*,\s*2\s*,\s*7\s*,\s*8\s*\]/.test(mapSrc),
|
||||
'TYPES_WITH_DST_SRC = [1, 2, 7, 8] (RESPONSE, TXT_MSG, ANON_REQ, PATH)');
|
||||
assert(/payload_type\s*!==\s*7/.test(mapSrc),
|
||||
'ANON_REQ (type 7) special-cased to skip srcHash extraction');
|
||||
assert(/payload_type\s*===\s*5/.test(mapSrc),
|
||||
'GRP_TXT (type 5) branch present for channel_hash extraction');
|
||||
assert(/PAYLOAD_TYPE_MAP\s*=\s*\{[^}]*0:\s*'REQ'[^}]*1:\s*'RESPONSE'[^}]*2:\s*'TXT_MSG'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 0=REQ, 1=RESPONSE, 2=TXT_MSG');
|
||||
assert(/5:\s*'GRP_TXT'[^}]*7:\s*'ANON_REQ'[^}]*8:\s*'PATH'/m.test(mapSrc),
|
||||
'PAYLOAD_TYPE_MAP covers 5=GRP_TXT, 7=ANON_REQ, 8=PATH');
|
||||
|
||||
// Polish review (djb): pathLen MUST be bounded before slicing. A crafted
|
||||
// pathLen=200 byte would surface random body bytes as srcHash/destHash.
|
||||
// Cap at MeshCore wire max of 64 hops in BOTH the TXT-family branch and
|
||||
// the GRP_TXT channel-hash branch.
|
||||
assert((mapSrc.match(/pathLen[^>]*>\s*64/g) || []).length >= 2,
|
||||
'raw_hex pathLen capped at >64 in both TXT and GRP_TXT branches (#1423 review/djb)');
|
||||
assert(/Number\.isFinite\(pathLen\)/.test(mapSrc),
|
||||
'raw_hex pathLen guarded with Number.isFinite (rejects NaN from non-hex byte)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex B: replica extractor reproduces map.js logic ===');
|
||||
|
||||
// Pure replica of the extractor inside loadRouteFromDeepLink. If map.js's
|
||||
// logic changes, this replica MUST be updated and the diff explained.
|
||||
function extractSrcDst(rawHex, payloadType) {
|
||||
const TYPES = [1, 2, 7, 8];
|
||||
if (TYPES.indexOf(payloadType) < 0) return { src: null, dst: null };
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) {
|
||||
return { src: null, dst: null };
|
||||
}
|
||||
const destOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < destOff + 2) return { src: null, dst: null };
|
||||
const dst = rawHex.slice(destOff, destOff + 2).toUpperCase();
|
||||
let src = null;
|
||||
if (payloadType !== 7 && rawHex.length >= destOff + 4) {
|
||||
src = rawHex.slice(destOff + 2, destOff + 4).toUpperCase();
|
||||
}
|
||||
return { src, dst };
|
||||
} catch (_) { return { src: null, dst: null }; }
|
||||
}
|
||||
|
||||
function extractChannelHash(rawHex, payloadType) {
|
||||
if (payloadType !== 5) return null;
|
||||
try {
|
||||
const pathLen = parseInt(rawHex.slice(2, 4), 16);
|
||||
if (!Number.isFinite(pathLen) || pathLen < 0 || pathLen > 64) return null;
|
||||
const chOff = 4 + pathLen * 2;
|
||||
if (rawHex.length < chOff + 2) return null;
|
||||
return rawHex.slice(chOff, chOff + 2).toUpperCase();
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// Build a hex string: route+type byte, path_len, path bytes, then payload.
|
||||
function build(routeType, pathBytes, payloadBytes) {
|
||||
const lenHex = pathBytes.length.toString(16).padStart(2, '0');
|
||||
return routeType + lenHex + pathBytes.join('') + payloadBytes.join('');
|
||||
}
|
||||
|
||||
// Fixture 1: TXT_MSG (type 2), 2 path hops AB,CD, destHash=42, srcHash=99
|
||||
const txt = build('02', ['AB', 'CD'], ['42', '99', 'FF', 'EE']);
|
||||
let r = extractSrcDst(txt, 2);
|
||||
assert(r.dst === '42' && r.src === '99',
|
||||
'TXT_MSG (type 2) extracts destHash=42, srcHash=99 after 2-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 2: RESPONSE (type 1), 0-hop path
|
||||
const resp = build('01', [], ['7A', '3C']);
|
||||
r = extractSrcDst(resp, 1);
|
||||
assert(r.dst === '7A' && r.src === '3C',
|
||||
'RESPONSE (type 1) extracts destHash + srcHash on 0-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 3: ANON_REQ (type 7) — destHash present, srcHash MUST be null
|
||||
const anon = build('07', ['11'], ['DD', 'BB', 'CC']);
|
||||
r = extractSrcDst(anon, 7);
|
||||
assert(r.dst === 'DD', 'ANON_REQ (type 7) extracts destHash=DD');
|
||||
assert(r.src === null, 'ANON_REQ (type 7) MUST NOT extract srcHash (anonymous sender) — got ' + r.src);
|
||||
|
||||
// Fixture 4: PATH (type 8) carries both hashes
|
||||
const pathPkt = build('08', ['AA', 'BB', 'CC'], ['11', '22']);
|
||||
r = extractSrcDst(pathPkt, 8);
|
||||
assert(r.dst === '11' && r.src === '22',
|
||||
'PATH (type 8) extracts destHash + srcHash after 3-hop path (got dst=' + r.dst + ', src=' + r.src + ')');
|
||||
|
||||
// Fixture 5: GRP_TXT (type 5) — channel_hash extraction, NOT src/dst
|
||||
const grp = build('05', ['77'], ['AB', 'XX']);
|
||||
const ch = extractChannelHash(grp, 5);
|
||||
assert(ch === 'AB', 'GRP_TXT (type 5) extracts channel_hash=AB after 1-hop path (got ' + ch + ')');
|
||||
r = extractSrcDst(grp, 5);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'GRP_TXT (type 5) is NOT in TYPES_WITH_DST_SRC — extractor returns nulls');
|
||||
|
||||
// Fixture 6: non-extracting types (REQ=0, ACK=3, ADVERT=4, MULTIPART=10, …)
|
||||
[0, 3, 4, 6, 9, 10, 11, 12].forEach(function (pt) {
|
||||
r = extractSrcDst('00' + '00' + 'FFFF', pt);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'payload_type=' + pt + ' (not in TYPES_WITH_DST_SRC) → no extraction');
|
||||
});
|
||||
|
||||
// Edge case: raw_hex too short (path length claims more bytes than present)
|
||||
r = extractSrcDst('02' + '04' + 'AB', 2); // claims 4-hop path, only 1 byte payload
|
||||
assert(r.src === null && r.dst === null, 'truncated raw_hex → null extraction (no crash)');
|
||||
|
||||
// Polish review (djb): malicious pathLen=200 (0xC8) MUST be rejected even
|
||||
// when the body is long enough to slice. Without the cap, the extractor
|
||||
// would surface random body bytes as src/destHash strings in the UI.
|
||||
const evil = '02' + 'C8' + 'AB'.repeat(500); // pathLen=200, plenty of body to slice
|
||||
r = extractSrcDst(evil, 2);
|
||||
assert(r.src === null && r.dst === null,
|
||||
'malicious pathLen=200 → rejected, no OOB-style byte surfacing');
|
||||
const evilCh = extractChannelHash('05' + 'C8' + 'AB'.repeat(500), 5);
|
||||
assert(evilCh === null, 'malicious pathLen=200 (GRP_TXT) → rejected');
|
||||
// Boundary: pathLen=64 (max) still works; 65 rejected.
|
||||
const okBig = '02' + '40' + 'AB'.repeat(64) + 'EE' + 'FF';
|
||||
r = extractSrcDst(okBig, 2);
|
||||
assert(r.dst === 'EE' && r.src === 'FF', 'pathLen=64 (max allowed) still extracts');
|
||||
const tooBig = '02' + '41' + 'AB'.repeat(65) + 'EE' + 'FF';
|
||||
r = extractSrcDst(tooBig, 2);
|
||||
assert(r.src === null && r.dst === null, 'pathLen=65 → rejected (above wire max of 64)');
|
||||
|
||||
console.log('\n=== #1418 raw_hex C: channel_hash NOT extracted for non-GRP_TXT ===');
|
||||
[0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12].forEach(function (pt) {
|
||||
const v = extractChannelHash('05' + '00' + 'AB', pt);
|
||||
assert(v === null, 'payload_type=' + pt + ' returns null channel_hash');
|
||||
});
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* #1418 — route-view.js spider-fan collision logic + loop marker invariants.
|
||||
*
|
||||
* Spider-fan rules (per route-view.js Tufte v7):
|
||||
* - Pixel-distance threshold: COLLISION_THRESHOLD = 14px. Markers within
|
||||
* 14px of each other are grouped and fanned onto an arc of R = 16px.
|
||||
* - Markers further apart than 14px are NOT fanned (no group, kept put).
|
||||
* - Each marker's original LatLng is cached in mk._origLatLng so repeated
|
||||
* re-fan passes (zoom changes, re-render) don't drift.
|
||||
* - Loop case (SRC.pubkey === DST.pubkey, same physical node) → endpoint
|
||||
* markers built with isLoop=true → bigger SVG (size 28) + double
|
||||
* concentric ring (r=10 and r=13 stroke-only circles).
|
||||
*
|
||||
* Strategy: replicate the grouping algorithm verbatim from the IIFE,
|
||||
* apply it to synthetic pixel coordinates, and assert grouping decisions.
|
||||
* Then exercise buildMarkerSVG() by extracting it and checking the loop-
|
||||
* specific SVG markup.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public', 'route-view.js'), 'utf8');
|
||||
|
||||
console.log('\n=== #1418 spider A: source invariants ===');
|
||||
assert(/COLLISION_THRESHOLD\s*=\s*14/.test(src),
|
||||
'COLLISION_THRESHOLD === 14 (pixel proximity for fan trigger)');
|
||||
assert(/var\s+R\s*=\s*16/.test(src),
|
||||
'fan radius R === 16 px (arc that markers offset onto)');
|
||||
assert(/_origLatLng/.test(src),
|
||||
'_origLatLng cache field present (prevents drift on repeated fans)');
|
||||
assert(/if\s*\(!mk\._origLatLng\)\s*mk\._origLatLng\s*=\s*ll/.test(src),
|
||||
'_origLatLng written only ONCE (idempotent — repeated fans use cached origin)');
|
||||
assert(/srcDstSameNode/.test(src),
|
||||
'srcDstSameNode detection (loop case) present');
|
||||
assert(/isLoop:\s*isLoop/.test(src) || /isLoop:\s*srcDstSameNode/.test(src),
|
||||
'isLoop flag passed into buildMarkerSVG for endpoint markers');
|
||||
assert(/if\s*\(isLoop\)\s*size\s*=\s*28/.test(src),
|
||||
'isLoop marker grows to size 28 (vs default 22)');
|
||||
// Two stroke circles for loop endpoints (r=10 endpoint ring + r=13 outer)
|
||||
assert(/r="10"[^>]*fill="none"/.test(src) && /r="13"[^>]*fill="none"/.test(src),
|
||||
'loop markers render double concentric ring (r=10 endpoint + r=13 outer)');
|
||||
|
||||
console.log('\n=== #1418 spider B: replicate grouping logic ===');
|
||||
|
||||
// Verbatim grouping algorithm from spiderFanFor() in route-view.js.
|
||||
// Inputs: array of { x, y } pixel points. Output: array of groups (arrays
|
||||
// of point objects); singletons are NOT returned (matches "if (group.length > 1)").
|
||||
function groupCollisions(pts, threshold) {
|
||||
const visited = {};
|
||||
const groups = [];
|
||||
pts.forEach(function (a, ai) {
|
||||
if (visited[ai]) return;
|
||||
const group = [a];
|
||||
visited[ai] = true;
|
||||
pts.forEach(function (b, bi) {
|
||||
if (bi === ai || visited[bi]) return;
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
if (Math.sqrt(dx*dx + dy*dy) < threshold) { group.push(b); visited[bi] = true; }
|
||||
});
|
||||
if (group.length > 1) groups.push(group);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Case 1: two markers 10px apart → grouped (within 14px)
|
||||
let g = groupCollisions([{x:100,y:100},{x:108,y:106}], 14);
|
||||
assert(g.length === 1 && g[0].length === 2,
|
||||
'two markers 10px apart → one group of 2');
|
||||
|
||||
// Case 2: two markers 50px apart → NOT grouped
|
||||
g = groupCollisions([{x:100,y:100},{x:150,y:100}], 14);
|
||||
assert(g.length === 0,
|
||||
'two markers 50px apart → no group (no fan)');
|
||||
|
||||
// Case 3: three markers, two overlap (3px) and one 30px away
|
||||
g = groupCollisions([{x:100,y:100},{x:101,y:103},{x:130,y:100}], 14);
|
||||
assert(g.length === 1 && g[0].length === 2,
|
||||
'three markers: two close + one far → one group of 2 (singleton excluded)');
|
||||
|
||||
// Case 4: exactly at threshold (14px). Spec uses strict-less-than → NOT grouped.
|
||||
g = groupCollisions([{x:0,y:0},{x:14,y:0}], 14);
|
||||
assert(g.length === 0, 'exactly 14px apart → NOT grouped (strict < threshold)');
|
||||
|
||||
// Case 5: cluster of 4 within 14px each → single group of 4
|
||||
g = groupCollisions([{x:100,y:100},{x:102,y:101},{x:99,y:104},{x:101,y:99}], 14);
|
||||
assert(g.length === 1 && g[0].length === 4,
|
||||
'cluster of 4 within threshold → single group of 4');
|
||||
|
||||
console.log('\n=== #1418 spider C: extract buildMarkerSVG + verify loop output ===');
|
||||
|
||||
const fnMatch = src.match(/function\s+buildMarkerSVG\s*\(\s*p\s*,\s*opts\s*\)\s*\{[\s\S]*?\n {2}\}\n/);
|
||||
assert(!!fnMatch, 'buildMarkerSVG() function body extracted');
|
||||
|
||||
if (fnMatch) {
|
||||
const ctx = {};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fnMatch[0], ctx);
|
||||
// Normal endpoint (origin, not loop)
|
||||
const normal = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: false });
|
||||
assert(normal.size === 22, 'non-loop endpoint marker size === 22 (got ' + normal.size + ')');
|
||||
assert(/r="10"[^>]*fill="none"/.test(normal.html), 'non-loop endpoint has the r=10 single ring');
|
||||
assert(!/r="13"/.test(normal.html), 'non-loop endpoint does NOT have the r=13 outer ring');
|
||||
|
||||
// Loop endpoint
|
||||
const loop = ctx.buildMarkerSVG({ isOrigin: true, resolved: true }, { color: '#3b82f6', seqNum: 1, isLoop: true });
|
||||
assert(loop.size === 28, 'loop marker size === 28 (got ' + loop.size + ')');
|
||||
assert(/r="10"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=10 inner ring');
|
||||
assert(/r="13"[^>]*fill="none"/.test(loop.html), 'loop endpoint has the r=13 outer ring');
|
||||
// SVG viewBox should match the larger size
|
||||
assert(/viewBox="0 0 28 28"/.test(loop.html), 'loop marker SVG viewBox is 0 0 28 28');
|
||||
|
||||
// Interior (non-endpoint) marker — no ring
|
||||
const inner = ctx.buildMarkerSVG({ resolved: true }, { color: '#3b82f6', seqNum: 2, isLoop: false });
|
||||
assert(!/r="10"[^>]*fill="none"/.test(inner.html),
|
||||
'interior marker has NO endpoint ring (only main 8px filled circle)');
|
||||
|
||||
// Unresolved hop renders dashed muted circle
|
||||
const unres = ctx.buildMarkerSVG({ resolved: false }, { color: '#3b82f6', seqNum: 3 });
|
||||
assert(/stroke-dasharray="2 2"/.test(unres.html),
|
||||
'unresolved hop rendered with stroke-dasharray="2 2"');
|
||||
}
|
||||
|
||||
console.log('\n=== #1418 spider D: srcDstSameNode loop detection invariants ===');
|
||||
// The detection condition must be lowercase-compared (route-view does
|
||||
// String(...).toLowerCase() === String(...).toLowerCase()) so AaBb === aabb.
|
||||
assert(/positions\[0\]\.pubkey[\s\S]{0,80}positions\[positions\.length-1\]\.pubkey/.test(src),
|
||||
'srcDstSameNode compares positions[0].pubkey vs positions[last].pubkey');
|
||||
assert(/toLowerCase\(\)\s*===\s*String\(positions\[positions\.length-1\]\.pubkey\)\.toLowerCase\(\)/.test(src),
|
||||
'pubkey loop-equality is case-insensitive (toLowerCase on both sides)');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(' passed: ' + passed);
|
||||
console.log(' failed: ' + failed);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user