diff --git a/public/hop-display.js b/public/hop-display.js index 1f775194..7e02a9e7 100644 --- a/public/hop-display.js +++ b/public/hop-display.js @@ -87,7 +87,9 @@ window.HopDisplay = (function() { const unreliableBadge = unreliable ? ' ' : ''; - const warnBadge = conflictBadge + unreliableBadge; + // #2097 — opts.badge === false renders the name without its badge, for the + // packets list, which summarises per path instead of per hop. + const warnBadge = opts.badge === false ? '' : (conflictBadge + unreliableBadge); const cls = [ 'hop', @@ -97,10 +99,16 @@ window.HopDisplay = (function() { globalFallback ? 'hop-global-fallback' : '', ].filter(Boolean).join(' '); - if (opts.link !== false) { - return `${display}${warnBadge}`; - } - return `${display}${warnBadge}`; + const pill = opts.link !== false + ? `${display}` + : `${display}`; + + // #2097 — a badge is a sibling of its pill, so in "A [8] → B [6]" nothing + // said which name the 8 belonged to. Wrapping the pair keeps them together + // and puts the separator clearly outside. Only when there is a badge: + // wrapping every hop would change the layout of every path for nothing. + if (!warnBadge) return pill; + return `${pill}${warnBadge}`; } /** diff --git a/public/nodes.js b/public/nodes.js index 0af9abab..a4dc1fd5 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -1004,7 +1004,7 @@ if (window.HopDisplay) { const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable }; const html = HopDisplay.renderHop(h.prefix, entry); - return isThis ? html.replace('class="', 'class="hop-current ') : html; + return isThis ? html.replace('class="hop ', 'class="hop-current hop ') : html; } const name = escapeHtml(h.name || h.prefix); const link = h.pubkey ? `${name}` : `${name}`; diff --git a/public/packets.js b/public/packets.js index 57ed04b9..3d7edc6e 100644 --- a/public/packets.js +++ b/public/packets.js @@ -981,10 +981,16 @@ api('/observers', { ttl: 60000 }), api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })), ]); + const obsList = obsData.observers || obsData || []; HopResolver.init(nodeData.nodes || [], { - observers: obsData.observers || obsData || [], + observers: obsList, iataCoords: coordData.coords || {}, }); + // #2097 — seed the lookup resolveHops() needs for the anchor, in case a + // render gets here before loadObservers() has run. + if (!observerMap || !observerMap.size) { + observerMap = new Map(obsList.map(o => [o.id, o])); + } } catch (e) { // Non-fatal: hops will render as unresolved hex prefixes until a later // call succeeds. Log so a paginated /api/nodes failure isn't silent. @@ -994,14 +1000,58 @@ } // Resolve hop hex prefixes to node names (cached, client-side) - async function resolveHops(hops) { - const unknown = hops.filter(h => !(h in hopNameCache)); - if (unknown.length) { - await ensureHopResolver(); - const resolved = HopResolver.resolve(unknown); - Object.assign(hopNameCache, resolved || {}); - // Cache misses as null so we don't re-query - unknown.forEach(h => { if (!(h in hopNameCache)) hopNameCache[h] = null; }); + // #2097 — the cache key carries the observer, because an ambiguous hop + // resolves differently depending on who heard it. renderHop() has always + // looked for this key; nothing ever wrote it. + // #2097 — the observer's own position, used as the anchor at the receiving + // end of the path. The IATA route is dead weight: measured on the live + // deployment, none of the 42 observers has its code in /api/iata-coords, so + // nodeInRegion() always returns null. lat/lon is reported directly and works. + function observerPosition(observerId) { + const o = observerId && observerMap ? observerMap.get(observerId) : null; + const lat = o && Number.isFinite(Number(o.lat)) ? Number(o.lat) : null; + const lon = o && Number.isFinite(Number(o.lon)) ? Number(o.lon) : null; + return (lat === null || lon === null) ? [null, null] : [lat, lon]; + } + + function hopCacheKey(h, observerId) { + return observerId ? h + ':' + observerId : h; + } + + // #2097 — resolve WITH the observer. Every 1-byte prefix on the network is + // shared by several nodes, and the observer is what lets HopResolver filter + // candidates by region and report the rest as conflicts. Called with the hops + // alone, it returns the first candidate with no signal that it guessed, which + // is how a repeater 126 km outside the observer's region ended up displayed + // as a certainty. + async function resolveHops(hops, observerId) { + const unknown = hops.filter(h => !(hopCacheKey(h, observerId) in hopNameCache)); + if (!unknown.length) return; + await ensureHopResolver(); + const [obsLat, obsLon] = observerPosition(observerId); + const resolved = HopResolver.resolve(unknown, null, null, obsLat, obsLon, observerId) || {}; + for (const h of unknown) { + const entry = resolved[h] || null; + hopNameCache[hopCacheKey(h, observerId)] = entry; + // Bare key as a fallback for any render that has no observer in hand. + if (!(h in hopNameCache)) hopNameCache[h] = entry; + } + } + + // Resolve every hop of every packet, grouped by the observer that heard it, + // so each group gets its own regional filtering. + async function resolveHopsForPackets(packets) { + const groups = new Map(); + for (const p of packets || []) { + const obs = (p && p.observer_id) ? String(p.observer_id) : ''; + let set = groups.get(obs); + if (!set) { set = new Set(); groups.set(obs, set); } + try { getParsedPath(p).forEach(h => set.add(h)); } catch {} + } + // ensureHopResolver() is idempotent and awaited once inside resolveHops; + // the resolve itself is local computation, so the loop costs no requests. + for (const [obs, set] of groups) { + if (set.size) await resolveHops([...set], obs || undefined); } } @@ -1028,14 +1078,18 @@ } } - function renderHop(h, observerId) { + function renderHop(h, observerId, opts) { // Use per-packet cache key if observer context available (ambiguous hops differ by region) - const cacheKey = observerId ? h + ':' + observerId : h; + const cacheKey = hopCacheKey(h, observerId); const entry = hopNameCache[cacheKey] || hopNameCache[h]; - return HopDisplay.renderHop(h, entry, { hexMode: showHexHashes }); + return HopDisplay.renderHop(h, entry, Object.assign({ hexMode: showHexHashes }, opts || {})); } - function renderPath(hops, observerId) { + // #2097 — opts.summary renders the list form: names without a badge on every + // hop, and one indicator for the whole path. A row with five 1-byte hops was + // five warning triangles, which is noise in a table; the detail pane keeps + // the per-hop badges, because that is where the question gets answered. + function renderPath(hops, observerId, opts) { if (!hops || !hops.length) return '—'; // #1633 — render-time filter (default OFF). Applies at every consumer // because every site funnels through this function (group header, child @@ -1044,7 +1098,25 @@ ? window.MC_filterPathHops(hops) : hops; if (!filtered.length) return '— (1-byte filtered)'; - return filtered.map(h => renderHop(h, observerId)).join('→'); + const summary = !!(opts && opts.summary); + const body = filtered + .map(h => renderHop(h, observerId, summary ? { badge: false } : null)) + .join('→'); + if (!summary) return body; + + let uncertain = 0; + for (const h of filtered) { + const entry = hopNameCache[hopCacheKey(h, observerId)] || hopNameCache[h]; + if (entry && entry.ambiguous) uncertain++; + } + if (!uncertain) return body; + const label = uncertain + ' of ' + filtered.length + ' hops have more than one candidate'; + // Leads the hops: .path-hops clips at its right edge, and a trailing + // indicator was the one thing clipped, with no +N pill to show for it. + const warn = '' + + uncertain + ''; + return warn + body; } let directPacketId = null; @@ -1340,9 +1412,7 @@ panel.appendChild(content); const pkt = data.packet; try { - const hops = getParsedPath(pkt); - const newHops = hops.filter(h => !(h in hopNameCache)); - if (newHops.length) await resolveHops(newHops); + await resolveHopsForPackets([pkt]); } catch {} await renderDetail(content, data, obsTarget); initPanelResize(); @@ -1398,7 +1468,7 @@ } try { hops.forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} } - (newHops.size ? resolveHops([...newHops]) : Promise.resolve()).then(() => { + (newHops.size ? resolveHopsForPackets(filtered) : Promise.resolve()).then(() => { if (groupByHash) { // Update existing groups or create new ones for (const p of filtered) { @@ -1605,11 +1675,7 @@ const hopJob = (async () => { try { await cacheResolvedPaths(packets); - const allHops = new Set(); - for (const p of packets) { - try { getParsedPath(p).forEach(h => allHops.add(h)); } catch {} - } - if (allHops.size) await resolveHops([...allHops]); + await resolveHopsForPackets(packets); // Re-render rows so resolved hop names replace hex prefixes. if (filtersBuilt) renderTableRows(); } catch (e) { @@ -2177,11 +2243,7 @@ if (p._children) sortGroupChildren(p); } // Resolve any new hops from updated header paths - const newHops = new Set(); - for (const p of packets) { - try { getParsedPath(p).forEach(h => { if (!(h in hopNameCache)) newHops.add(h); }); } catch {} - } - if (newHops.size) await resolveHops([...newHops]); + await resolveHopsForPackets(packets); renderTableRows(); }); @@ -2465,7 +2527,7 @@ const groupRegion = headerObserverId ? (observerMap.get(headerObserverId)?.iata || '') : ''; let groupPath = []; try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {} - const groupPathStr = renderPath(groupPath, headerObserverId); + const groupPathStr = renderPath(groupPath, headerObserverId, { summary: true }); const groupTypeName = payloadTypeName(p.payload_type); const groupTypeClass = payloadTypeColor(p.payload_type); const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0; @@ -2516,7 +2578,7 @@ : (childPath.length > 0 ? childPath[0].length / 2 : 0)); const _cHashSizeTitle = _cIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : ''; const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : ''; - const childPathStr = renderPath(childPath, c.observer_id); + const childPathStr = renderPath(childPath, c.observer_id, { summary: true }); const _childHashStripe = _hashStripeStyle(c.hash || p.hash); html += ` ${childRegion ? `${childRegion}` : '—'} @@ -2551,7 +2613,7 @@ const _flatIsTrace = p.payload_type === 9; const hashBytes = _flatIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1); const _flatHashSizeTitle = _flatIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : ''; - const pathStr = renderPath(pathHops, p.observer_id); + const pathStr = renderPath(pathHops, p.observer_id, { summary: true }); const detail = getDetailPreview(decoded); const _flatHashStripe = _hashStripeStyle(p.hash); const _flatStyle = _flatHashStripe + _chanStyle; @@ -2848,7 +2910,7 @@ var kids = Array.prototype.slice.call(host.children); for (var i = 0; i < kids.length; i++) { var k = kids[i]; - if (k.classList.contains('path-overflow-pill')) continue; + if (k.classList.contains('path-overflow-pill') || k.classList.contains('hop-path-warn')) continue; if (k.classList.contains('arrow')) { if (current) segments.push(current); current = ''; @@ -3446,9 +3508,7 @@ // Resolve path hops for detail view const pkt = data.packet; try { - const hops = getParsedPath(pkt); - const newHops = hops.filter(h => !(h in hopNameCache)); - if (newHops.length) await resolveHops(newHops); + await resolveHopsForPackets([pkt]); } catch {} panel.innerHTML = isMobileNow ? '' : (useSlideOver ? '' : ('
' + PANEL_CLOSE_HTML)); const content = document.createElement('div'); @@ -3538,7 +3598,11 @@ resolved = HopResolver.resolveFromServer(pathHops, serverResolved); } else { await ensureHopResolver(); - resolved = HopResolver.resolve(pathHops); + // #2097 — with the observer: the cache write below stores this under + // the per-observer key, so it has to have been resolved for that + // observer or the key promises something the value is not. + const [dLat, dLon] = observerPosition(pkt.observer_id); + resolved = HopResolver.resolve(pathHops, null, null, dLat, dLon, pkt.observer_id); } if (resolved) { for (const [k, v] of Object.entries(resolved)) { @@ -4320,12 +4384,7 @@ } // Resolve hops from children: prefer server-side resolved_path await cacheResolvedPaths(group?._children || []); - const childHops = new Set(); - for (const c of (group?._children || [])) { - try { getParsedPath(c).forEach(h => childHops.add(h)); } catch {} - } - const newHops = [...childHops].filter(h => !(h in hopNameCache)); - if (newHops.length) await resolveHops(newHops); + await resolveHopsForPackets(group?._children || []); expandedHashes.add(hash); renderTableRows(); // Also open detail panel — no extra fetch needed @@ -4410,10 +4469,7 @@ await loadObservers(); const data = await api(`/packets/${param}`); if (!data?.packet) { app.innerHTML = `

Packet not found

Packet ${param} doesn't exist.

← Back to packets
`; return; } - const hops = []; - try { hops.push(...getParsedPath(data.packet)); } catch {} - const newHops = hops.filter(h => !(h in hopNameCache)); - if (newHops.length) await resolveHops(newHops); + await resolveHopsForPackets([data.packet]); const container = document.createElement('div'); container.style.cssText = 'max-width:800px;margin:0 auto;padding:20px'; container.innerHTML = `
← Back to packets
`; diff --git a/public/style.css b/public/style.css index d585c988..64a9a61e 100644 --- a/public/style.css +++ b/public/style.css @@ -3109,6 +3109,20 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .hop-conflict-btn { background: var(--status-yellow, #f59e0b); color: #000; border: none; border-radius: 4px; font-size: 11px; font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; } .hop-conflict-btn:hover { background: var(--status-yellow, #d97706); filter: brightness(0.85); } + +/* #2097 — a badge is a sibling of its pill, so "A [8] -> B [6]" gave no clue + which name the 8 belonged to. The group ties the pair together and keeps the + separator outside it. Only hops that carry a badge are wrapped. */ +.hop-group { display: inline-flex; align-items: center; white-space: nowrap; } +.hop-group .hop-conflict-btn, +.hop-group .hop-unreliable-btn { margin-left: 2px; } + +/* One indicator for a whole path, used by the packets list. The detail pane + keeps a badge per hop, because that is where the candidates get read. */ +.hop-path-warn { display: inline-flex; align-items: center; gap: 2px; margin-right: 4px; flex: 0 0 auto; + background: var(--status-yellow, #f59e0b); color: #000; border-radius: 4px; + font-size: 11px; font-weight: 700; padding: 1px 5px; line-height: 1.2; + vertical-align: middle; white-space: nowrap; } .hop-conflict-popover { position: absolute; z-index: var(--z-popover); background: var(--surface-1); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; } .hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border); diff --git a/test-all.sh b/test-all.sh index 5d8a8e41..ff29fa74 100755 --- a/test-all.sh +++ b/test-all.sh @@ -140,6 +140,7 @@ node tests/unit/test-issue-2042-recent-adverts-label.js node tests/unit/test-issue-2001-map-scope-state.js node tests/unit/test-issue-2012-clear-filters-selection.js node tests/unit/test-issue-2095-channels-client-state.js +node tests/unit/test-issue-2097-hop-ambiguity-badge.js node tests/unit/test-live-anims.js node tests/unit/test-live-dt-cap-1524.js node tests/unit/test-live-legend-helper.js diff --git a/tests/e2e/test-issue-1146-path-link-contrast-e2e.js b/tests/e2e/test-issue-1146-path-link-contrast-e2e.js index 7c496611..3698ea89 100644 --- a/tests/e2e/test-issue-1146-path-link-contrast-e2e.js +++ b/tests/e2e/test-issue-1146-path-link-contrast-e2e.js @@ -199,7 +199,7 @@ async function effectiveBgFor(page, selector) { warning: !!chain.querySelector('.hop-unreliable-btn'), conflict: !!chain.querySelector('.hop-conflict-btn'), siblingAmbiguous: sibling.classList.contains('hop-ambiguous'), - hopCount: chain.children.length - chain.querySelectorAll('button').length, + hopCount: chain.children.length - chain.querySelectorAll(':scope > button').length, }; }, { selector, targetHopKey, siblingKey }); assert(result.hopCount === 18, 'Expected all 18 hops in the rendered chain'); diff --git a/tests/unit/test-issue-2097-hop-ambiguity-badge.js b/tests/unit/test-issue-2097-hop-ambiguity-badge.js new file mode 100644 index 00000000..4753e911 --- /dev/null +++ b/tests/unit/test-issue-2097-hop-ambiguity-badge.js @@ -0,0 +1,233 @@ +/** + * #2097 — an ambiguous path hop was shown as a single certain name. + * + * Every node on the network shares its 1-byte path prefix with at least one + * other: measured, 254 prefixes cover all 2043 nodes. HopResolver already marks + * such a hop `ambiguous` with a populated `conflicts` list, and HopDisplay + * already renders a warning badge for it. Neither was reaching the packets page, + * because resolve() takes six parameters and packets.js passed one. + * + * Without the observer, `packetIata` is null, so nodeInRegion() never runs, no + * candidate is flagged `regional`, `globalFallback` stays false, and + * HopDisplay's badgeCount computes to 0. The whole chain is silent while the + * data says the name is a guess. + * + * These tests pin the chain end to end: resolver output, then the markup + * HopDisplay produces from it. + */ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +const fs = require('fs'); +const vm = require('vm'); + +function load(file, extra) { + const sandbox = Object.assign({ + window: {}, document: { addEventListener() {}, getElementById: () => null, querySelector: () => null, createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, appendChild() {}, setAttribute() {} }), body: { appendChild() {}, removeChild() {} } }, + console, Math, Object, Array, Number, Date, Map, Set, JSON, String, Boolean, + parseInt, parseFloat, isNaN, encodeURIComponent, decodeURIComponent, + setTimeout, clearTimeout, + }, extra || {}); + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(REPO_ROOT + '/public/' + file, 'utf8'), sandbox); + return sandbox; +} + +const rs = load('hop-resolver.js'); +const HopResolver = rs.window.HopResolver; +const ds = load('hop-display.js'); +const HopDisplay = ds.window.HopDisplay; + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' PASS ' + msg); } + else { failed++; console.log(' FAIL ' + msg); } +} + +// Three nodes share the 1-byte prefix "ef", as nine real ones do today. Two sit +// near the observer, one is 130 km away. +const near1 = { public_key: 'ef0069c0aa', name: 'NEAR-ONE', role: 'repeater', lat: 51.08, lon: 3.78 }; +const near2 = { public_key: 'ef86f6a5bb', name: 'NEAR-TWO', role: 'repeater', lat: 51.15, lon: 3.70 }; +const far = { public_key: 'efbf0eeacc', name: 'FAR-AWAY', role: 'repeater', lat: 50.87, lon: 5.52 }; +const other = { public_key: 'e7aaaaaadd', name: 'NEXT-HOP', role: 'repeater', lat: 51.10, lon: 3.80 }; +const NODES = [near1, near2, far, other]; + +// The observer that heard the packet, and its IATA centre. +const OBSERVER_ID = 'A943DC76B109'; +const IATA = 'OST'; + +console.log('\n=== #2097: the resolver marks an ambiguous hop ==='); + +HopResolver.init(NODES, { + observers: [{ id: OBSERVER_ID, iata: IATA }], + iataCoords: { [IATA]: { lat: 51.23, lon: 2.92 } }, +}); + +{ + const r = HopResolver.resolve(['ef']); + const e = r['ef']; + assert(e && e.ambiguous === true, 'three candidates for "ef" are reported as ambiguous'); + assert(e && Array.isArray(e.conflicts) && e.conflicts.length === 3, 'all three candidates are listed in conflicts'); +} + +console.log('\n=== #2097: HopDisplay renders a badge only when the count survives ==='); + +{ + // This is the regression itself: resolve() without the observer produces + // conflicts that carry no regional flag, and HopDisplay then computes + // badgeCount = 0 and renders nothing. + const noCtx = HopResolver.resolve(['ef'])['ef']; + const withCtx = HopResolver.resolve(['ef'], null, null, null, null, OBSERVER_ID)['ef']; + + const flaggedNo = (noCtx.conflicts || []).filter(c => c.regional).length; + const flaggedYes = (withCtx.conflicts || []).filter(c => c.regional).length; + assert(flaggedYes > flaggedNo, + `observer context flags regional candidates (${flaggedNo} without, ${flaggedYes} with)`); + + const html = HopDisplay.renderHop('ef', withCtx, {}); + assert(/hop-conflict-btn/.test(html), + 'a hop with several regional candidates renders the conflict badge'); + assert(/ph-warning/.test(html), 'the badge carries a warning icon, not colour alone'); + assert(/data-conflict=/.test(html), 'the badge carries the candidate list for the popover'); +} + +{ + // A hop with exactly one candidate must stay clean: the badge has to mean + // something, and 2-byte hops are unique 98.5% of the time. + const one = HopResolver.resolve(['e7aa'], null, null, null, null, OBSERVER_ID)['e7aa']; + const html = HopDisplay.renderHop('e7aa', one, {}); + assert(!/hop-conflict-btn/.test(html), 'an unambiguous hop renders no badge'); + assert(/NEXT-HOP/.test(html), 'an unambiguous hop still shows its name'); +} + +{ + // Geography must actually narrow the field: the node 130 km outside the + // observer's region should not be the one presented as the answer. + const withCtx = HopResolver.resolve(['ef'], null, null, null, null, OBSERVER_ID)['ef']; + assert(withCtx.name !== 'FAR-AWAY', + 'the candidate outside the observer region is not the one displayed (got ' + withCtx.name + ')'); +} + + +console.log('\n=== #2097: packets.js never resolves a hop without its observer ==='); + +{ + // Structural guard. The resolver and the display could always report the + // ambiguity; the regression was one caller dropping five of six arguments, + // and nothing failed when it did. + const src = fs.readFileSync(REPO_ROOT + '/public/packets.js', 'utf8'); + + const bare = src.match(/HopResolver\.resolve\(\s*[A-Za-z_$][\w$]*\s*\)/g) || []; + assert(bare.length === 0, + 'no HopResolver.resolve() call passes the hops alone' + + (bare.length ? ': ' + bare.join(', ') : '')); + + assert(/function hopCacheKey\(/.test(src), + 'the per-observer cache key is computed in one place'); + assert(/async function resolveHopsForPackets\(/.test(src), + 'multi-packet call sites group their hops by observer'); + + // renderHop reads the per-observer key; something has to write it. + assert(/hopNameCache\[hopCacheKey\(h, observerId\)\] = entry/.test(src), + 'resolveHops writes the per-observer cache key that renderHop reads'); +} + +console.log('\n=== #2097: a badge belongs to the pill on its left ==='); + +{ + // The badge is a sibling after the pill, so in "A [8] -> B [6]" a reader + // cannot tell which name the 8 qualifies. Pill and badge must render as one + // unbreakable group, with the arrow clearly outside it. + const withCtx = HopResolver.resolve(['ef'], null, null, null, null, OBSERVER_ID)['ef']; + const html = HopDisplay.renderHop('ef', withCtx, {}); + assert(/class="hop-group"/.test(html), 'pill and badge are wrapped in one group'); + const m = html.match(/([\s\S]*)<\/span>$/); + assert(m && /hop-link/.test(m[1]) && /hop-conflict-btn/.test(m[1]), + 'the group contains both the pill and its badge'); +} + +{ + // An unambiguous hop needs no wrapper: the group exists to tie a badge to its + // pill, and wrapping every hop would change layout for nothing. + const one = HopResolver.resolve(['e7aa'], null, null, null, null, OBSERVER_ID)['e7aa']; + const html = HopDisplay.renderHop('e7aa', one, {}); + assert(!/hop-group/.test(html), 'a hop with no badge is not wrapped'); +} + +{ + // The packets list wants the names without a badge on every hop. + const withCtx = HopResolver.resolve(['ef'], null, null, null, null, OBSERVER_ID)['ef']; + const plain = HopDisplay.renderHop('ef', withCtx, { badge: false }); + assert(!/hop-conflict-btn/.test(plain), 'badge:false suppresses the badge'); + assert(/hop-ambiguous/.test(plain), + 'the hop keeps its ambiguous class, so CSS can still mark it'); + assert(/NEAR-|FAR-/.test(plain), 'the name is still rendered'); +} + +console.log('\n=== #2097: the list summarises, the detail pane does not ==='); + +{ + const src = fs.readFileSync(REPO_ROOT + '/public/packets.js', 'utf8'); + assert(/function renderPath\(hops, observerId, opts\)/.test(src), + 'renderPath takes an options object'); + assert(/summary: true/.test(src), + 'at least one call site asks for the summarised form'); + assert(/renderPath\(pathHops, effectivePkt\.observer_id\)/.test(src), + 'the detail pane calls renderPath without summary, so it keeps per-hop badges'); + assert(/hop-path-warn/.test(src), + 'the summarised form emits a single per-path indicator'); + // .path-hops clips at its edge. Trailing the hops, the indicator was what + // got clipped: every hop fitted, so no +N pill appeared and the warning + // was invisible (#1128 Bug 1 E2E once the path column narrowed). + assert(/return warn \+ body;/.test(src), + 'the indicator leads the path, so the edge clips hops, never the indicator'); + const seg = (src.match(/function _pathHopSegments\(host\) \{[\s\S]*?\n \}/) || [''])[0]; + assert(/hop-path-warn/.test(seg), + 'the +N popover leaves the indicator out of the hop list'); +} + +console.log('\n=== #2097: the observer position anchors the pick ==='); + +{ + // Measured on the live deployment: 0 of 42 observers have their IATA code in + // /api/iata-coords, so nodeInRegion() always returns null and the 300 km + // filter narrows nothing. 27 of 42 do carry their own lat/lon, and resolve() + // already accepts them as the backward anchor for pickByAffinity. + // + // FAR-AWAY is listed first on purpose: with no anchor the resolver has no + // context and keeps candidate order, which is the shape of the live bug. + const far = { public_key: 'efbf0eeacc', name: 'FAR-AWAY', role: 'repeater', lat: 50.87, lon: 5.52 }; + const near1 = { public_key: 'ef0069c0aa', name: 'NEAR-ONE', role: 'repeater', lat: 51.08, lon: 3.78 }; + const near2 = { public_key: 'ef86f6a5bb', name: 'NEAR-TWO', role: 'repeater', lat: 51.15, lon: 3.70 }; + HopResolver.init([far, near1, near2], { + observers: [{ id: OBSERVER_ID, iata: IATA }], + iataCoords: {}, // deliberately empty: this is the live state + }); + + const OBS_LAT = 51.21, OBS_LON = 3.44; // the observer that heard it + + const without = HopResolver.resolve(['ef'], null, null, null, null, OBSERVER_ID)['ef']; + const withPos = HopResolver.resolve(['ef'], null, null, OBS_LAT, OBS_LON, OBSERVER_ID)['ef']; + + assert(without.name === 'FAR-AWAY', + 'without an anchor the resolver keeps candidate order (got ' + without.name + ')'); + assert(withPos.name !== 'FAR-AWAY', + 'the observer position rules out the distant candidate (got ' + withPos.name + ')'); + assert(withPos.ambiguous === true, + 'it is still reported as ambiguous: a better pick is not a certain one'); + assert((withPos.conflicts || []).length === 3, + 'every candidate is still listed for the reader'); +} + +{ + const src = fs.readFileSync(REPO_ROOT + '/public/packets.js', 'utf8'); + assert(/HopResolver\.resolve\(unknown, null, null, obsLat, obsLon, observerId\)/.test(src), + 'resolveHops passes the observer position as the anchor'); + const withId = src.match(/HopResolver\.resolve\([^)]*observer_id[^)]*\)/g) || []; + assert(withId.every(c => !/null, null, null, null/.test(c)), + 'no call passes an observer id but drops its position: ' + withId.join(' | ')); + assert(/function observerPosition\(/.test(src) && /observerPosition\(observerId\)/.test(src), + 'one helper looks the observer up, used by every resolve path'); +} + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0);