diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js
index c859339..0142204 100644
--- a/web/public/assets/js/app/__tests__/node-page.test.js
+++ b/web/public/assets/js/app/__tests__/node-page.test.js
@@ -42,9 +42,13 @@ const {
lookupNeighborDetails,
seedNeighborRoleIndex,
buildNeighborRoleIndex,
+ fetchNodeDetailsIntoIndex,
+ renderRoleAwareBadge,
collectTraceNodeFetchMap,
buildTraceRoleIndex,
categoriseNeighbors,
+ renderNeighborBadge,
+ renderNeighborGroup,
renderNeighborGroups,
renderSingleNodeTable,
classifySnapshot,
@@ -1257,3 +1261,245 @@ test('initializeNodeDetailPage handles missing reference payloads', async () =>
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Node reference unavailable'), true);
});
+
+test('parseReferencePayload returns null for blank or unparseable input', () => {
+ assert.equal(parseReferencePayload(null), null);
+ assert.equal(parseReferencePayload(' '), null);
+ assert.equal(parseReferencePayload('not-json'), null);
+ assert.equal(parseReferencePayload(JSON.stringify(42)), null);
+ assert.deepEqual(parseReferencePayload(JSON.stringify({ nodeId: '!a' })), { nodeId: '!a' });
+});
+
+test('initializeNodeDetailPage rejects invalid documents and missing identifiers', async () => {
+ await assert.rejects(
+ () => initializeNodeDetailPage({ document: null, fetchImpl: async () => ({}) }),
+ /document with querySelector/,
+ );
+
+ const root = { dataset: { nodeReference: JSON.stringify({}) }, innerHTML: '' };
+ const documentStub = {
+ querySelector: selector => (selector === '#nodeDetail' ? root : null),
+ };
+ const result = await initializeNodeDetailPage({
+ document: documentStub,
+ fetchImpl: async () => ({ ok: true, json: async () => ({}) }),
+ renderShortHtml: short => `${short}`,
+ });
+ assert.equal(result, false);
+ assert.equal(root.innerHTML.includes('Node identifier missing'), true);
+});
+
+test('renderRoleAwareBadge falls back when both shortName and identifier are absent', () => {
+ const html = renderRoleAwareBadge((short, role) => `${short}`, {});
+ assert.equal(html, '?');
+});
+
+test('renderRoleAwareBadge invokes default span renderer when renderShortHtml is missing', () => {
+ const html = renderRoleAwareBadge(null, { shortName: 'AB&CD' });
+ assert.equal(html.includes('class="short-name"'), true);
+ assert.equal(html.includes('AB&CD'), true);
+});
+
+test('seedNeighborRoleIndex tolerates non-array and non-object entries', () => {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ assert.equal(seedNeighborRoleIndex(index, null).size, 0);
+ assert.equal(seedNeighborRoleIndex(index, 'not-an-array').size, 0);
+ assert.equal(seedNeighborRoleIndex(index, [null, 7, 'string']).size, 0);
+});
+
+test('seedNeighborRoleIndex hydrates roles from nested neighbor and node objects', () => {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ seedNeighborRoleIndex(index, [
+ {
+ neighbor: { node_id: '!ally', node_num: 11, role: 'ROUTER', short_name: 'ALLY', long_name: 'Ally Long' },
+ node: { node_id: '!self', node_num: 22, role: 'CLIENT', short_name: 'SELF', long_name: 'Self Long' },
+ },
+ ]);
+ assert.equal(index.byId.get('!ally'), 'ROUTER');
+ assert.equal(index.byId.get('!self'), 'CLIENT');
+ assert.equal(index.byNum.get(11), 'ROUTER');
+ assert.equal(index.byNum.get(22), 'CLIENT');
+ const allyDetails = lookupNeighborDetails(index, { identifier: '!ally' });
+ assert.equal(allyDetails.shortName, 'ALLY');
+ assert.equal(allyDetails.longName, 'Ally Long');
+});
+
+test('fetchNodeDetailsIntoIndex skips work when no fetch is reachable', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = undefined;
+ try {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ await fetchNodeDetailsIntoIndex(index, new Map([['x', 'x']]), undefined);
+ assert.equal(index.byId.size, 0);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('fetchNodeDetailsIntoIndex returns immediately for empty or non-Map inputs', async () => {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ let calls = 0;
+ const fetchImpl = async () => { calls += 1; return { ok: true, json: async () => ({}) }; };
+ await fetchNodeDetailsIntoIndex(index, null, fetchImpl);
+ await fetchNodeDetailsIntoIndex(index, new Map(), fetchImpl);
+ assert.equal(calls, 0);
+});
+
+test('fetchNodeDetailsIntoIndex silently ignores 404 responses without registering', async () => {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ const fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
+ await fetchNodeDetailsIntoIndex(index, new Map([['gone', 'gone']]), fetchImpl);
+ assert.equal(index.byId.size, 0);
+});
+
+test('renderSingleNodeTable returns empty string for invalid inputs', () => {
+ assert.equal(renderSingleNodeTable(null, () => ''), '');
+ assert.equal(renderSingleNodeTable({ nodeId: '!a' }, null), '');
+ assert.equal(renderSingleNodeTable('string-not-object', () => ''), '');
+});
+
+test('renderTelemetryCharts returns empty string when no entries fall in the window', () => {
+ const out = renderTelemetryCharts(makeAggregatedNode([
+ { timestamp: '1970-01-01T00:00:00Z' },
+ ]), { now: () => Date.UTC(2026, 0, 1) });
+ assert.equal(out, '');
+});
+
+test('renderTelemetryCharts returns empty string when chart specs produce no markup', () => {
+ // Aggregated snapshot with valid timestamp but no telemetry fields any chart
+ // can plot — every chart spec filters its empty series and returns ''.
+ const node = makeAggregatedNode([
+ { rx_time: CHART_NOW_SECONDS - 60, telemetry_type: 'device' },
+ ]);
+ const out = renderTelemetryCharts(node, { nowMs: CHART_NOW_MS });
+ assert.equal(out, '');
+});
+
+test('renderTracePath returns empty string when fewer than two badges render', () => {
+ const renderShortHtml = short => `${short}`;
+ // Single-element path → items.length < 2 → empty result.
+ assert.equal(renderTracePath(['!only'], renderShortHtml), '');
+ // Two refs but the renderer yields blanks → filter strips them → items.length < 2.
+ assert.equal(renderTracePath([{ identifier: '!a' }, { identifier: '!b' }], () => ''), '');
+});
+
+test('renderNeighborBadge returns empty string for invalid inputs', () => {
+ assert.equal(renderNeighborBadge(null, 'heardBy', () => ''), '');
+ assert.equal(renderNeighborBadge({ neighbor_id: '!a' }, 'weHear', null), '');
+ // Entry without any identifier in keys → returns ''
+ assert.equal(renderNeighborBadge({ snr: 5 }, 'weHear', () => ''), '');
+});
+
+test('renderNeighborBadge merges role-index metadata into the source object', () => {
+ const source = {};
+ const entry = { neighbor_id: '!ally', neighbor: source };
+ const roleIndex = {
+ byId: new Map([['!ally', 'ROUTER']]),
+ byNum: new Map(),
+ detailsById: new Map([
+ ['!ally', { shortName: 'ALLY', longName: 'Ally Long', role: 'ROUTER' }],
+ ]),
+ detailsByNum: new Map(),
+ };
+ const renderShortHtml = (short, role, long, badgeSource) =>
+ `${short}`;
+ const html = renderNeighborBadge(entry, 'weHear', renderShortHtml, roleIndex);
+ assert.match(html, /ALLY/);
+ assert.equal(source.short_name, 'ALLY');
+ assert.equal(source.long_name, 'Ally Long');
+ assert.equal(source.role, 'ROUTER');
+});
+
+test('renderNeighborBadge derives short name from identifier when no metadata is available', () => {
+ const html = renderNeighborBadge(
+ { neighbor_id: '!abcdef12' },
+ 'weHear',
+ short => `${short}`,
+ );
+ // Last four hex chars of identifier, uppercased.
+ assert.equal(html, 'EF12');
+});
+
+test('renderNeighborGroup skips entries that fail to render and returns empty when none survive', () => {
+ const renderShortHtml = (short, role) => `${short}`;
+ // Two entries; only one yields a valid badge.
+ const html = renderNeighborGroup(
+ 'Heard by',
+ [
+ { node_id: '!peer', node_short_name: 'PEER' },
+ { snr: 5 }, // no identifier → renderNeighborBadge returns '' → filtered out.
+ ],
+ 'heardBy',
+ renderShortHtml,
+ );
+ assert.equal(html.includes('PEER'), true);
+ assert.equal(html.match(/
/g).length, 1);
+
+ // All entries fail → returns ''.
+ const empty = renderNeighborGroup('Heard by', [{ snr: 1 }, { snr: 2 }], 'heardBy', renderShortHtml);
+ assert.equal(empty, '');
+});
+
+test('renderTraceroutes returns empty string when no trace path renders content', () => {
+ const renderShortHtml = short => `${short}`;
+ // Each trace yields a single-hop path which renderTracePath rejects → no items remain.
+ assert.equal(renderTraceroutes([{ src: '!a', hops: [], dest: null }], renderShortHtml), '');
+});
+
+test('fetchNodeDetailHtml rejects non-object references', async () => {
+ await assert.rejects(() => fetchNodeDetailHtml(null), TypeError);
+ await assert.rejects(() => fetchNodeDetailHtml('not-an-object'), TypeError);
+});
+
+test('normalizeNodeReference returns null for non-object inputs and references missing both ids', () => {
+ const { normalizeNodeReference } = __testUtils;
+ assert.equal(normalizeNodeReference(null), null);
+ assert.equal(normalizeNodeReference('not-an-object'), null);
+ assert.equal(normalizeNodeReference({}), null);
+ assert.deepEqual(normalizeNodeReference({ nodeId: '!a' }), { nodeId: '!a', nodeNum: null });
+});
+
+test('fetchNodeDetailsIntoIndex warns and continues when a non-404 response fails', async () => {
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ const fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
+ const originalWarn = console.warn;
+ const messages = [];
+ console.warn = (...args) => messages.push(args[0]);
+ try {
+ await fetchNodeDetailsIntoIndex(index, new Map([['ouch', 'ouch']]), fetchImpl, 'unit-test');
+ } finally {
+ console.warn = originalWarn;
+ }
+ assert.equal(index.byId.size, 0);
+ assert.equal(messages.some(msg => typeof msg === 'string' && msg.includes('unit-test')), true);
+});
+
+test('fetchNodeDetailsIntoIndex caps in-flight requests at NEIGHBOR_ROLE_FETCH_CONCURRENCY', async () => {
+ // Eight identifiers, four-wide pool: at most four fetches should be in flight.
+ const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
+ const fetchIdMap = new Map(ids.map(id => [id, id]));
+ let inFlight = 0;
+ let peak = 0;
+ const release = [];
+ const fetchImpl = () => {
+ inFlight += 1;
+ if (inFlight > peak) peak = inFlight;
+ return new Promise(resolve => {
+ release.push(() => {
+ inFlight -= 1;
+ resolve({ ok: true, status: 200, json: async () => ({ node_id: '!stub', role: 'CLIENT' }) });
+ });
+ });
+ };
+ const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
+ const work = fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl);
+ // Yield so the four workers reach their first await.
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(peak, 4, `expected concurrency cap of 4, observed peak ${peak}`);
+ // Drain in two waves; the second wave only starts once the first releases.
+ release.splice(0, 4).forEach(fn => fn());
+ await new Promise(resolve => setImmediate(resolve));
+ release.splice(0).forEach(fn => fn());
+ await work;
+ assert.equal(peak, 4);
+});
diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js
index 4d9e0cf..146b4b9 100644
--- a/web/public/assets/js/app/node-page.js
+++ b/web/public/assets/js/app/node-page.js
@@ -14,1486 +14,83 @@
* limitations under the License.
*/
-import { refreshNodeInformation } from './node-details.js';
-import { protocolIconPrefixHtml } from './protocol-helpers.js';
-import {
- extractChatMessageMetadata,
- formatChatChannelTag,
- formatChatMessagePrefix,
- formatChatPresetTag,
-} from './chat-format.js';
-import { buildMessageIndex } from './message-replies.js';
-import { renderChatEntryContent } from './chat-entry-renderer.js';
-import {
- fmtAlt,
- fmtHumidity,
- fmtPressure,
- fmtTemperature,
- fmtTx,
-} from './short-info-telemetry.js';
+/**
+ * Node detail page entry point.
+ *
+ * Acts as a thin barrel re-exporting the public surface assembled in the
+ * focused submodules under ``./node-page/`` so existing consumers
+ * (``views/node_detail.erb``, ``node-detail-overlay.js``, ``charts-page.js``,
+ * and the unit-test suite) keep working unchanged.
+ *
+ * @module node-page
+ */
+
import { escapeHtml } from './utils.js';
-import { buildNodeDetailHref, canonicalNodeIdentifier, renderNodeLongNameLink } from './node-rendering.js';
-import {
- DAY_MS,
- HOUR_MS,
- TELEMETRY_WINDOW_MS,
- DEFAULT_CHART_DIMENSIONS,
- DEFAULT_CHART_MARGIN,
- TELEMETRY_CHART_SPECS,
- clamp,
- hexToRgba,
- formatCompactDate,
- formatGasResistance,
- formatSeriesPointValue,
- formatFrequency,
- formatBattery,
- formatVoltage,
- formatUptime,
- formatTimestamp,
- padTwo,
- formatMessageTimestamp,
- formatHardwareModel,
- formatCoordinate,
- formatRelativeSeconds,
- formatDurationSeconds,
- formatSnr,
- toTimestampMs,
- resolveSnapshotTimestamp,
- buildMidnightTicks,
- buildHourlyTicks,
- buildLinearTicks,
- buildLogTicks,
- formatAxisTick,
- createChartDimensions,
- resolveAxisX,
- scaleTimestamp,
- scaleValueToAxis,
- collectSnapshotContainers,
- classifySnapshot,
- extractSnapshotValue,
- buildSeriesPoints,
- resolveAxisMax,
- renderTelemetrySeries,
- renderYAxis,
- renderXAxis,
- renderTelemetryChart,
-} from './node-page-charts.js';
-import { fetchMessages, fetchNodesById, fetchTracesForNode } from './node-page-data.js';
import { numberOrNull, stringOrNull } from './value-helpers.js';
+import { fetchMessages, fetchTracesForNode } from './node-page-data.js';
+import {
+ classifySnapshot,
+ formatBattery,
+ formatCoordinate,
+ formatDurationSeconds,
+ formatFrequency,
+ formatHardwareModel,
+ formatMessageTimestamp,
+ formatRelativeSeconds,
+ formatSnr,
+ formatTimestamp,
+ formatUptime,
+ formatVoltage,
+ padTwo,
+} from './node-page-charts.js';
+import {
+ buildNeighborRoleIndex,
+ cloneRoleIndex,
+ fetchNodeDetailsIntoIndex,
+ lookupNeighborDetails,
+ lookupRole,
+ normalizeNodeId,
+ registerRoleCandidate,
+ seedNeighborRoleIndex,
+} from './node-page/role-index.js';
+import {
+ categoriseNeighbors,
+ renderNeighborBadge,
+ renderNeighborGroup,
+ renderNeighborGroups,
+} from './node-page/neighbor-rendering.js';
+import { renderRoleAwareBadge } from './node-page/badge.js';
+import { renderSingleNodeTable } from './node-page/single-node-table.js';
+import { renderTelemetryCharts } from './node-page/telemetry-charts.js';
+import { renderMessages } from './node-page/messages.js';
+import {
+ buildTraceRoleIndex,
+ collectTraceNodeFetchMap,
+ extractTracePath,
+ normalizeTraceNodeRef,
+ renderTracePath,
+ renderTraceroutes,
+} from './node-page/traces.js';
+import { renderNodeDetailHtml } from './node-page/detail-html.js';
+import {
+ fetchNodeDetailHtml,
+ initializeNodeDetailPage,
+ normalizeNodeReference,
+ parseReferencePayload,
+ resolveRenderShortHtml,
+} from './node-page/bootstrap.js';
-const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'default' });
-const MESSAGE_LIMIT = 50;
-const RENDER_WAIT_INTERVAL_MS = 20;
-const RENDER_WAIT_TIMEOUT_MS = 500;
-/** Maximum number of in-flight /api/nodes fetches issued in parallel when
- * resolving role information for neighbour badges. Kept small to avoid
- * overwhelming the server with bursts of concurrent requests. */
-const NEIGHBOR_ROLE_FETCH_CONCURRENCY = 4;
-const TRACE_LIMIT = 200;
+export {
+ fetchNodeDetailHtml,
+ initializeNodeDetailPage,
+ renderTelemetryCharts,
+};
/**
- * Render the telemetry charts for the supplied node when telemetry snapshots
- * exist.
- *
- * @param {Object} node Normalised node payload.
- * @param {{ nowMs?: number }} [options] Rendering options.
- * @returns {string} Chart grid markup or an empty string.
+ * Test surface used by ``__tests__/node-page.test.js``. Built explicitly so
+ * adding or removing a public helper triggers the test that asserts on this
+ * map's shape.
*/
-export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
- const telemetrySource = node?.rawSources?.telemetry;
- const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
- ? node.rawSources.telemetrySnapshots
- : null;
- const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
- ? telemetrySource.snapshots
- : null;
- const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
- if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
- return '';
- }
- const entries = rawSnapshots
- .map(snapshot => {
- const timestamp = resolveSnapshotTimestamp(snapshot);
- if (timestamp == null) return null;
- return { timestamp, snapshot };
- })
- .filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
- .sort((a, b) => a.timestamp - b.timestamp);
- if (entries.length === 0) {
- return '';
- }
- const isAggregated = snapshotHistory == null && aggregatedSnapshots != null;
- const charts = TELEMETRY_CHART_SPECS
- .map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }))
- .filter(chart => stringOrNull(chart));
- if (charts.length === 0) {
- return '';
- }
- return `
-
-
- ${charts.join('')}
-
-
- `;
-}
-
-/**
- * Normalise a node identifier for consistent lookups.
- *
- * @param {*} identifier Candidate identifier.
- * @returns {string|null} Lower-case identifier or ``null`` when invalid.
- */
-function normalizeNodeId(identifier) {
- const value = stringOrNull(identifier);
- return value ? value.toLowerCase() : null;
-}
-
-/**
- * Register a role candidate within the supplied index.
- *
- * @param {{
- * byId: Map,
- * byNum: Map,
- * detailsById: Map,
- * detailsByNum: Map,
- * }} index Role index maps.
- * @param {{
- * identifier?: *,
- * numericId?: *,
- * role?: *,
- * shortName?: *,
- * longName?: *,
- * }} payload Role candidate payload.
- * @returns {void}
- */
-function registerRoleCandidate(
- index,
- { identifier = null, numericId = null, role = null, shortName = null, longName = null } = {},
-) {
- if (!index || typeof index !== 'object') return;
-
- if (!(index.byId instanceof Map)) index.byId = new Map();
- if (!(index.byNum instanceof Map)) index.byNum = new Map();
- if (!(index.detailsById instanceof Map)) index.detailsById = new Map();
- if (!(index.detailsByNum instanceof Map)) index.detailsByNum = new Map();
-
- const resolvedRole = stringOrNull(role);
- const resolvedShort = stringOrNull(shortName);
- const resolvedLong = stringOrNull(longName);
-
- const idKey = normalizeNodeId(identifier);
- const numKey = numberOrNull(numericId);
-
- if (resolvedRole) {
- if (idKey && !index.byId.has(idKey)) {
- index.byId.set(idKey, resolvedRole);
- }
- if (numKey != null && !index.byNum.has(numKey)) {
- index.byNum.set(numKey, resolvedRole);
- }
- }
-
- const applyDetails = (existing, keyType) => {
- const current = existing instanceof Map && (keyType === 'id' ? idKey : numKey) != null
- ? existing.get(keyType === 'id' ? idKey : numKey)
- : null;
- const merged = current && typeof current === 'object' ? { ...current } : {};
- if (resolvedRole && !merged.role) merged.role = resolvedRole;
- if (resolvedShort && !merged.shortName) merged.shortName = resolvedShort;
- if (resolvedLong && !merged.longName) merged.longName = resolvedLong;
- if (keyType === 'id' && idKey && merged.identifier == null) merged.identifier = idKey;
- if (keyType === 'num' && numKey != null && merged.numericId == null) {
- merged.numericId = numKey;
- }
- return merged;
- };
-
- if (idKey) {
- const merged = applyDetails(index.detailsById, 'id');
- if (Object.keys(merged).length > 0) {
- index.detailsById.set(idKey, merged);
- }
- }
- if (numKey != null) {
- const merged = applyDetails(index.detailsByNum, 'num');
- if (Object.keys(merged).length > 0) {
- index.detailsByNum.set(numKey, merged);
- }
- }
-}
-
-/**
- * Clone an existing role index into fresh map instances.
- *
- * @param {Object|null|undefined} index Original role index maps.
- * @returns {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}}
- * Cloned maps with identical entries.
- */
-function cloneRoleIndex(index) {
- return {
- byId: index?.byId instanceof Map ? new Map(index.byId) : new Map(),
- byNum: index?.byNum instanceof Map ? new Map(index.byNum) : new Map(),
- detailsById: index?.detailsById instanceof Map ? new Map(index.detailsById) : new Map(),
- detailsByNum: index?.detailsByNum instanceof Map ? new Map(index.detailsByNum) : new Map(),
- };
-}
-
-/**
- * Resolve a role from the provided index using identifier or numeric keys.
- *
- * @param {{byId?: Map, byNum?: Map}|null} index Role lookup maps.
- * @param {{ identifier?: *, numericId?: * }} payload Lookup payload.
- * @returns {string|null} Resolved role string or ``null`` when unavailable.
- */
-function lookupRole(index, { identifier = null, numericId = null } = {}) {
- if (!index || typeof index !== 'object') return null;
- const idKey = normalizeNodeId(identifier);
- if (idKey && index.byId instanceof Map && index.byId.has(idKey)) {
- return index.byId.get(idKey) ?? null;
- }
- const numKey = numberOrNull(numericId);
- if (numKey != null && index.byNum instanceof Map && index.byNum.has(numKey)) {
- return index.byNum.get(numKey) ?? null;
- }
- return null;
-}
-
-/**
- * Resolve neighbour metadata from the provided index.
- *
- * @param {{
- * detailsById?: Map,
- * detailsByNum?: Map,
- * byId?: Map,
- * byNum?: Map,
- * }|null} index Role lookup maps.
- * @param {{ identifier?: *, numericId?: * }} payload Lookup payload.
- * @returns {{ role?: string|null, shortName?: string|null, longName?: string|null }|null}
- * Resolved metadata object or ``null`` when unavailable.
- */
-function lookupNeighborDetails(index, { identifier = null, numericId = null } = {}) {
- if (!index || typeof index !== 'object') return null;
- const idKey = normalizeNodeId(identifier);
- const numKey = numberOrNull(numericId);
-
- const details = {};
- if (idKey && index.detailsById instanceof Map && index.detailsById.has(idKey)) {
- Object.assign(details, index.detailsById.get(idKey));
- }
- if (numKey != null && index.detailsByNum instanceof Map && index.detailsByNum.has(numKey)) {
- Object.assign(details, index.detailsByNum.get(numKey));
- }
-
- if (!details.role) {
- const role = lookupRole(index, { identifier, numericId });
- if (role) details.role = role;
- }
-
- if (Object.keys(details).length === 0) {
- return null;
- }
-
- return {
- role: details.role ?? null,
- shortName: details.shortName ?? null,
- longName: details.longName ?? null,
- };
-}
-
-/**
- * Gather role hints from neighbor entries into the provided index.
- *
- * @param {{
- * byId: Map,
- * byNum: Map,
- * detailsById: Map,
- * detailsByNum: Map,
- * }} index Role index maps.
- * @param {Array