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 = `
`;
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);