From e04fab5b199125f3e5cd38c7afc51e8e1c17e58a Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sat, 2 May 2026 22:35:34 +0200 Subject: [PATCH] web: refactor 7/7 main js (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * web: refactor 7/7 main js * web: refactor 7/7 main js * web: address review feedback on 7/7 main.js refactor * Consolidate the duplicate ./main/format-utils.js import block in main.js so all symbols come from a single, alphabetised import statement (review item: "Important — Duplicate format-utils.js import block"). * Replace the leftover stale JSDoc atop +createOfflineTileLayer+ with one clear "do not inline" DI block, and likewise expand the +fetchMessages+ wrapper docstring so future readers see the shim's purpose without hunting for the implementation (review nit: "thin wrappers ... worth a one-line JSDoc"). * Add per-module unit tests under public/assets/js/app/main/__tests__/ covering every previously- uncovered branch in the 9 modules codecov flagged: tile-coords, sort-comparators, fullscreen-helpers, format-utils, data-fetchers, data-merge, tooltip-html, long-link-router, and offline-tile-layer. This drives the codecov patch percentage on PR #778 from 78.99% to ~100% on the new modules and unblocks the codecov/patch gate. JS suite: 1,114 tests, 0 failures. --- web/public/assets/js/app/main.js | 1122 ++--------------- .../app/main/__tests__/data-fetchers.test.js | 346 +++++ .../js/app/main/__tests__/data-merge.test.js | 242 ++++ .../app/main/__tests__/format-utils.test.js | 405 ++++++ .../main/__tests__/fullscreen-helpers.test.js | 152 +++ .../main/__tests__/long-link-router.test.js | 188 +++ .../main/__tests__/offline-tile-layer.test.js | 228 ++++ .../main/__tests__/sort-comparators.test.js | 128 ++ .../js/app/main/__tests__/tile-coords.test.js | 49 + .../app/main/__tests__/tooltip-html.test.js | 119 ++ web/public/assets/js/app/main/constants.js | 36 + .../assets/js/app/main/data-fetchers.js | 193 +++ web/public/assets/js/app/main/data-merge.js | 181 +++ .../assets/js/app/main/filter-helpers.js | 53 + web/public/assets/js/app/main/format-utils.js | 282 +++++ .../assets/js/app/main/fullscreen-helpers.js | 54 + .../assets/js/app/main/long-link-router.js | 125 ++ .../assets/js/app/main/offline-tile-layer.js | 134 ++ .../assets/js/app/main/protocol-icons.js | 57 + .../assets/js/app/main/short-html-renderer.js | 92 ++ .../assets/js/app/main/sort-comparators.js | 83 ++ web/public/assets/js/app/main/tile-coords.js | 44 + web/public/assets/js/app/main/tooltip-html.js | 78 ++ 23 files changed, 3367 insertions(+), 1024 deletions(-) create mode 100644 web/public/assets/js/app/main/__tests__/data-fetchers.test.js create mode 100644 web/public/assets/js/app/main/__tests__/data-merge.test.js create mode 100644 web/public/assets/js/app/main/__tests__/format-utils.test.js create mode 100644 web/public/assets/js/app/main/__tests__/fullscreen-helpers.test.js create mode 100644 web/public/assets/js/app/main/__tests__/long-link-router.test.js create mode 100644 web/public/assets/js/app/main/__tests__/offline-tile-layer.test.js create mode 100644 web/public/assets/js/app/main/__tests__/sort-comparators.test.js create mode 100644 web/public/assets/js/app/main/__tests__/tile-coords.test.js create mode 100644 web/public/assets/js/app/main/__tests__/tooltip-html.test.js create mode 100644 web/public/assets/js/app/main/constants.js create mode 100644 web/public/assets/js/app/main/data-fetchers.js create mode 100644 web/public/assets/js/app/main/data-merge.js create mode 100644 web/public/assets/js/app/main/filter-helpers.js create mode 100644 web/public/assets/js/app/main/format-utils.js create mode 100644 web/public/assets/js/app/main/fullscreen-helpers.js create mode 100644 web/public/assets/js/app/main/long-link-router.js create mode 100644 web/public/assets/js/app/main/offline-tile-layer.js create mode 100644 web/public/assets/js/app/main/protocol-icons.js create mode 100644 web/public/assets/js/app/main/short-html-renderer.js create mode 100644 web/public/assets/js/app/main/sort-comparators.js create mode 100644 web/public/assets/js/app/main/tile-coords.js create mode 100644 web/public/assets/js/app/main/tooltip-html.js diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index abfcb5d..17174b7 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -118,6 +118,74 @@ import { protocolIconPrefixHtml, } from './protocol-helpers.js'; +// Pure helpers extracted into focused submodules under ``./main/``. They +// remain locally callable inside ``initializeApp`` because module-level +// bindings are visible in every nested scope. +import { + cssEscape, + fmtCoords, + fmtHw, + formatDate, + formatShortInfoUptime, + formatSnrDisplay, + formatTime, + pad, + parseNodeNumericRef, + pickFirstProperty, + pickNumericProperty, + resolveTimestampSeconds, + shortInfoValueOrDash, + timeAgo, + timeHum, + toFiniteNumber, +} from './main/format-utils.js'; +import { + applyNodeNameFallback, + extractIdentifierFromHref, + getNodeDisplayNameForOverlay, + getNodeIdentifierFromLink, + shouldHandleNodeLongLink, +} from './main/long-link-router.js'; +import { + buildTelemetryIndex, + mergePositionsIntoNodes, + mergeTelemetryIntoNodes, +} from './main/data-merge.js'; +import { renderShortHtml } from './main/short-html-renderer.js'; +import { + NODE_LIMIT, + SNAPSHOT_LIMIT, + TRACE_LIMIT, + TRACE_MAX_AGE_SECONDS, +} from './main/constants.js'; +import { + fetchNeighbors, + fetchNodeById, + fetchNodes, + fetchPositions, + fetchTelemetry, + fetchTraces, + filterRecentTraces, + resolveSnapshotLimit, + fetchMessages as fetchMessagesImpl, +} from './main/data-fetchers.js'; +import { + compareNumber, + compareString, + hasNumberValue, + hasStringValue, +} from './main/sort-comparators.js'; +import { makeRoleFilterKey, normalizeFilterProtocol } from './main/filter-helpers.js'; +import { tileToLat, tileToLon } from './main/tile-coords.js'; +import { + buildMeshcoreIconImg, + buildMeshtasticIconImg, + buildProtocolIconImg, +} from './main/protocol-icons.js'; +import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from './main/tooltip-html.js'; +import { createOfflineTileLayer as createOfflineTileLayerImpl } from './main/offline-tile-layer.js'; +import { getActiveFullscreenElement, legendClickHandler } from './main/fullscreen-helpers.js'; + /** * Entry point for the interactive dashboard. Wires up event listeners, * initializes the map, and triggers the first data refresh cycle. @@ -248,10 +316,10 @@ export function initializeApp(config) { /** Whether the very first full fetch has completed. */ let initialFetchDone = false; - const NODE_LIMIT = 1000; - const TRACE_LIMIT = 200; - const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60; - const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW; + // NODE_LIMIT, TRACE_LIMIT, TRACE_MAX_AGE_SECONDS, and SNAPSHOT_LIMIT are + // imported from ``./main/constants.js`` so the helpers extracted into + // ``./main/data-fetchers.js`` and ``./main/data-merge.js`` share the same + // values without re-declaring them here. const CHAT_LIMIT = MESSAGE_LIMIT; const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60; const REFRESH_MS = config.refreshMs; @@ -320,68 +388,6 @@ export function initializeApp(config) { } } - /** - * Determine whether the provided value contains a non-empty string. - * - * @param {*} value Candidate value extracted from a node record. - * @returns {boolean} True when the value is a non-empty string. - */ - function hasStringValue(value) { - if (value == null) return false; - return String(value).trim().length > 0; - } - - /** - * Determine whether the provided value can be interpreted as a finite number. - * - * @param {*} value Candidate value extracted from a node record. - * @returns {boolean} True when the value parses to a finite number. - */ - function hasNumberValue(value) { - if (value == null || value === '') return false; - const num = typeof value === 'number' ? value : Number(value); - return Number.isFinite(num); - } - - /** - * Locale-aware comparator for string table values. - * - * @param {*} a First value. - * @param {*} b Second value. - * @returns {number} Comparator result compatible with ``Array.prototype.sort``. - */ - function compareString(a, b) { - const strA = (a == null ? '' : String(a)).trim(); - const strB = (b == null ? '' : String(b)).trim(); - const hasA = strA.length > 0; - const hasB = strB.length > 0; - if (!hasA && !hasB) return 0; - if (!hasA) return 1; - if (!hasB) return -1; - return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' }); - } - - /** - * Comparator for numeric table values that tolerates string inputs. - * - * @param {*} a First value. - * @param {*} b Second value. - * @returns {number} Comparator result for ``Array.prototype.sort``. - */ - function compareNumber(a, b) { - const numA = typeof a === 'number' ? a : Number(a); - const numB = typeof b === 'number' ? b : Number(b); - const validA = Number.isFinite(numA); - const validB = Number.isFinite(numB); - if (validA && validB) { - if (numA === numB) return 0; - return numA < numB ? -1 : 1; - } - if (validA) return -1; - if (validB) return 1; - return 0; - } - /** * Sort a list of nodes using the active table sorter configuration. * @@ -645,21 +651,6 @@ export function initializeApp(config) { ); } - /** - * Resolve the element currently being displayed in fullscreen mode. - * - * @returns {Element|null} Active fullscreen element if any. - */ - function getActiveFullscreenElement() { - if (typeof document === 'undefined') return null; - return ( - document.fullscreenElement || - document.webkitFullscreenElement || - document.msFullscreenElement || - null - ); - } - /** * Determine whether the map container is currently in fullscreen mode. * @@ -856,13 +847,6 @@ export function initializeApp(config) { * @param {function(Event): void} fn Handler body. * @returns {function(Event): void} Full click listener. */ - function legendClickHandler(fn) { - return (event) => { - event.preventDefault(); - event.stopPropagation(); - fn(event); - }; - } /** * Canonical protocol token for use in compound filter keys. @@ -873,28 +857,6 @@ export function initializeApp(config) { * @param {string|null|undefined} protocol Raw protocol value. * @returns {'meshtastic'|'meshcore'} Normalised protocol token. */ - function normalizeFilterProtocol(protocol) { - return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic'; - } - - /** - * Build a compound filter key that encodes both protocol and role. - * - * Using compound keys avoids collisions between role names that appear in - * both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter - * set stores these keys so that clicking the MeshCore SENSOR button only - * includes MeshCore SENSOR nodes, not Meshtastic ones. - * - * @param {*} role Raw role value from the API. - * @param {string|null|undefined} protocol Protocol string from the API. - * @returns {string} Compound key in the form ``":"``. - */ - function makeRoleFilterKey(role, protocol) { - return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`; - } - - - /** * Lazily create the floating map status element used for progress messages. * @@ -1056,132 +1018,21 @@ export function initializeApp(config) { } /** - * Convert a tile X coordinate to longitude degrees. + * Closure-bound dependency-injection bridge to + * ``createOfflineTileLayerImpl``. The implementation in + * ``./main/offline-tile-layer.js`` is dependency-free so it can be unit + * tested standalone; this shim feeds it the Leaflet global from + * ``initializeApp``'s closure. **Do not inline** — keeping the wrapper + * preserves Leaflet-as-parameter so tests can pass a fake. * - * @param {number} x Tile X index. - * @param {number} z Zoom level. - * @returns {number} Longitude in degrees. - */ - function tileToLon(x, z) { - return (x / Math.pow(2, z)) * 360 - 180; - } - - /** - * Convert a tile Y coordinate to latitude degrees. + * Returns ``null`` when Leaflet is absent to preserve the original + * semantics. * - * @param {number} y Tile Y index. - * @param {number} z Zoom level. - * @returns {number} Latitude in degrees. - */ - function tileToLat(y, z) { - const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); - return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); - } - - /** - * Create a minimal Leaflet tile layer that renders offline tiles from cache. - * - * @returns {L.GridLayer} Configured tile layer instance. + * @returns {Object|null} Configured Leaflet ``GridLayer`` or ``null``. */ function createOfflineTileLayer() { if (!hasLeaflet) return null; - const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' }); - /** @type {HTMLElement|null} */ - let cachedOfflineFallbackTile = null; - - /** - * Provide a minimal placeholder tile when canvas rendering is not available. - * - * @param {number} size Pixel width and height of the tile. - * @returns {HTMLElement} Cloned fallback element ready for Leaflet consumption. - */ - function getOfflineFallbackTile(size) { - if (!cachedOfflineFallbackTile) { - const placeholder = document.createElement('div'); - placeholder.className = 'offline-tile-fallback'; - placeholder.style.width = `${size}px`; - placeholder.style.height = `${size}px`; - placeholder.style.backgroundColor = 'rgba(33, 66, 110, 0.92)'; - placeholder.style.display = 'flex'; - placeholder.style.alignItems = 'center'; - placeholder.style.justifyContent = 'center'; - placeholder.style.color = 'rgba(255, 255, 255, 0.6)'; - placeholder.style.font = 'bold 14px system-ui, sans-serif'; - placeholder.style.textTransform = 'uppercase'; - placeholder.textContent = 'Offline tile'; - cachedOfflineFallbackTile = placeholder; - } - return /** @type {HTMLElement} */ (cachedOfflineFallbackTile.cloneNode(true)); - } - - /** - * Render a placeholder tile for offline map usage. - * - * @param {{x: number, y: number, z: number}} coords Tile coordinates supplied by Leaflet. - * @returns {HTMLElement} Tile node containing placeholder artwork. - */ - offlineLayer.createTile = coords => { - const size = 256; - const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext('2d'); - if (!ctx) { - console.warn('Canvas 2D context unavailable for offline tile rendering. Using fallback placeholder.'); - return getOfflineFallbackTile(size); - } - try { - const gradient = ctx.createLinearGradient(0, 0, size, size); - gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)'); - gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)'); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, size, size); - - ctx.strokeStyle = 'rgba(255,255,255,0.12)'; - ctx.lineWidth = 1; - const steps = 4; - for (let i = 1; i < steps; i++) { - const pos = (size / steps) * i; - ctx.beginPath(); - ctx.moveTo(pos, 0); - ctx.lineTo(pos, size); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(0, pos); - ctx.lineTo(size, pos); - ctx.stroke(); - } - - const west = tileToLon(coords.x, coords.z); - const east = tileToLon(coords.x + 1, coords.z); - const north = tileToLat(coords.y, coords.z); - const south = tileToLat(coords.y + 1, coords.z); - - ctx.fillStyle = 'rgba(255,255,255,0.7)'; - ctx.font = '12px system-ui, sans-serif'; - ctx.textBaseline = 'top'; - ctx.fillText(`${west.toFixed(1)}°`, 8, 8); - ctx.textBaseline = 'bottom'; - ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8); - ctx.textAlign = 'right'; - ctx.textBaseline = 'top'; - ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8); - ctx.textBaseline = 'bottom'; - ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8); - - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = 'rgba(255,255,255,0.35)'; - ctx.font = 'bold 22px system-ui, sans-serif'; - ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2); - - return canvas; - } catch (error) { - console.error('Failed to render offline tile. Falling back to placeholder element.', error); - return getOfflineFallbackTile(size); - } - }; - return offlineLayer; + return createOfflineTileLayerImpl(L); } /** @@ -1461,29 +1312,6 @@ export function initializeApp(config) { * @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``. * @returns {HTMLImageElement} Icon element ready to append. */ - function buildProtocolIconImg(src, variantClass) { - const img = document.createElement('img'); - img.setAttribute('src', src); - img.setAttribute('alt', ''); - img.setAttribute('width', '12'); - img.setAttribute('height', '12'); - img.setAttribute('aria-hidden', 'true'); - img.setAttribute('loading', 'lazy'); - img.setAttribute('decoding', 'async'); - img.className = `protocol-icon ${variantClass}`; - return img; - } - - /** @returns {HTMLImageElement} Meshtastic protocol icon element. */ - function buildMeshtasticIconImg() { - return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic'); - } - - /** @returns {HTMLImageElement} MeshCore protocol icon element. */ - function buildMeshcoreIconImg() { - return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore'); - } - function updateNeighborLinesToggleState() { if (!neighborLinesToggleButton) return; const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines'; @@ -1909,67 +1737,8 @@ export function initializeApp(config) { }); // --- Helpers --- - /** - * Render a short name badge with role-based styling. - * - * @param {string} short Short node identifier. - * @param {string} role Node role string. - * @param {string} longName Full node name. - * @param {?Object} nodeData Optional node metadata attached to the badge. - * @returns {string} HTML snippet describing the badge. - */ - function renderShortHtml(short, role, longName, nodeData = null) { - const safeTitle = longName ? escapeHtml(String(longName)) : ''; - const titleAttr = safeTitle ? ` title="${safeTitle}"` : ''; - const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role)); - let infoAttr = ''; - if (nodeData && typeof nodeData === 'object') { - const info = { - nodeId: nodeData.node_id ?? nodeData.nodeId ?? '', - nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null, - shortName: short != null ? String(short) : (nodeData.short_name ?? ''), - longName: nodeData.long_name ?? longName ?? '', - role: roleValue, - hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '', - telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null, - }; - Object.assign(info, collectTelemetryMetrics(nodeData)); - const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`]; - const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : ''; - if (attrNodeIdRaw) { - attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`); - } - const attrNodeNum = Number(info.nodeNum); - if (Number.isFinite(attrNodeNum)) { - attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`); - } - infoAttr = attrParts.join(''); - } - if (!short) { - return ` ? `; - } - // Pad the label for the badge. For plain-ASCII names that are already - // 4 characters (meshtastic always stores exactly 4) no padding is added. - // Shorter names or names containing emoji/non-ASCII get a single space - // on each side — grapheme width varies too much for character-count - // centering to work reliably. - const raw = String(short); - const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter - ? [...new Intl.Segmenter().segment(raw)].length - : raw.length; - let centred; - if (graphemeCount >= 4) { - centred = raw; - } else { - centred = ` ${raw} `; - } - const padded = escapeHtml(centred).replace(/ /g, ' '); - const protocol = nodeData?.protocol ?? null; - const color = getRoleColor(roleValue, protocol); - const textColor = getRoleTextColor(roleValue, protocol); - const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`; - return `${padded}`; - } + // ``renderShortHtml`` is imported from ``./main/short-html-renderer.js`` — + // see the module-level imports near the top of this file. const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {}); potatoMeshNamespace.renderShortHtml = renderShortHtml; @@ -1984,47 +1753,6 @@ export function initializeApp(config) { * @param {string} value Raw selector fragment. * @returns {string} Escaped selector fragment safe for interpolation. */ - function cssEscape(value) { - if (typeof value !== 'string' || value.length === 0) { - return ''; - } - if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') { - return window.CSS.escape(value); - } - return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`); - } - - /** - * Parse a node identifier or numeric reference into a finite number. - * - * @param {*} ref Identifier or numeric reference. - * @returns {number|null} Parsed number or ``null``. - */ - function parseNodeNumericRef(ref) { - if (ref == null) return null; - if (typeof ref === 'number') { - return Number.isFinite(ref) ? ref : null; - } - if (typeof ref === 'string') { - const trimmed = ref.trim(); - if (!trimmed) return null; - if (trimmed.startsWith('!')) { - const hex = trimmed.slice(1); - if (!/^[0-9A-Fa-f]+$/.test(hex)) return null; - const parsedHex = Number.parseInt(hex, 16); - return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null; - } - if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) { - const parsedHex = Number.parseInt(trimmed, 16); - return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null; - } - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : null; - } - const parsed = Number(ref); - return Number.isFinite(parsed) ? parsed : null; - } - /** * Populate the ``nodesById`` index for quick lookups. * @@ -2201,29 +1929,6 @@ export function initializeApp(config) { return lines.join('
'); } - /** - * Format uptime values for the short-info overlay. - * - * @param {*} value Raw uptime value. - * @returns {string} Human readable uptime string. - */ - function formatShortInfoUptime(value) { - if (value == null || value === '') return ''; - const num = Number(value); - if (!Number.isFinite(num)) return ''; - return num === 0 ? '0s' : timeHum(num); - } - - /** - * Format overlay values with an em dash fallback when blank. - * - * @param {*} value Candidate value. - * @returns {string} Formatted value or em dash. - */ - function shortInfoValueOrDash(value) { - return value != null && value !== '' ? String(value) : '—'; - } - /** * Transform a node-shaped payload into the overlay data format. * @@ -2774,60 +2479,6 @@ export function initializeApp(config) { }); } - /** - * Build tooltip HTML showing styled short-name badges for a trace path. - * - * @param {Array} pathNodes Ordered node payloads along the trace. - * @returns {string} HTML fragment or ``''`` when unavailable. - */ - function buildTraceTooltipHtml(pathNodes) { - if (!Array.isArray(pathNodes) || pathNodes.length < 2) { - return ''; - } - const parts = pathNodes - .map(node => { - if (!node || typeof node !== 'object') { - return null; - } - const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : ''); - const long = normalizeNodeNameValue(node.long_name ?? node.longName) || ''; - return renderShortHtml(short, node.role, long, node); - }) - .filter(Boolean); - if (!parts.length) return ''; - const arrow = ''; - return `
${parts.join(arrow)}
`; - } - - /** - * Build tooltip HTML for a neighbor segment showing styled short-name badges. - * - * @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor. - * @returns {string} HTML fragment or ``''`` when unavailable. - */ - function buildNeighborTooltipHtml(segment) { - if (!segment) return ''; - const sourceNode = segment.sourceNode || null; - const targetNode = segment.targetNode || null; - const sourceShort = normalizeNodeNameValue( - segment.sourceShortName || - (sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) || - (sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '') - ); - const targetShort = normalizeNodeNameValue( - segment.targetShortName || - (targetNode ? targetNode.short_name ?? targetNode.shortName : null) || - (targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '') - ); - if (!sourceShort || !targetShort) return ''; - const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || ''; - const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || ''; - const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {}); - const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {}); - const arrow = ''; - return `
${sourceHtml}${arrow}${targetHtml}
`; - } - /** * Resolve a node reference for a trace hop using cached node indices. * @@ -2955,62 +2606,6 @@ export function initializeApp(config) { return null; } - /** - * Retrieve the first present property value from a collection of objects. - * - * @param {Array} sources Candidate objects. - * @param {Array} keys Ordered property names to inspect. - * @returns {*} First present non-blank value or ``null`` when absent. - */ - function pickFirstProperty(sources, keys) { - if (!Array.isArray(sources) || !Array.isArray(keys)) { - return null; - } - for (const source of sources) { - if (!source || typeof source !== 'object') continue; - for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(source, key)) continue; - const value = source[key]; - if (value == null) continue; - if (typeof value === 'string') { - const trimmed = value.trim(); - if (trimmed.length === 0) { - continue; - } - return trimmed; - } - return value; - } - } - return null; - } - - /** - * Retrieve the first finite numeric property from candidate objects. - * - * @param {Array} sources Candidate objects. - * @param {Array} keys Ordered property names to inspect. - * @returns {?number} First finite number when available. - */ - function pickNumericProperty(sources, keys) { - if (!Array.isArray(sources) || !Array.isArray(keys)) { - return null; - } - for (const source of sources) { - if (!source || typeof source !== 'object') continue; - for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(source, key)) continue; - const raw = source[key]; - if (raw == null || raw === '') continue; - const num = typeof raw === 'number' ? raw : Number(raw); - if (Number.isFinite(num)) { - return num; - } - } - } - return null; - } - /** * Describe an encrypted message when the payload cannot be decrypted. * @@ -3399,547 +2994,26 @@ export function initializeApp(config) { } /** - * Pad a numeric value with leading zeros. + * Closure-bound dependency-injection bridge to ``fetchMessagesImpl``. The + * implementation in ``./main/data-fetchers.js`` is dependency-free so it + * can be unit tested standalone; this shim feeds it the dashboard's + * ``CHAT_ENABLED`` flag and ``normaliseMessageLimit`` from + * ``initializeApp``'s closure. **Do not inline** — keeping the wrapper + * preserves the chat-enabled and limit-normalisation flags as injected + * dependencies so the underlying fetcher remains pure. * - * @param {number} n Numeric value. - * @returns {string} Padded string. - */ - function pad(n) { return String(n).padStart(2, "0"); } - - /** - * Format a ``Date`` object as ``HH:MM``. - * - * @param {Date} d Date instance. - * @returns {string} Time string. - */ - function formatTime(d) { - return pad(d.getHours()) + ":" + - pad(d.getMinutes()) + ":" + - pad(d.getSeconds()); - } - - /** - * Format a ``Date`` object as ``YYYY-MM-DD``. - * - * @param {Date} d Date instance. - * @returns {string} Date string. - */ - function formatDate(d) { - return d.getFullYear() + "-" + - pad(d.getMonth() + 1) + "-" + - pad(d.getDate()); - } - - /** - * Format hardware model strings for display. - * - * @param {*} v Raw hardware model value. - * @returns {string} Sanitised string. - */ - function fmtHw(v) { - return v && v !== "UNSET" ? String(v) : ""; - } - - /** - * Format coordinate values with a configurable precision. - * - * @param {*} v Raw coordinate value. - * @param {number} [d=5] Decimal precision. - * @returns {string} Formatted coordinate string. - */ - function fmtCoords(v, d = 5) { - if (v == null || v === '') return ""; - const n = Number(v); - return Number.isFinite(n) ? n.toFixed(d) : ""; - } - - /** - * Format SNR readings with a ``dB`` suffix. - * - * @param {*} value Raw SNR value. - * @returns {string} Formatted SNR string. - */ - function formatSnrDisplay(value) { - if (value == null || value === '') return ''; - const n = Number(value); - if (!Number.isFinite(n)) return ''; - return `${n.toFixed(1)} dB`; - } - - /** - * Determine whether a long name link should trigger the overlay behaviour. - * - * @param {?Element} link Anchor element. - * @returns {boolean} ``true`` when the link participates in overlays. - */ - function shouldHandleNodeLongLink(link) { - if (!link || !link.dataset) return false; - if ('nodeDetailLink' in link.dataset && link.dataset.nodeDetailLink === 'false') { - return false; - } - return true; - } - - /** - * Extract the canonical node identifier from the provided link element. - * - * @param {?Element} link Anchor element. - * @returns {string} Canonical node identifier or ``''`` when unavailable. - */ - function getNodeIdentifierFromLink(link) { - if (!link) return ''; - const datasetIdentifier = link.dataset && typeof link.dataset.nodeId === 'string' - ? canonicalNodeIdentifier(link.dataset.nodeId) - : null; - if (datasetIdentifier) { - return datasetIdentifier; - } - if (typeof link.getAttribute === 'function') { - const attrHref = link.getAttribute('href'); - const canonicalFromAttr = extractIdentifierFromHref(attrHref); - if (canonicalFromAttr) { - return canonicalFromAttr; - } - } - if (typeof link.href === 'string') { - const canonicalFromProperty = extractIdentifierFromHref(link.href); - if (canonicalFromProperty) { - return canonicalFromProperty; - } - } - return ''; - } - - /** - * Extract the canonical identifier from a node detail hyperlink. - * - * @param {string} href Link href attribute. - * @returns {string} Canonical identifier or ``''``. - */ - function extractIdentifierFromHref(href) { - if (typeof href !== 'string' || href.length === 0) { - return ''; - } - const match = href.match(/\/nodes\/(![^/?#]+)/i); - if (!match || !match[1]) { - return ''; - } - try { - const decoded = decodeURIComponent(match[1]); - return canonicalNodeIdentifier(decoded) ?? ''; - } catch { - return canonicalNodeIdentifier(match[1]) ?? ''; - } - } - - /** - * Determine the preferred display name for overlay content. - * - * @param {Object} node Node payload. - * @returns {string} Friendly display name. - */ - function getNodeDisplayNameForOverlay(node) { - if (!node || typeof node !== 'object') return ''; - return ( - normalizeNodeNameValue(node.long_name ?? node.longName) || - normalizeNodeNameValue(node.short_name ?? node.shortName) || - (typeof node.node_id === 'string' ? node.node_id : '') - ); - } - - /** - * Populate missing node name fields with sensible defaults. - * - * @param {Object} node Node payload. - * @returns {Object} Updated node reference. - */ - function applyNodeNameFallback(node) { - if (!node || typeof node !== 'object') return; - const short = normalizeNodeNameValue(node.short_name ?? node.shortName); - const long = normalizeNodeNameValue(node.long_name ?? node.longName); - if (short || long) return; - const nodeId = normalizeNodeNameValue(node.node_id ?? node.nodeId); - if (!nodeId) return; - const fallbackShort = nodeId.slice(-4); - const fallbackLong = `Meshtastic ${nodeId}`; - node.short_name = fallbackShort; - node.long_name = fallbackLong; - if ('shortName' in node) node.shortName = fallbackShort; - if ('longName' in node) node.longName = fallbackLong; - } - - /** - * Convert a duration in seconds into a human readable string. - * - * @param {number} unixSec Duration in seconds. - * @returns {string} Human readable representation. - */ - function timeHum(unixSec) { - if (!unixSec) return ""; - if (unixSec < 0) return "0s"; - if (unixSec < 60) return `${unixSec}s`; - if (unixSec < 3600) return `${Math.floor(unixSec/60)}m ${Math.floor((unixSec%60))}s`; - if (unixSec < 86400) return `${Math.floor(unixSec/3600)}h ${Math.floor((unixSec%3600)/60)}m`; - return `${Math.floor(unixSec/86400)}d ${Math.floor((unixSec%86400)/3600)}h`; - } - - /** - * Return a relative time string describing how long ago an event occurred. - * - * @param {number} unixSec Timestamp in seconds. - * @param {number} [nowSec] Reference timestamp. - * @returns {string} Human readable relative time. - */ - function timeAgo(unixSec, nowSec = Date.now()/1000) { - if (!unixSec) return ""; - const diff = Math.floor(nowSec - Number(unixSec)); - if (diff < 0) return "0s"; - if (diff < 60) return `${diff}s`; - if (diff < 3600) return `${Math.floor(diff/60)}m ${Math.floor((diff%60))}s`; - if (diff < 86400) return `${Math.floor(diff/3600)}h ${Math.floor((diff%3600)/60)}m`; - return `${Math.floor(diff/86400)}d ${Math.floor((diff%86400)/3600)}h`; - } - - /** - * Determine how many snapshots should be requested from the API to build a - * richer aggregate. - * - * @param {number} requestedLimit Desired number of unique entities. - * @param {number} [maxLimit=NODE_LIMIT] Maximum rows accepted by the API. - * @returns {number} Effective request limit honouring {@link SNAPSHOT_LIMIT}. - */ - function resolveSnapshotLimit(requestedLimit, maxLimit = NODE_LIMIT) { - const base = Number.isFinite(requestedLimit) && requestedLimit > 0 - ? Math.floor(requestedLimit) - : maxLimit; - const expanded = base * SNAPSHOT_LIMIT; - const candidate = expanded > base ? expanded : base; - return Math.min(candidate, maxLimit); - } - - /** - * Fetch the latest nodes from the JSON API. - * - * @param {number} [limit=NODE_LIMIT] Maximum number of records. - * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. - * @returns {Promise>} Parsed node payloads. - */ - async function fetchNodes(limit = NODE_LIMIT, since = 0) { - const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); - let url = `/api/nodes?limit=${effectiveLimit}`; - if (since > 0) url += `&since=${since}`; - const r = await fetch(url, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Retrieve a single node record by identifier from the API. - * - * @param {string} nodeId Canonical node identifier. - * @returns {Promise} Parsed node payload or null when absent. - */ - async function fetchNodeById(nodeId) { - if (typeof nodeId !== 'string') return null; - const trimmed = nodeId.trim(); - if (trimmed.length === 0) return null; - const r = await fetch(`/api/nodes/${encodeURIComponent(trimmed)}`, { cache: 'default' }); - if (r.status === 404) return null; - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Fetch recent messages from the JSON API. - * - * @param {number} [limit=NODE_LIMIT] Maximum number of rows. + * @param {number} [limit=MESSAGE_LIMIT] Requested limit. * @param {{ encrypted?: boolean, since?: number }} [options] Optional retrieval flags. - * @returns {Promise>} Parsed message payloads. + * @returns {Promise>} Message payloads. */ - async function fetchMessages(limit = MESSAGE_LIMIT, options = {}) { - if (!CHAT_ENABLED) return []; - const safeLimit = normaliseMessageLimit(limit); - const params = new URLSearchParams({ limit: String(safeLimit) }); - if (options && options.encrypted) { - params.set('encrypted', 'true'); - } - if (options && options.since > 0) { - params.set('since', String(options.since)); - } - const query = params.toString(); - const r = await fetch(`/api/messages?${query}`, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Fetch neighbour information from the JSON API. - * - * @param {number} [limit=NODE_LIMIT] Maximum number of rows. - * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. - * @returns {Promise>} Parsed neighbour payloads. - */ - async function fetchNeighbors(limit = NODE_LIMIT, since = 0) { - const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); - let url = `/api/neighbors?limit=${effectiveLimit}`; - if (since > 0) url += `&since=${since}`; - const r = await fetch(url, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Fetch traceroute observations from the JSON API. - * - * @param {number} [limit=TRACE_LIMIT] Maximum number of records. - * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. - * @returns {Promise>} Parsed trace payloads. - */ - async function fetchTraces(limit = TRACE_LIMIT, since = 0) { - const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TRACE_LIMIT; - const effectiveLimit = Math.min(safeLimit, NODE_LIMIT); - let url = `/api/traces?limit=${effectiveLimit}`; - if (since > 0) url += `&since=${since}`; - const r = await fetch(url, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - const traces = await r.json(); - return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS); - } - - /** - * Fetch telemetry entries from the JSON API. - * - * @param {number} [limit=NODE_LIMIT] Maximum number of rows. - * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. - * @returns {Promise>} Parsed telemetry payloads. - */ - async function fetchTelemetry(limit = NODE_LIMIT, since = 0) { - const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); - let url = `/api/telemetry?limit=${effectiveLimit}`; - if (since > 0) url += `&since=${since}`; - const r = await fetch(url, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Fetch position packets from the JSON API. - * - * @param {number} [limit=NODE_LIMIT] Maximum number of rows. - * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. - * @returns {Promise>} Parsed position payloads. - */ - async function fetchPositions(limit = NODE_LIMIT, since = 0) { - const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); - let url = `/api/positions?limit=${effectiveLimit}`; - if (since > 0) url += `&since=${since}`; - const r = await fetch(url, { cache: 'default' }); - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - } - - /** - * Convert arbitrary values to finite numbers when possible. - * - * @param {*} value Raw value. - * @returns {number|null} Finite number or null when conversion fails. - */ - function toFiniteNumber(value) { - if (value == null || value === '') return null; - const num = typeof value === 'number' ? value : Number(value); - return Number.isFinite(num) ? num : null; - } - - - /** - * Determine the best-effort timestamp in seconds from numeric or ISO values. - * - * @param {*} numeric Numeric timestamp. - * @param {*} isoString ISO formatted timestamp. - * @returns {number|null} Timestamp in seconds. - */ - function resolveTimestampSeconds(numeric, isoString) { - const parsedNumeric = toFiniteNumber(numeric); - if (parsedNumeric != null) return parsedNumeric; - if (typeof isoString === 'string' && isoString.length) { - const parsedIso = Date.parse(isoString); - if (Number.isFinite(parsedIso)) { - return parsedIso / 1000; - } - } - return null; - } - - /** - * Filter trace entries to discard packets older than the configured window. - * - * @param {Array} traces Trace payloads. - * @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds. - * @returns {Array} Recent trace entries. - */ - function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) { - if (!Array.isArray(traces)) { - return []; - } - if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) { - return [...traces]; - } - const nowSeconds = Math.floor(Date.now() / 1000); - const cutoff = nowSeconds - maxAgeSeconds; - return traces.filter(trace => { - const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso); - return rxTime != null && rxTime >= cutoff; + function fetchMessages(limit = MESSAGE_LIMIT, options = {}) { + return fetchMessagesImpl(limit, { + ...options, + chatEnabled: CHAT_ENABLED, + normaliseMessageLimit, }); } - /** - * Merge recent position packets into the node list. - * - * @param {Array} nodes Node payloads. - * @param {Array} positions Position entries. - * @returns {Array} Updated node collection. - */ - function mergePositionsIntoNodes(nodes, positions) { - if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return; - - const nodesById = new Map(); - for (const node of nodes) { - if (!node || typeof node !== 'object') continue; - const key = typeof node.node_id === 'string' ? node.node_id : null; - if (key) nodesById.set(key, node); - } - - if (nodesById.size === 0) return; - - const updated = new Set(); - for (const pos of positions) { - if (!pos || typeof pos !== 'object') continue; - const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null; - if (!nodeId || updated.has(nodeId)) continue; - const node = nodesById.get(nodeId); - if (!node) continue; - - const lat = toFiniteNumber(pos.latitude); - const lon = toFiniteNumber(pos.longitude); - if (lat == null || lon == null) continue; - - const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso); - const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso); - if (currentTimestamp != null) { - if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) { - continue; - } - } - - updated.add(nodeId); - node.latitude = lat; - node.longitude = lon; - - const alt = toFiniteNumber(pos.altitude); - if (alt != null) node.altitude = alt; - - const posTime = toFiniteNumber(pos.position_time); - if (posTime != null) { - node.position_time = posTime; - node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length - ? pos.position_time_iso - : new Date(posTime * 1000).toISOString(); - } else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) { - node.pos_time_iso = pos.position_time_iso; - } - - if (pos.location_source != null && pos.location_source !== '') { - node.location_source = pos.location_source; - } - - const precision = toFiniteNumber(pos.precision_bits); - if (precision != null) node.precision_bits = precision; - } - } - - /** - * Build a lookup table of telemetry entries keyed by node identifier. - * - * @param {Array} entries Telemetry payloads. - * @returns {Map} Indexed telemetry data. - */ - function buildTelemetryIndex(entries) { - const byNodeId = new Map(); - const byNodeNum = new Map(); - if (!Array.isArray(entries)) { - return { byNodeId, byNodeNum }; - } - for (const entry of entries) { - if (!entry || typeof entry !== 'object') continue; - const nodeId = typeof entry.node_id === 'string' ? entry.node_id : (typeof entry.nodeId === 'string' ? entry.nodeId : null); - const nodeNumRaw = entry.node_num ?? entry.nodeNum; - const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw); - const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime); - const telemetryTime = toFiniteNumber(entry.telemetry_time ?? entry.telemetryTime); - const timestamp = rxTime != null ? rxTime : telemetryTime != null ? telemetryTime : Number.NEGATIVE_INFINITY; - if (nodeId) { - const existing = byNodeId.get(nodeId); - if (!existing || timestamp > existing.timestamp) { - byNodeId.set(nodeId, { entry, timestamp }); - } - } - if (Number.isFinite(nodeNum)) { - const existing = byNodeNum.get(nodeNum); - if (!existing || timestamp > existing.timestamp) { - byNodeNum.set(nodeNum, { entry, timestamp }); - } - } - } - return { byNodeId, byNodeNum }; - } - - /** - * Merge telemetry metrics into the node list. - * - * @param {Array} nodes Node payloads. - * @param {Array} telemetryEntries Telemetry data. - * @returns {Array} Updated node collection. - */ - function mergeTelemetryIntoNodes(nodes, telemetryEntries) { - if (!Array.isArray(nodes) || !nodes.length) return; - const { byNodeId, byNodeNum } = buildTelemetryIndex(telemetryEntries); - for (const node of nodes) { - if (!node || typeof node !== 'object') continue; - const nodeId = typeof node.node_id === 'string' ? node.node_id : (typeof node.nodeId === 'string' ? node.nodeId : null); - const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum; - const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw); - let telemetryEntry = null; - if (nodeId && byNodeId.has(nodeId)) { - telemetryEntry = byNodeId.get(nodeId).entry; - } else if (Number.isFinite(nodeNum) && byNodeNum.has(nodeNum)) { - telemetryEntry = byNodeNum.get(nodeNum).entry; - } - if (!telemetryEntry || typeof telemetryEntry !== 'object') continue; - const metrics = { - battery_level: toFiniteNumber(telemetryEntry.battery_level ?? telemetryEntry.batteryLevel), - voltage: toFiniteNumber(telemetryEntry.voltage), - uptime_seconds: toFiniteNumber(telemetryEntry.uptime_seconds ?? telemetryEntry.uptimeSeconds), - channel_utilization: toFiniteNumber(telemetryEntry.channel_utilization ?? telemetryEntry.channelUtilization), - air_util_tx: toFiniteNumber(telemetryEntry.air_util_tx ?? telemetryEntry.airUtilTx), - temperature: toFiniteNumber(telemetryEntry.temperature), - relative_humidity: toFiniteNumber(telemetryEntry.relative_humidity ?? telemetryEntry.relativeHumidity), - barometric_pressure: toFiniteNumber(telemetryEntry.barometric_pressure ?? telemetryEntry.barometricPressure), - }; - for (const [key, value] of Object.entries(metrics)) { - if (value == null) continue; - node[key] = value; - } - const telemetryTime = toFiniteNumber(telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime); - if (telemetryTime != null) { - node.telemetry_time = telemetryTime; - } - const rxTime = toFiniteNumber(telemetryEntry.rx_time ?? telemetryEntry.rxTime); - if (rxTime != null) { - node.telemetry_rx_time = rxTime; - } - } - } - /** * Compute distance from the configured map center. * diff --git a/web/public/assets/js/app/main/__tests__/data-fetchers.test.js b/web/public/assets/js/app/main/__tests__/data-fetchers.test.js new file mode 100644 index 0000000..295d2bf --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/data-fetchers.test.js @@ -0,0 +1,346 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + fetchMessages, + fetchNeighbors, + fetchNodeById, + fetchNodes, + fetchPositions, + fetchTelemetry, + fetchTraces, + filterRecentTraces, + resolveSnapshotLimit, +} from '../data-fetchers.js'; +import { NODE_LIMIT, SNAPSHOT_LIMIT, TRACE_LIMIT } from '../constants.js'; + +/** + * Install a temporary global ``fetch`` stub that records every call and + * returns the supplied response. Returns a teardown handle that restores + * the previous binding and exposes the captured call list. + * + * @param {{ ok?: boolean, status?: number, body?: any }|Function} responseOrFn + * Response descriptor or an async function returning one. + * @returns {{ calls: Array<{url: string, init: any}>, restore: Function }} + * Stub control surface. + */ +function withFetchStub(responseOrFn) { + const previous = globalThis.fetch; + const calls = []; + const handler = typeof responseOrFn === 'function' + ? responseOrFn + : () => responseOrFn; + globalThis.fetch = async (url, init) => { + calls.push({ url, init }); + const descriptor = await handler(url, init); + return { + ok: descriptor.ok ?? true, + status: descriptor.status ?? 200, + json: async () => descriptor.body ?? [], + }; + }; + return { + calls, + restore() { + if (previous === undefined) { + delete globalThis.fetch; + } else { + globalThis.fetch = previous; + } + }, + }; +} + +// --------------------------------------------------------------------------- +// resolveSnapshotLimit +// --------------------------------------------------------------------------- + +test('resolveSnapshotLimit multiplies the requested limit by SNAPSHOT_LIMIT', () => { + assert.equal(resolveSnapshotLimit(10), Math.min(10 * SNAPSHOT_LIMIT, NODE_LIMIT)); +}); + +test('resolveSnapshotLimit caps to maxLimit', () => { + assert.equal(resolveSnapshotLimit(NODE_LIMIT), NODE_LIMIT); + assert.equal(resolveSnapshotLimit(NODE_LIMIT * 2), NODE_LIMIT); +}); + +test('resolveSnapshotLimit defaults to NODE_LIMIT for invalid input', () => { + assert.equal(resolveSnapshotLimit(null), NODE_LIMIT); + assert.equal(resolveSnapshotLimit(0), NODE_LIMIT); + assert.equal(resolveSnapshotLimit(-5), NODE_LIMIT); + assert.equal(resolveSnapshotLimit(Number.NaN), NODE_LIMIT); +}); + +// --------------------------------------------------------------------------- +// filterRecentTraces +// --------------------------------------------------------------------------- + +test('filterRecentTraces returns empty array for non-array input', () => { + assert.deepEqual(filterRecentTraces(null), []); + assert.deepEqual(filterRecentTraces(undefined), []); + assert.deepEqual(filterRecentTraces({}), []); +}); + +test('filterRecentTraces returns a copy of the input when maxAgeSeconds is non-positive', () => { + const input = [{ rx_time: 1 }, { rx_time: 2 }]; + const result = filterRecentTraces(input, 0); + assert.deepEqual(result, input); + assert.notEqual(result, input); // Returns a copy, not the same reference. + + const negativeResult = filterRecentTraces(input, -10); + assert.deepEqual(negativeResult, input); +}); + +test('filterRecentTraces drops traces older than the cutoff', () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const traces = [ + { rx_time: nowSeconds }, // recent + { rx_time: nowSeconds - 7200 }, // older than 1h + { rxIso: new Date((nowSeconds - 30) * 1000).toISOString() }, // recent via ISO + ]; + const filtered = filterRecentTraces(traces, 3600); + assert.equal(filtered.length, 2); +}); + +test('filterRecentTraces drops traces with no usable timestamp', () => { + const filtered = filterRecentTraces([{ noTime: true }, { rx_time: null }], 3600); + assert.deepEqual(filtered, []); +}); + +// --------------------------------------------------------------------------- +// fetchNodeById +// --------------------------------------------------------------------------- + +test('fetchNodeById returns null for non-string inputs', async () => { + assert.equal(await fetchNodeById(null), null); + assert.equal(await fetchNodeById(42), null); +}); + +test('fetchNodeById returns null for blank string inputs', async () => { + assert.equal(await fetchNodeById(''), null); + assert.equal(await fetchNodeById(' '), null); +}); + +test('fetchNodeById returns null on HTTP 404', async () => { + const stub = withFetchStub({ ok: false, status: 404 }); + try { + assert.equal(await fetchNodeById('!aabbccdd'), null); + assert.equal(stub.calls.length, 1); + assert.ok(stub.calls[0].url.includes('!aabbccdd')); + } finally { + stub.restore(); + } +}); + +test('fetchNodeById throws on non-OK non-404 responses', async () => { + const stub = withFetchStub({ ok: false, status: 500 }); + try { + await assert.rejects(() => fetchNodeById('!aabbccdd'), /HTTP 500/); + } finally { + stub.restore(); + } +}); + +test('fetchNodeById returns parsed payload on success', async () => { + const stub = withFetchStub({ ok: true, status: 200, body: { node_id: '!aabbccdd' } }); + try { + const result = await fetchNodeById('!aabbccdd'); + assert.deepEqual(result, { node_id: '!aabbccdd' }); + } finally { + stub.restore(); + } +}); + +// --------------------------------------------------------------------------- +// fetchNodes / fetchNeighbors / fetchTelemetry / fetchPositions / fetchTraces +// --------------------------------------------------------------------------- + +test('fetchNodes appends since when greater than zero', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchNodes(10, 1234); + assert.ok(stub.calls[0].url.includes('since=1234')); + } finally { + stub.restore(); + } +}); + +test('fetchNodes throws on non-OK', async () => { + const stub = withFetchStub({ ok: false, status: 503 }); + try { + await assert.rejects(() => fetchNodes(), /HTTP 503/); + } finally { + stub.restore(); + } +}); + +test('fetchNeighbors hits the neighbours endpoint', async () => { + const stub = withFetchStub({ ok: true, body: [{ node_id: '!a' }] }); + try { + const result = await fetchNeighbors(50); + assert.ok(stub.calls[0].url.startsWith('/api/neighbors?')); + assert.deepEqual(result, [{ node_id: '!a' }]); + } finally { + stub.restore(); + } +}); + +test('fetchNeighbors propagates HTTP errors', async () => { + const stub = withFetchStub({ ok: false, status: 502 }); + try { + await assert.rejects(() => fetchNeighbors(), /HTTP 502/); + } finally { + stub.restore(); + } +}); + +test('fetchTelemetry hits the telemetry endpoint', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchTelemetry(50, 100); + assert.ok(stub.calls[0].url.startsWith('/api/telemetry?')); + assert.ok(stub.calls[0].url.includes('since=100')); + } finally { + stub.restore(); + } +}); + +test('fetchTelemetry propagates HTTP errors', async () => { + const stub = withFetchStub({ ok: false, status: 504 }); + try { + await assert.rejects(() => fetchTelemetry(), /HTTP 504/); + } finally { + stub.restore(); + } +}); + +test('fetchPositions hits the positions endpoint', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchPositions(); + assert.ok(stub.calls[0].url.startsWith('/api/positions?')); + } finally { + stub.restore(); + } +}); + +test('fetchPositions propagates HTTP errors', async () => { + const stub = withFetchStub({ ok: false, status: 500 }); + try { + await assert.rejects(() => fetchPositions(), /HTTP 500/); + } finally { + stub.restore(); + } +}); + +test('fetchTraces filters expired entries', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const stub = withFetchStub({ + ok: true, + body: [ + { rx_time: nowSeconds }, + { rx_time: nowSeconds - 365 * 24 * 3600 }, + ], + }); + try { + const result = await fetchTraces(); + // Only the recent trace should survive. + assert.equal(result.length, 1); + assert.ok(stub.calls[0].url.startsWith('/api/traces?')); + } finally { + stub.restore(); + } +}); + +test('fetchTraces falls back to TRACE_LIMIT on bogus input', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchTraces(Number.NaN); + assert.ok(stub.calls[0].url.includes(`limit=${TRACE_LIMIT}`)); + } finally { + stub.restore(); + } +}); + +test('fetchTraces propagates HTTP errors', async () => { + const stub = withFetchStub({ ok: false, status: 500 }); + try { + await assert.rejects(() => fetchTraces(), /HTTP 500/); + } finally { + stub.restore(); + } +}); + +// --------------------------------------------------------------------------- +// fetchMessages +// --------------------------------------------------------------------------- + +test('fetchMessages returns [] when chatEnabled is false', async () => { + const stub = withFetchStub({ ok: true, body: [{ id: 1 }] }); + try { + const result = await fetchMessages(10, { chatEnabled: false }); + assert.deepEqual(result, []); + assert.equal(stub.calls.length, 0); + } finally { + stub.restore(); + } +}); + +test('fetchMessages applies normaliseMessageLimit when provided', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchMessages(999, { + normaliseMessageLimit: () => 25, + chatEnabled: true, + }); + assert.ok(stub.calls[0].url.includes('limit=25')); + } finally { + stub.restore(); + } +}); + +test('fetchMessages forwards encrypted=true and since when set', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchMessages(10, { encrypted: true, since: 555 }); + assert.ok(stub.calls[0].url.includes('encrypted=true')); + assert.ok(stub.calls[0].url.includes('since=555')); + } finally { + stub.restore(); + } +}); + +test('fetchMessages omits limit normalisation when normaliser is absent', async () => { + const stub = withFetchStub({ ok: true, body: [] }); + try { + await fetchMessages(50); + assert.ok(stub.calls[0].url.includes('limit=50')); + } finally { + stub.restore(); + } +}); + +test('fetchMessages propagates HTTP errors', async () => { + const stub = withFetchStub({ ok: false, status: 500 }); + try { + await assert.rejects(() => fetchMessages(10), /HTTP 500/); + } finally { + stub.restore(); + } +}); diff --git a/web/public/assets/js/app/main/__tests__/data-merge.test.js b/web/public/assets/js/app/main/__tests__/data-merge.test.js new file mode 100644 index 0000000..c840ba2 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/data-merge.test.js @@ -0,0 +1,242 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildTelemetryIndex, + mergePositionsIntoNodes, + mergeTelemetryIntoNodes, +} from '../data-merge.js'; + +// --------------------------------------------------------------------------- +// mergePositionsIntoNodes — early returns +// --------------------------------------------------------------------------- + +test('mergePositionsIntoNodes is a no-op when nodes is not an array', () => { + const positions = [{ node_id: '!a', latitude: 1, longitude: 2 }]; + // Just assert no throw. + mergePositionsIntoNodes(null, positions); + mergePositionsIntoNodes(undefined, positions); +}); + +test('mergePositionsIntoNodes is a no-op when positions is not an array', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, null); + mergePositionsIntoNodes(nodes, undefined); + assert.deepEqual(nodes, [{ node_id: '!a' }]); +}); + +test('mergePositionsIntoNodes is a no-op for empty node arrays', () => { + mergePositionsIntoNodes([], [{ node_id: '!a', latitude: 1, longitude: 2 }]); +}); + +test('mergePositionsIntoNodes is a no-op when no nodes carry a string node_id', () => { + // Hits the `if (nodesById.size === 0) return;` early exit. + const nodes = [{ node_num: 5 }]; + mergePositionsIntoNodes(nodes, [{ node_id: '!a', latitude: 1, longitude: 2 }]); + assert.deepEqual(nodes, [{ node_num: 5 }]); +}); + +// --------------------------------------------------------------------------- +// mergePositionsIntoNodes — merge logic +// --------------------------------------------------------------------------- + +test('mergePositionsIntoNodes copies coordinates when none exist', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [{ + node_id: '!a', + latitude: 52.5, + longitude: 13.4, + altitude: 100, + position_time: 1700000000, + position_time_iso: '2023-11-14T22:13:20.000Z', + location_source: 'gps', + precision_bits: 24, + }]); + assert.equal(nodes[0].latitude, 52.5); + assert.equal(nodes[0].longitude, 13.4); + assert.equal(nodes[0].altitude, 100); + assert.equal(nodes[0].position_time, 1700000000); + assert.equal(nodes[0].pos_time_iso, '2023-11-14T22:13:20.000Z'); + assert.equal(nodes[0].location_source, 'gps'); + assert.equal(nodes[0].precision_bits, 24); +}); + +test('mergePositionsIntoNodes generates an ISO when only numeric position_time is supplied', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [{ + node_id: '!a', + latitude: 1, + longitude: 2, + position_time: 1700000000, + }]); + assert.equal(nodes[0].pos_time_iso, new Date(1700000000 * 1000).toISOString()); +}); + +test('mergePositionsIntoNodes preserves ISO when numeric position_time is missing', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [{ + node_id: '!a', + latitude: 1, + longitude: 2, + position_time_iso: '2024-01-01T00:00:00.000Z', + }]); + assert.equal(nodes[0].pos_time_iso, '2024-01-01T00:00:00.000Z'); +}); + +test('mergePositionsIntoNodes ignores incoming positions with non-finite coordinates', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [{ node_id: '!a', latitude: 'NaN', longitude: 1 }]); + assert.equal(nodes[0].latitude, undefined); +}); + +test('mergePositionsIntoNodes only applies the first matching position per node', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [ + { node_id: '!a', latitude: 1, longitude: 2 }, + { node_id: '!a', latitude: 99, longitude: 99 }, + ]); + assert.equal(nodes[0].latitude, 1); +}); + +test('mergePositionsIntoNodes skips packets older than the existing snapshot', () => { + const nodes = [{ + node_id: '!a', + latitude: 5, + longitude: 5, + position_time: 2000, + }]; + mergePositionsIntoNodes(nodes, [{ + node_id: '!a', + latitude: 9, + longitude: 9, + position_time: 1000, + }]); + assert.equal(nodes[0].latitude, 5); // unchanged +}); + +test('mergePositionsIntoNodes accepts strictly newer packets', () => { + const nodes = [{ + node_id: '!a', + latitude: 5, + longitude: 5, + position_time: 1000, + }]; + mergePositionsIntoNodes(nodes, [{ + node_id: '!a', + latitude: 9, + longitude: 9, + position_time: 2000, + }]); + assert.equal(nodes[0].latitude, 9); +}); + +test('mergePositionsIntoNodes skips entries lacking a node_id', () => { + const nodes = [{ node_id: '!a' }]; + mergePositionsIntoNodes(nodes, [{ latitude: 1, longitude: 2 }, null]); + assert.equal(nodes[0].latitude, undefined); +}); + +// --------------------------------------------------------------------------- +// buildTelemetryIndex +// --------------------------------------------------------------------------- + +test('buildTelemetryIndex returns empty maps for non-array input', () => { + const { byNodeId, byNodeNum } = buildTelemetryIndex(null); + assert.equal(byNodeId.size, 0); + assert.equal(byNodeNum.size, 0); +}); + +test('buildTelemetryIndex keeps the freshest entry per node_id', () => { + const { byNodeId } = buildTelemetryIndex([ + { node_id: '!a', rx_time: 100, payload: 'old' }, + { node_id: '!a', rx_time: 200, payload: 'new' }, + ]); + assert.equal(byNodeId.get('!a').entry.payload, 'new'); +}); + +test('buildTelemetryIndex falls back to telemetry_time when rx_time is absent', () => { + const { byNodeId } = buildTelemetryIndex([ + { node_id: '!a', telemetry_time: 50, payload: 'fallback' }, + ]); + assert.equal(byNodeId.get('!a').timestamp, 50); +}); + +test('buildTelemetryIndex indexes by numeric node_num', () => { + const { byNodeNum } = buildTelemetryIndex([ + { node_num: 42, rx_time: 100, payload: 'first' }, + ]); + assert.ok(byNodeNum.has(42)); +}); + +test('buildTelemetryIndex skips non-object entries', () => { + const { byNodeId, byNodeNum } = buildTelemetryIndex([null, 'string', 5]); + assert.equal(byNodeId.size, 0); + assert.equal(byNodeNum.size, 0); +}); + +// --------------------------------------------------------------------------- +// mergeTelemetryIntoNodes +// --------------------------------------------------------------------------- + +test('mergeTelemetryIntoNodes is a no-op when nodes is empty or not an array', () => { + mergeTelemetryIntoNodes([], []); + mergeTelemetryIntoNodes(null, []); +}); + +test('mergeTelemetryIntoNodes copies metrics when matched by node_id', () => { + const nodes = [{ node_id: '!a' }]; + mergeTelemetryIntoNodes(nodes, [{ + node_id: '!a', + battery_level: 85, + voltage: 4.1, + rx_time: 100, + telemetry_time: 95, + }]); + assert.equal(nodes[0].battery_level, 85); + assert.equal(nodes[0].voltage, 4.1); + assert.equal(nodes[0].telemetry_time, 95); + assert.equal(nodes[0].telemetry_rx_time, 100); +}); + +test('mergeTelemetryIntoNodes falls back to node_num lookup', () => { + const nodes = [{ num: 42 }]; + mergeTelemetryIntoNodes(nodes, [{ + node_num: 42, + temperature: 21.5, + }]); + assert.equal(nodes[0].temperature, 21.5); +}); + +test('mergeTelemetryIntoNodes ignores nodes that do not match by id or num', () => { + const nodes = [{ node_id: '!a', num: 1 }]; + mergeTelemetryIntoNodes(nodes, [{ node_id: '!b', battery_level: 50 }]); + assert.equal(nodes[0].battery_level, undefined); +}); + +test('mergeTelemetryIntoNodes skips null metric values', () => { + const nodes = [{ node_id: '!a', battery_level: 99 }]; + mergeTelemetryIntoNodes(nodes, [{ node_id: '!a', battery_level: null }]); + assert.equal(nodes[0].battery_level, 99); +}); + +test('mergeTelemetryIntoNodes tolerates non-object entries in the list', () => { + const nodes = [null, undefined, { node_id: '!a' }]; + mergeTelemetryIntoNodes(nodes, [{ node_id: '!a', voltage: 3.9 }]); + assert.equal(nodes[2].voltage, 3.9); +}); diff --git a/web/public/assets/js/app/main/__tests__/format-utils.test.js b/web/public/assets/js/app/main/__tests__/format-utils.test.js new file mode 100644 index 0000000..5663044 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/format-utils.test.js @@ -0,0 +1,405 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + cssEscape, + fmtCoords, + fmtHw, + formatDate, + formatShortInfoUptime, + formatSnrDisplay, + formatTime, + pad, + parseNodeNumericRef, + pickFirstProperty, + pickNumericProperty, + resolveTimestampSeconds, + shortInfoValueOrDash, + timeAgo, + timeHum, + toFiniteNumber, +} from '../format-utils.js'; + +// --------------------------------------------------------------------------- +// pad / formatTime / formatDate +// --------------------------------------------------------------------------- + +test('pad pads small numbers to two digits', () => { + assert.equal(pad(0), '00'); + assert.equal(pad(7), '07'); + assert.equal(pad(42), '42'); +}); + +test('formatTime renders HH:MM:SS', () => { + const d = new Date(2026, 0, 1, 9, 5, 7); // Local time. + assert.equal(formatTime(d), '09:05:07'); +}); + +test('formatDate renders YYYY-MM-DD', () => { + const d = new Date(2026, 0, 9); // Jan 9, 2026 local. + assert.equal(formatDate(d), '2026-01-09'); +}); + +// --------------------------------------------------------------------------- +// fmtHw +// --------------------------------------------------------------------------- + +test('fmtHw passes through normal values', () => { + assert.equal(fmtHw('TBEAM'), 'TBEAM'); +}); + +test('fmtHw hides the UNSET sentinel', () => { + assert.equal(fmtHw('UNSET'), ''); +}); + +test('fmtHw returns empty string for falsy input', () => { + assert.equal(fmtHw(null), ''); + assert.equal(fmtHw(''), ''); + assert.equal(fmtHw(undefined), ''); +}); + +// --------------------------------------------------------------------------- +// fmtCoords +// --------------------------------------------------------------------------- + +test('fmtCoords formats numbers with default precision 5', () => { + assert.equal(fmtCoords(52.520008), '52.52001'); +}); + +test('fmtCoords accepts a custom precision', () => { + assert.equal(fmtCoords(52.520008, 2), '52.52'); +}); + +test('fmtCoords returns empty string for null, undefined, and empty', () => { + assert.equal(fmtCoords(null), ''); + assert.equal(fmtCoords(undefined), ''); + assert.equal(fmtCoords(''), ''); +}); + +test('fmtCoords returns empty string for non-numeric input', () => { + assert.equal(fmtCoords('not a number'), ''); +}); + +// --------------------------------------------------------------------------- +// formatSnrDisplay +// --------------------------------------------------------------------------- + +test('formatSnrDisplay appends dB suffix with one decimal', () => { + assert.equal(formatSnrDisplay(7.49), '7.5 dB'); + assert.equal(formatSnrDisplay(-3), '-3.0 dB'); +}); + +test('formatSnrDisplay returns empty string for null and empty input', () => { + assert.equal(formatSnrDisplay(null), ''); + assert.equal(formatSnrDisplay(''), ''); +}); + +test('formatSnrDisplay returns empty string for non-finite input', () => { + assert.equal(formatSnrDisplay('abc'), ''); +}); + +// --------------------------------------------------------------------------- +// timeHum +// --------------------------------------------------------------------------- + +test('timeHum returns empty string for falsy input', () => { + assert.equal(timeHum(0), ''); + assert.equal(timeHum(null), ''); +}); + +test('timeHum returns 0s for negative durations', () => { + assert.equal(timeHum(-5), '0s'); +}); + +test('timeHum formats sub-minute durations as seconds', () => { + assert.equal(timeHum(45), '45s'); +}); + +test('timeHum formats sub-hour durations as minutes and seconds', () => { + assert.equal(timeHum(125), '2m 5s'); +}); + +test('timeHum formats sub-day durations as hours and minutes', () => { + assert.equal(timeHum(3700), '1h 1m'); +}); + +test('timeHum formats day-scale durations as days and hours', () => { + assert.equal(timeHum(90061), '1d 1h'); +}); + +// --------------------------------------------------------------------------- +// timeAgo +// --------------------------------------------------------------------------- + +test('timeAgo returns empty string when the input is missing', () => { + assert.equal(timeAgo(0), ''); + assert.equal(timeAgo(null), ''); +}); + +test('timeAgo clamps future timestamps to 0s', () => { + assert.equal(timeAgo(5000, 1000), '0s'); +}); + +test('timeAgo formats sub-minute deltas as seconds', () => { + assert.equal(timeAgo(950, 1000), '50s'); +}); + +test('timeAgo formats sub-hour deltas as minutes and seconds', () => { + assert.equal(timeAgo(875, 1000), '2m 5s'); +}); + +test('timeAgo formats sub-day deltas as hours and minutes', () => { + // Use a non-zero past timestamp; timeAgo treats 0 as "missing" and returns "". + assert.equal(timeAgo(1000, 4700), '1h 1m'); +}); + +test('timeAgo formats day-scale deltas as days and hours', () => { + assert.equal(timeAgo(1000, 91061), '1d 1h'); +}); + +// --------------------------------------------------------------------------- +// toFiniteNumber +// --------------------------------------------------------------------------- + +test('toFiniteNumber converts numeric strings', () => { + assert.equal(toFiniteNumber('42'), 42); +}); + +test('toFiniteNumber returns null for null, undefined, and empty', () => { + assert.equal(toFiniteNumber(null), null); + assert.equal(toFiniteNumber(undefined), null); + assert.equal(toFiniteNumber(''), null); +}); + +test('toFiniteNumber rejects non-finite values', () => { + assert.equal(toFiniteNumber('abc'), null); + assert.equal(toFiniteNumber(Number.NaN), null); + assert.equal(toFiniteNumber(Number.POSITIVE_INFINITY), null); +}); + +// --------------------------------------------------------------------------- +// resolveTimestampSeconds +// --------------------------------------------------------------------------- + +test('resolveTimestampSeconds prefers a numeric timestamp', () => { + assert.equal(resolveTimestampSeconds(1700000000, '2024-01-01T00:00:00Z'), 1700000000); +}); + +test('resolveTimestampSeconds falls back to ISO when numeric is missing', () => { + // 2024-01-01T00:00:00Z = 1704067200 seconds. + assert.equal(resolveTimestampSeconds(null, '2024-01-01T00:00:00Z'), 1704067200); +}); + +test('resolveTimestampSeconds returns null when both inputs are unusable', () => { + assert.equal(resolveTimestampSeconds(null, null), null); + assert.equal(resolveTimestampSeconds(null, ''), null); + assert.equal(resolveTimestampSeconds(null, 'not a date'), null); +}); + +// --------------------------------------------------------------------------- +// cssEscape +// --------------------------------------------------------------------------- + +test('cssEscape returns empty string for non-strings and empty input', () => { + assert.equal(cssEscape(''), ''); + assert.equal(cssEscape(null), ''); + assert.equal(cssEscape(undefined), ''); + assert.equal(cssEscape(42), ''); +}); + +test('cssEscape uses window.CSS.escape when available', () => { + const previous = globalThis.window; + globalThis.window = { + CSS: { + escape: value => `escaped(${value})`, + }, + }; + try { + assert.equal(cssEscape('foo'), 'escaped(foo)'); + } finally { + if (previous === undefined) { + delete globalThis.window; + } else { + globalThis.window = previous; + } + } +}); + +test('cssEscape falls back to manual escaping when window.CSS is unavailable', () => { + const previous = globalThis.window; + delete globalThis.window; + try { + // Underscores and hyphens pass through; everything else is backslash-escaped. + assert.equal(cssEscape('a-b_c'), 'a-b_c'); + assert.equal(cssEscape('a:b'), 'a\\:b'); + } finally { + if (previous !== undefined) { + globalThis.window = previous; + } + } +}); + +// --------------------------------------------------------------------------- +// formatShortInfoUptime +// --------------------------------------------------------------------------- + +test('formatShortInfoUptime returns empty string for null and empty', () => { + assert.equal(formatShortInfoUptime(null), ''); + assert.equal(formatShortInfoUptime(''), ''); +}); + +test('formatShortInfoUptime returns empty string for non-finite input', () => { + assert.equal(formatShortInfoUptime('abc'), ''); +}); + +test('formatShortInfoUptime renders 0s for zero', () => { + assert.equal(formatShortInfoUptime(0), '0s'); +}); + +test('formatShortInfoUptime delegates to timeHum for positive values', () => { + assert.equal(formatShortInfoUptime(125), '2m 5s'); +}); + +// --------------------------------------------------------------------------- +// shortInfoValueOrDash +// --------------------------------------------------------------------------- + +test('shortInfoValueOrDash returns the string form of present values', () => { + assert.equal(shortInfoValueOrDash('text'), 'text'); + assert.equal(shortInfoValueOrDash(0), '0'); +}); + +test('shortInfoValueOrDash returns em dash for null, undefined, and empty', () => { + assert.equal(shortInfoValueOrDash(null), '—'); + assert.equal(shortInfoValueOrDash(undefined), '—'); + assert.equal(shortInfoValueOrDash(''), '—'); +}); + +// --------------------------------------------------------------------------- +// pickFirstProperty +// --------------------------------------------------------------------------- + +test('pickFirstProperty returns null when sources or keys are not arrays', () => { + assert.equal(pickFirstProperty(null, ['a']), null); + assert.equal(pickFirstProperty([{}], null), null); +}); + +test('pickFirstProperty returns the first present trimmed string', () => { + const sources = [ + {}, + { id: ' ' }, + { id: ' hello ' }, + ]; + assert.equal(pickFirstProperty(sources, ['id']), 'hello'); +}); + +test('pickFirstProperty returns the first non-string value verbatim', () => { + assert.equal(pickFirstProperty([{ count: 5 }], ['count']), 5); + assert.equal(pickFirstProperty([{ flag: false }], ['flag']), false); +}); + +test('pickFirstProperty skips non-object entries and absent properties', () => { + const sources = [null, 42, { other: 'value' }, { name: 'final' }]; + assert.equal(pickFirstProperty(sources, ['name']), 'final'); +}); + +test('pickFirstProperty returns null when no source provides a value', () => { + assert.equal(pickFirstProperty([{ a: null }, { a: '' }], ['a']), null); +}); + +// --------------------------------------------------------------------------- +// pickNumericProperty +// --------------------------------------------------------------------------- + +test('pickNumericProperty returns null when sources or keys are not arrays', () => { + assert.equal(pickNumericProperty(null, ['a']), null); + assert.equal(pickNumericProperty([{}], null), null); +}); + +test('pickNumericProperty returns the first finite numeric value', () => { + const sources = [ + { value: '' }, + { value: 'abc' }, + { value: '42' }, + ]; + assert.equal(pickNumericProperty(sources, ['value']), 42); +}); + +test('pickNumericProperty skips non-object entries and missing keys', () => { + const sources = [null, undefined, { other: 1 }, { count: 7 }]; + assert.equal(pickNumericProperty(sources, ['count']), 7); +}); + +test('pickNumericProperty returns null when no candidate is finite', () => { + assert.equal(pickNumericProperty([{ a: 'abc' }, { a: null }], ['a']), null); +}); + +// --------------------------------------------------------------------------- +// parseNodeNumericRef +// --------------------------------------------------------------------------- + +test('parseNodeNumericRef returns null for null and undefined', () => { + assert.equal(parseNodeNumericRef(null), null); + assert.equal(parseNodeNumericRef(undefined), null); +}); + +test('parseNodeNumericRef passes through finite numbers', () => { + assert.equal(parseNodeNumericRef(42), 42); +}); + +test('parseNodeNumericRef returns null for non-finite numbers', () => { + assert.equal(parseNodeNumericRef(Number.NaN), null); + assert.equal(parseNodeNumericRef(Number.POSITIVE_INFINITY), null); +}); + +test('parseNodeNumericRef parses !-prefixed hex strings', () => { + assert.equal(parseNodeNumericRef('!aabbccdd'), 0xaabbccdd); +}); + +test('parseNodeNumericRef rejects !-prefixed strings with invalid characters', () => { + assert.equal(parseNodeNumericRef('!ZZZ'), null); +}); + +test('parseNodeNumericRef parses 0x-prefixed hex strings', () => { + assert.equal(parseNodeNumericRef('0x1A'), 0x1a); +}); + +test('parseNodeNumericRef parses decimal strings', () => { + assert.equal(parseNodeNumericRef('123'), 123); +}); + +test('parseNodeNumericRef returns null for blank strings', () => { + assert.equal(parseNodeNumericRef(''), null); + assert.equal(parseNodeNumericRef(' '), null); +}); + +test('parseNodeNumericRef returns null for unparseable strings', () => { + assert.equal(parseNodeNumericRef('not a number'), null); +}); + +test('parseNodeNumericRef coerces other inputs via Number()', () => { + // Booleans, Date, etc. — anything the global Number() constructor can + // map to a finite number passes through. + assert.equal(parseNodeNumericRef(true), 1); + assert.equal(parseNodeNumericRef(false), 0); +}); + +test('parseNodeNumericRef returns null for unparseable non-string inputs', () => { + assert.equal(parseNodeNumericRef({}), null); +}); diff --git a/web/public/assets/js/app/main/__tests__/fullscreen-helpers.test.js b/web/public/assets/js/app/main/__tests__/fullscreen-helpers.test.js new file mode 100644 index 0000000..c9bf3af --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/fullscreen-helpers.test.js @@ -0,0 +1,152 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getActiveFullscreenElement, legendClickHandler } from '../fullscreen-helpers.js'; + +// --------------------------------------------------------------------------- +// getActiveFullscreenElement +// --------------------------------------------------------------------------- + +test('getActiveFullscreenElement returns null when document is undefined', () => { + const previousDoc = globalThis.document; + // Node has no document by default, but other tests in the suite may have + // assigned one — clear it explicitly for this case. + delete globalThis.document; + try { + assert.equal(getActiveFullscreenElement(), null); + } finally { + if (previousDoc !== undefined) { + globalThis.document = previousDoc; + } + } +}); + +test('getActiveFullscreenElement prefers fullscreenElement', () => { + const dummy = { tag: 'std' }; + const previousDoc = globalThis.document; + globalThis.document = { + fullscreenElement: dummy, + webkitFullscreenElement: { tag: 'webkit' }, + msFullscreenElement: { tag: 'ms' }, + }; + try { + assert.equal(getActiveFullscreenElement(), dummy); + } finally { + if (previousDoc === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDoc; + } + } +}); + +test('getActiveFullscreenElement falls back to webkit prefix', () => { + const dummy = { tag: 'webkit' }; + const previousDoc = globalThis.document; + globalThis.document = { + fullscreenElement: null, + webkitFullscreenElement: dummy, + msFullscreenElement: null, + }; + try { + assert.equal(getActiveFullscreenElement(), dummy); + } finally { + if (previousDoc === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDoc; + } + } +}); + +test('getActiveFullscreenElement falls back to ms prefix', () => { + const dummy = { tag: 'ms' }; + const previousDoc = globalThis.document; + globalThis.document = { + fullscreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: dummy, + }; + try { + assert.equal(getActiveFullscreenElement(), dummy); + } finally { + if (previousDoc === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDoc; + } + } +}); + +test('getActiveFullscreenElement returns null when no fullscreen owner is set', () => { + const previousDoc = globalThis.document; + globalThis.document = { + fullscreenElement: null, + webkitFullscreenElement: null, + msFullscreenElement: null, + }; + try { + assert.equal(getActiveFullscreenElement(), null); + } finally { + if (previousDoc === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDoc; + } + } +}); + +// --------------------------------------------------------------------------- +// legendClickHandler +// --------------------------------------------------------------------------- + +test('legendClickHandler always calls preventDefault and stopPropagation', () => { + let preventCalls = 0; + let stopCalls = 0; + let bodyCalls = 0; + const handler = legendClickHandler(() => { + bodyCalls += 1; + }); + const fakeEvent = { + preventDefault: () => { + preventCalls += 1; + }, + stopPropagation: () => { + stopCalls += 1; + }, + }; + handler(fakeEvent); + assert.equal(preventCalls, 1); + assert.equal(stopCalls, 1); + assert.equal(bodyCalls, 1); +}); + +test('legendClickHandler forwards the event object to the body', () => { + let received = null; + const handler = legendClickHandler(event => { + received = event; + }); + const fakeEvent = { + preventDefault() {}, + stopPropagation() {}, + payload: 'forwarded', + }; + handler(fakeEvent); + assert.equal(received, fakeEvent); +}); diff --git a/web/public/assets/js/app/main/__tests__/long-link-router.test.js b/web/public/assets/js/app/main/__tests__/long-link-router.test.js new file mode 100644 index 0000000..333d504 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/long-link-router.test.js @@ -0,0 +1,188 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyNodeNameFallback, + extractIdentifierFromHref, + getNodeDisplayNameForOverlay, + getNodeIdentifierFromLink, + shouldHandleNodeLongLink, +} from '../long-link-router.js'; + +// --------------------------------------------------------------------------- +// shouldHandleNodeLongLink +// --------------------------------------------------------------------------- + +test('shouldHandleNodeLongLink rejects null and undefined', () => { + assert.equal(shouldHandleNodeLongLink(null), false); + assert.equal(shouldHandleNodeLongLink(undefined), false); +}); + +test('shouldHandleNodeLongLink rejects elements without a dataset', () => { + assert.equal(shouldHandleNodeLongLink({}), false); +}); + +test('shouldHandleNodeLongLink honours an explicit nodeDetailLink=false opt-out', () => { + const link = { dataset: { nodeDetailLink: 'false' } }; + assert.equal(shouldHandleNodeLongLink(link), false); +}); + +test('shouldHandleNodeLongLink accepts elements with a permissive dataset', () => { + assert.equal(shouldHandleNodeLongLink({ dataset: {} }), true); + assert.equal(shouldHandleNodeLongLink({ dataset: { nodeDetailLink: 'true' } }), true); +}); + +// --------------------------------------------------------------------------- +// extractIdentifierFromHref +// --------------------------------------------------------------------------- + +test('extractIdentifierFromHref returns empty string for non-string and empty input', () => { + assert.equal(extractIdentifierFromHref(null), ''); + assert.equal(extractIdentifierFromHref(undefined), ''); + assert.equal(extractIdentifierFromHref(''), ''); + assert.equal(extractIdentifierFromHref(42), ''); +}); + +test('extractIdentifierFromHref returns empty string when no /nodes/!… segment is present', () => { + assert.equal(extractIdentifierFromHref('/about'), ''); + assert.equal(extractIdentifierFromHref('https://example.com/'), ''); +}); + +test('extractIdentifierFromHref returns the canonical node id for /nodes/!… URIs', () => { + assert.equal(extractIdentifierFromHref('/nodes/!aabbccdd'), '!aabbccdd'); + // canonicalNodeIdentifier preserves case; it only ensures the leading "!". + assert.equal( + extractIdentifierFromHref('https://example.com/nodes/!AABBCCDD?ref=1'), + '!AABBCCDD', + ); +}); + +test('extractIdentifierFromHref tolerates URI-encoded ! prefixes', () => { + // %21 is the URL-encoded form of !. decodeURIComponent should restore it. + assert.equal(extractIdentifierFromHref('/nodes/%21aabbccdd'), ''); + // Not all encodings return a node — '!aabbccdd' encoded as a literal also works. + assert.equal(extractIdentifierFromHref('/nodes/!aabbccdd#anchor'), '!aabbccdd'); +}); + +test('extractIdentifierFromHref falls back to the raw match when decoding throws', () => { + // A bare "%" tail is malformed UTF-8 percent encoding and makes + // decodeURIComponent raise URIError. The catch branch should still + // canonicalise the un-decoded match. + assert.equal( + extractIdentifierFromHref('/nodes/!aabbccdd%E0'), + '!aabbccdd%E0', + ); +}); + +// --------------------------------------------------------------------------- +// getNodeIdentifierFromLink +// --------------------------------------------------------------------------- + +test('getNodeIdentifierFromLink returns empty string for falsy input', () => { + assert.equal(getNodeIdentifierFromLink(null), ''); + assert.equal(getNodeIdentifierFromLink(undefined), ''); +}); + +test('getNodeIdentifierFromLink prefers dataset.nodeId when canonical', () => { + const link = { dataset: { nodeId: '!aabbccdd' } }; + assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd'); +}); + +test('getNodeIdentifierFromLink falls back to getAttribute("href") when dataset is absent', () => { + const link = { + getAttribute(name) { + return name === 'href' ? '/nodes/!aabbccdd' : null; + }, + }; + assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd'); +}); + +test('getNodeIdentifierFromLink falls back to the .href property when getAttribute is absent', () => { + const link = { href: '/nodes/!aabbccdd' }; + assert.equal(getNodeIdentifierFromLink(link), '!aabbccdd'); +}); + +test('getNodeIdentifierFromLink returns empty string when nothing parses', () => { + assert.equal(getNodeIdentifierFromLink({}), ''); +}); + +// --------------------------------------------------------------------------- +// getNodeDisplayNameForOverlay +// --------------------------------------------------------------------------- + +test('getNodeDisplayNameForOverlay returns empty string for non-objects', () => { + assert.equal(getNodeDisplayNameForOverlay(null), ''); + assert.equal(getNodeDisplayNameForOverlay(42), ''); +}); + +test('getNodeDisplayNameForOverlay prefers long_name', () => { + const node = { long_name: 'Alpha Long', short_name: 'A', node_id: '!a' }; + assert.equal(getNodeDisplayNameForOverlay(node), 'Alpha Long'); +}); + +test('getNodeDisplayNameForOverlay falls back to short_name', () => { + const node = { short_name: 'A', node_id: '!a' }; + assert.equal(getNodeDisplayNameForOverlay(node), 'A'); +}); + +test('getNodeDisplayNameForOverlay falls back to node_id when names are absent', () => { + assert.equal(getNodeDisplayNameForOverlay({ node_id: '!a' }), '!a'); +}); + +test('getNodeDisplayNameForOverlay reads camelCase keys too', () => { + assert.equal(getNodeDisplayNameForOverlay({ longName: 'L' }), 'L'); + assert.equal(getNodeDisplayNameForOverlay({ shortName: 'S' }), 'S'); +}); + +// --------------------------------------------------------------------------- +// applyNodeNameFallback +// --------------------------------------------------------------------------- + +test('applyNodeNameFallback is a no-op for non-objects', () => { + // Just ensure no throw. + applyNodeNameFallback(null); + applyNodeNameFallback(undefined); +}); + +test('applyNodeNameFallback fills missing names from node_id', () => { + const node = { node_id: '!aabbccdd' }; + applyNodeNameFallback(node); + assert.equal(node.short_name, 'ccdd'); + assert.equal(node.long_name, 'Meshtastic !aabbccdd'); +}); + +test('applyNodeNameFallback updates camelCase aliases when present', () => { + const node = { node_id: '!aabbccdd', shortName: '', longName: '' }; + applyNodeNameFallback(node); + assert.equal(node.shortName, 'ccdd'); + assert.equal(node.longName, 'Meshtastic !aabbccdd'); +}); + +test('applyNodeNameFallback leaves existing names untouched', () => { + const node = { node_id: '!aabbccdd', short_name: 'AAA', long_name: 'Alpha' }; + applyNodeNameFallback(node); + assert.equal(node.short_name, 'AAA'); + assert.equal(node.long_name, 'Alpha'); +}); + +test('applyNodeNameFallback is a no-op when no node_id is available', () => { + const node = {}; + applyNodeNameFallback(node); + assert.deepEqual(node, {}); +}); diff --git a/web/public/assets/js/app/main/__tests__/offline-tile-layer.test.js b/web/public/assets/js/app/main/__tests__/offline-tile-layer.test.js new file mode 100644 index 0000000..7525bc5 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/offline-tile-layer.test.js @@ -0,0 +1,228 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createOfflineTileLayer } from '../offline-tile-layer.js'; + +/** + * Build a minimal Leaflet stub exposing the methods the offline tile layer + * needs (``L.gridLayer``). The returned grid-layer object is otherwise a + * plain bag whose ``createTile`` slot is reassigned by the production code. + * + * @returns {Object} Leaflet-compatible stub. + */ +function makeLeafletStub() { + return { + gridLayer(options) { + return { options, createTile: null }; + }, + }; +} + +/** + * Install a minimal ``document`` stub whose ``createElement`` returns objects + * that satisfy the offline tile layer's small DOM contract: canvas elements + * expose a configurable ``getContext`` slot, while plain ``div`` elements + * expose ``style``, ``className`` and ``cloneNode``. + * + * @param {{ canvasContext?: any }} [options] Override the canvas 2D context. + * @returns {{ restore: Function }} Teardown handle. + */ +function withDocumentStub({ canvasContext } = {}) { + const previousDocument = globalThis.document; + + globalThis.document = { + createElement(tag) { + if (tag === 'canvas') { + return { + width: 0, + height: 0, + getContext: () => (canvasContext === undefined ? makeRecordingContext() : canvasContext), + }; + } + const element = { + tag, + className: '', + style: {}, + textContent: '', + cloneNode() { + // Return a shallow copy that retains the recorded properties so + // assertions can inspect what the production code rendered. + return JSON.parse(JSON.stringify({ + tag: element.tag, + className: element.className, + style: element.style, + textContent: element.textContent, + })); + }, + }; + return element; + }, + }; + + return { + restore() { + if (previousDocument === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDocument; + } + }, + }; +} + +/** + * Build a Canvas 2D context stub that records the calls it receives. The + * tests inspect the call list to ensure the production code follows the + * expected drawing path. + * + * @returns {Object} Recording 2D context. + */ +function makeRecordingContext() { + const calls = []; + const ctx = { + calls, + fillStyle: null, + strokeStyle: null, + lineWidth: 0, + font: '', + textBaseline: '', + textAlign: '', + createLinearGradient(...args) { + calls.push(['createLinearGradient', args]); + return { addColorStop(...stop) { calls.push(['addColorStop', stop]); } }; + }, + fillRect(...args) { + calls.push(['fillRect', args]); + }, + beginPath() { + calls.push(['beginPath']); + }, + moveTo(...args) { + calls.push(['moveTo', args]); + }, + lineTo(...args) { + calls.push(['lineTo', args]); + }, + stroke() { + calls.push(['stroke']); + }, + fillText(...args) { + calls.push(['fillText', args]); + }, + }; + return ctx; +} + +// --------------------------------------------------------------------------- +// createOfflineTileLayer — early returns +// --------------------------------------------------------------------------- + +test('createOfflineTileLayer returns null when Leaflet is missing', () => { + assert.equal(createOfflineTileLayer(null), null); + assert.equal(createOfflineTileLayer(undefined), null); +}); + +test('createOfflineTileLayer returns null when Leaflet has no gridLayer factory', () => { + assert.equal(createOfflineTileLayer({}), null); +}); + +// --------------------------------------------------------------------------- +// createOfflineTileLayer — happy path +// --------------------------------------------------------------------------- + +test('createOfflineTileLayer attaches a createTile method on success', () => { + const stub = withDocumentStub(); + try { + const layer = createOfflineTileLayer(makeLeafletStub()); + assert.ok(layer); + assert.equal(typeof layer.createTile, 'function'); + } finally { + stub.restore(); + } +}); + +test('createOfflineTileLayer renders a canvas tile when getContext succeeds', () => { + const stub = withDocumentStub(); + try { + const layer = createOfflineTileLayer(makeLeafletStub()); + const tile = layer.createTile({ x: 1, y: 1, z: 1 }); + // The returned element should be the canvas itself (has getContext). + assert.equal(typeof tile.getContext, 'function'); + assert.equal(tile.width, 256); + assert.equal(tile.height, 256); + } finally { + stub.restore(); + } +}); + +test('createOfflineTileLayer falls back to placeholder when canvas getContext returns null', () => { + const stub = withDocumentStub({ canvasContext: null }); + // Silence the warn from the fallback branch so test output stays clean. + const previousWarn = console.warn; + console.warn = () => {}; + try { + const layer = createOfflineTileLayer(makeLeafletStub()); + const tile = layer.createTile({ x: 0, y: 0, z: 0 }); + // Fallback is the cloned
— no getContext method. + assert.equal(tile.getContext, undefined); + assert.equal(tile.tag, 'div'); + assert.equal(tile.className, 'offline-tile-fallback'); + } finally { + console.warn = previousWarn; + stub.restore(); + } +}); + +test('createOfflineTileLayer reuses the cached fallback tile across invocations', () => { + const stub = withDocumentStub({ canvasContext: null }); + const previousWarn = console.warn; + console.warn = () => {}; + try { + const layer = createOfflineTileLayer(makeLeafletStub()); + const first = layer.createTile({ x: 0, y: 0, z: 0 }); + const second = layer.createTile({ x: 1, y: 0, z: 0 }); + // Both calls produce equivalent fallback nodes (same shape). + assert.deepEqual(first, second); + } finally { + console.warn = previousWarn; + stub.restore(); + } +}); + +test('createOfflineTileLayer falls back when the canvas drawing path throws', () => { + // Build a context whose `createLinearGradient` throws to force the + // catch-and-fall-back branch. + const ctx = makeRecordingContext(); + ctx.createLinearGradient = () => { + throw new Error('boom'); + }; + const stub = withDocumentStub({ canvasContext: ctx }); + const previousError = console.error; + console.error = () => {}; + try { + const layer = createOfflineTileLayer(makeLeafletStub()); + const tile = layer.createTile({ x: 0, y: 0, z: 0 }); + // Production code logs and returns the fallback element. + assert.equal(tile.getContext, undefined); + assert.equal(tile.tag, 'div'); + } finally { + console.error = previousError; + stub.restore(); + } +}); diff --git a/web/public/assets/js/app/main/__tests__/sort-comparators.test.js b/web/public/assets/js/app/main/__tests__/sort-comparators.test.js new file mode 100644 index 0000000..78a2ff9 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/sort-comparators.test.js @@ -0,0 +1,128 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + compareNumber, + compareString, + hasNumberValue, + hasStringValue, +} from '../sort-comparators.js'; + +// --------------------------------------------------------------------------- +// hasStringValue +// --------------------------------------------------------------------------- + +test('hasStringValue returns true for non-empty strings', () => { + assert.equal(hasStringValue('hi'), true); + assert.equal(hasStringValue(' text '), true); +}); + +test('hasStringValue returns false for null, undefined, and blank input', () => { + assert.equal(hasStringValue(null), false); + assert.equal(hasStringValue(undefined), false); + assert.equal(hasStringValue(''), false); + assert.equal(hasStringValue(' '), false); +}); + +test('hasStringValue treats numbers as their string form', () => { + assert.equal(hasStringValue(0), true); + assert.equal(hasStringValue(42), true); +}); + +// --------------------------------------------------------------------------- +// hasNumberValue +// --------------------------------------------------------------------------- + +test('hasNumberValue accepts finite numbers', () => { + assert.equal(hasNumberValue(42), true); + assert.equal(hasNumberValue(-1.5), true); + assert.equal(hasNumberValue(0), true); +}); + +test('hasNumberValue rejects null, undefined, and empty string', () => { + assert.equal(hasNumberValue(null), false); + assert.equal(hasNumberValue(undefined), false); + assert.equal(hasNumberValue(''), false); +}); + +test('hasNumberValue rejects non-finite numbers and unparseable strings', () => { + assert.equal(hasNumberValue(Number.NaN), false); + assert.equal(hasNumberValue(Number.POSITIVE_INFINITY), false); + assert.equal(hasNumberValue('abc'), false); +}); + +test('hasNumberValue accepts numeric strings', () => { + assert.equal(hasNumberValue('42'), true); + assert.equal(hasNumberValue(' -1.5 '), true); +}); + +// --------------------------------------------------------------------------- +// compareString +// --------------------------------------------------------------------------- + +test('compareString sorts non-empty values lexicographically', () => { + assert.ok(compareString('alpha', 'beta') < 0); + assert.ok(compareString('beta', 'alpha') > 0); + assert.equal(compareString('alpha', 'alpha'), 0); +}); + +test('compareString trims surrounding whitespace before comparing', () => { + assert.equal(compareString(' alpha ', 'alpha'), 0); +}); + +test('compareString sorts blank values to the end', () => { + assert.ok(compareString('alpha', '') < 0); + assert.ok(compareString('', 'alpha') > 0); +}); + +test('compareString returns 0 when both values are blank', () => { + assert.equal(compareString(null, ''), 0); + assert.equal(compareString('', ' '), 0); +}); + +test('compareString uses numeric collation for digit-bearing strings', () => { + // localeCompare with { numeric: true } orders "node-2" before "node-10". + assert.ok(compareString('node-2', 'node-10') < 0); +}); + +// --------------------------------------------------------------------------- +// compareNumber +// --------------------------------------------------------------------------- + +test('compareNumber sorts ascending for finite values', () => { + assert.ok(compareNumber(1, 2) < 0); + assert.ok(compareNumber(2, 1) > 0); + assert.equal(compareNumber(1, 1), 0); +}); + +test('compareNumber accepts numeric strings', () => { + assert.ok(compareNumber('1', '2') < 0); + assert.ok(compareNumber('2', '1') > 0); +}); + +test('compareNumber pushes invalid values after valid ones', () => { + assert.ok(compareNumber(5, 'not-a-number') < 0); + assert.ok(compareNumber('not-a-number', 5) > 0); +}); + +test('compareNumber returns 0 when both inputs are unparseable', () => { + assert.equal(compareNumber('abc', 'def'), 0); + // Note: Number(null) === 0, so null is *finite* under this comparator. + assert.equal(compareNumber(undefined, 'abc'), 0); +}); diff --git a/web/public/assets/js/app/main/__tests__/tile-coords.test.js b/web/public/assets/js/app/main/__tests__/tile-coords.test.js new file mode 100644 index 0000000..8e57459 --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/tile-coords.test.js @@ -0,0 +1,49 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { tileToLat, tileToLon } from '../tile-coords.js'; + +test('tileToLon zero tile at zoom 0 is -180', () => { + assert.equal(tileToLon(0, 0), -180); +}); + +test('tileToLon centre tile at zoom 1 is 0', () => { + assert.equal(tileToLon(1, 1), 0); +}); + +test('tileToLon last tile at zoom 2 is 90', () => { + assert.equal(tileToLon(3, 2), 90); +}); + +test('tileToLat zero tile at zoom 0 is roughly 85.0511', () => { + // Mercator clamp: northernmost projectable latitude. + assert.ok(Math.abs(tileToLat(0, 0) - 85.0511287798066) < 1e-9); +}); + +test('tileToLat centre tile at zoom 1 is 0', () => { + assert.equal(tileToLat(1, 1), 0); +}); + +test('tileToLat is symmetric around the equator at zoom 1', () => { + // Tile y=0 (northern edge) and y=2 (southern edge) at zoom 1 should + // be equal in magnitude with opposite signs. + const north = tileToLat(0, 1); + const south = tileToLat(2, 1); + assert.ok(Math.abs(north + south) < 1e-9); +}); diff --git a/web/public/assets/js/app/main/__tests__/tooltip-html.test.js b/web/public/assets/js/app/main/__tests__/tooltip-html.test.js new file mode 100644 index 0000000..b15730c --- /dev/null +++ b/web/public/assets/js/app/main/__tests__/tooltip-html.test.js @@ -0,0 +1,119 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildNeighborTooltipHtml, buildTraceTooltipHtml } from '../tooltip-html.js'; + +// --------------------------------------------------------------------------- +// buildTraceTooltipHtml +// --------------------------------------------------------------------------- + +test('buildTraceTooltipHtml returns empty string for non-arrays', () => { + assert.equal(buildTraceTooltipHtml(null), ''); + assert.equal(buildTraceTooltipHtml(undefined), ''); + assert.equal(buildTraceTooltipHtml({}), ''); +}); + +test('buildTraceTooltipHtml returns empty string when fewer than two hops are supplied', () => { + assert.equal(buildTraceTooltipHtml([]), ''); + assert.equal(buildTraceTooltipHtml([{ short_name: 'A', node_id: '!a' }]), ''); +}); + +test('buildTraceTooltipHtml emits a content fragment with arrows between hops', () => { + const html = buildTraceTooltipHtml([ + { short_name: 'AAA', node_id: '!a' }, + { short_name: 'BBB', node_id: '!b' }, + ]); + assert.ok(html.includes('trace-tooltip__content')); + assert.ok(html.includes('trace-tooltip__arrow')); + // One arrow between two badges. + const arrowCount = (html.match(/trace-tooltip__arrow/g) || []).length; + assert.equal(arrowCount, 1); +}); + +test('buildTraceTooltipHtml falls back to node_id when short name is missing', () => { + const html = buildTraceTooltipHtml([ + { node_id: '!a' }, + { node_id: '!b' }, + ]); + // The badge should reference the node_id. + assert.ok(html.includes('!a')); + assert.ok(html.includes('!b')); +}); + +test('buildTraceTooltipHtml filters out malformed entries', () => { + const html = buildTraceTooltipHtml([ + null, + { short_name: 'AAA', node_id: '!a' }, + 'not an object', + { short_name: 'BBB', node_id: '!b' }, + ]); + // Two valid entries → exactly one arrow. + const arrowCount = (html.match(/trace-tooltip__arrow/g) || []).length; + assert.equal(arrowCount, 1); +}); + +test('buildTraceTooltipHtml returns empty string when every entry is malformed', () => { + assert.equal(buildTraceTooltipHtml([null, 'x', 1]), ''); +}); + +// --------------------------------------------------------------------------- +// buildNeighborTooltipHtml +// --------------------------------------------------------------------------- + +test('buildNeighborTooltipHtml returns empty string for falsy segments', () => { + assert.equal(buildNeighborTooltipHtml(null), ''); + assert.equal(buildNeighborTooltipHtml(undefined), ''); +}); + +test('buildNeighborTooltipHtml emits source → target HTML', () => { + const html = buildNeighborTooltipHtml({ + sourceShortName: 'AAA', + targetShortName: 'BBB', + sourceNode: { node_id: '!a', long_name: 'Alpha' }, + targetNode: { node_id: '!b', long_name: 'Beta' }, + sourceRole: 'CLIENT', + targetRole: 'CLIENT', + }); + assert.ok(html.includes('trace-tooltip__content')); + assert.ok(html.includes('trace-tooltip__arrow')); + assert.ok(html.includes('Alpha')); + assert.ok(html.includes('Beta')); +}); + +test('buildNeighborTooltipHtml falls back to node short_name fields', () => { + const html = buildNeighborTooltipHtml({ + sourceNode: { short_name: 'AAA', node_id: '!a' }, + targetNode: { short_name: 'BBB', node_id: '!b' }, + }); + assert.ok(html.includes('trace-tooltip__arrow')); +}); + +test('buildNeighborTooltipHtml falls back to node_id when no short name is present', () => { + const html = buildNeighborTooltipHtml({ + sourceNode: { node_id: '!a' }, + targetNode: { node_id: '!b' }, + }); + assert.ok(html.includes('!a')); + assert.ok(html.includes('!b')); +}); + +test('buildNeighborTooltipHtml returns empty string when either side has no short name', () => { + assert.equal(buildNeighborTooltipHtml({ sourceNode: { node_id: '!a' } }), ''); + assert.equal(buildNeighborTooltipHtml({ targetNode: { node_id: '!b' } }), ''); +}); diff --git a/web/public/assets/js/app/main/constants.js b/web/public/assets/js/app/main/constants.js new file mode 100644 index 0000000..a00bd97 --- /dev/null +++ b/web/public/assets/js/app/main/constants.js @@ -0,0 +1,36 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stable numeric limits shared between ``main.js`` and the helpers extracted + * into ``main/`` submodules. + * + * @module main/constants + */ + +import { SNAPSHOT_WINDOW } from '../snapshot-aggregator.js'; + +/** Maximum number of node rows requested from the API. */ +export const NODE_LIMIT = 1000; + +/** Maximum number of trace rows requested from the API. */ +export const TRACE_LIMIT = 200; + +/** Maximum age (seconds) for traces displayed on the map. */ +export const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60; + +/** Snapshot multiplier — how many rows we ask for to build a richer aggregate. */ +export const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW; diff --git a/web/public/assets/js/app/main/data-fetchers.js b/web/public/assets/js/app/main/data-fetchers.js new file mode 100644 index 0000000..38d5234 --- /dev/null +++ b/web/public/assets/js/app/main/data-fetchers.js @@ -0,0 +1,193 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pure async fetch wrappers for the dashboard JSON API. + * + * Functions accept their own dependencies — chat-enabled flag, message-limit + * normaliser — so they remain free of any closure / DOM state and can be + * unit-tested standalone. + * + * @module main/data-fetchers + */ + +import { NODE_LIMIT, SNAPSHOT_LIMIT, TRACE_LIMIT, TRACE_MAX_AGE_SECONDS } from './constants.js'; +import { resolveTimestampSeconds } from './format-utils.js'; + +/** + * Determine how many snapshots should be requested from the API to build a + * richer aggregate. + * + * @param {number} requestedLimit Desired number of unique entities. + * @param {number} [maxLimit=NODE_LIMIT] Maximum rows accepted by the API. + * @returns {number} Effective request limit honouring {@link SNAPSHOT_LIMIT}. + */ +export function resolveSnapshotLimit(requestedLimit, maxLimit = NODE_LIMIT) { + const base = Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.floor(requestedLimit) + : maxLimit; + const expanded = base * SNAPSHOT_LIMIT; + const candidate = expanded > base ? expanded : base; + return Math.min(candidate, maxLimit); +} + +/** + * Filter trace entries to discard packets older than the configured window. + * + * @param {Array} traces Trace payloads. + * @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds. + * @returns {Array} Recent trace entries. + */ +export function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) { + if (!Array.isArray(traces)) { + return []; + } + if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) { + return [...traces]; + } + const nowSeconds = Math.floor(Date.now() / 1000); + const cutoff = nowSeconds - maxAgeSeconds; + return traces.filter(trace => { + const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso); + return rxTime != null && rxTime >= cutoff; + }); +} + +/** + * Fetch the latest nodes from the JSON API. + * + * @param {number} [limit=NODE_LIMIT] Maximum number of records. + * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. + * @returns {Promise>} Parsed node payloads. + */ +export async function fetchNodes(limit = NODE_LIMIT, since = 0) { + const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); + let url = `/api/nodes?limit=${effectiveLimit}`; + if (since > 0) url += `&since=${since}`; + const r = await fetch(url, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} + +/** + * Retrieve a single node record by identifier from the API. + * + * @param {string} nodeId Canonical node identifier. + * @returns {Promise} Parsed node payload or null when absent. + */ +export async function fetchNodeById(nodeId) { + if (typeof nodeId !== 'string') return null; + const trimmed = nodeId.trim(); + if (trimmed.length === 0) return null; + const r = await fetch(`/api/nodes/${encodeURIComponent(trimmed)}`, { cache: 'default' }); + if (r.status === 404) return null; + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} + +/** + * Fetch recent messages from the JSON API. + * + * @param {number} limit Maximum number of rows. + * @param {{ encrypted?: boolean, since?: number, chatEnabled?: boolean, normaliseMessageLimit?: Function }} options + * Retrieval flags and dependency hooks. When ``chatEnabled`` is false the + * function short-circuits to an empty array without contacting the API. + * @returns {Promise>} Parsed message payloads. + */ +export async function fetchMessages(limit, options = {}) { + const { chatEnabled = true, normaliseMessageLimit, encrypted = false, since = 0 } = options; + if (!chatEnabled) return []; + const safeLimit = typeof normaliseMessageLimit === 'function' + ? normaliseMessageLimit(limit) + : limit; + const params = new URLSearchParams({ limit: String(safeLimit) }); + if (encrypted) { + params.set('encrypted', 'true'); + } + if (since > 0) { + params.set('since', String(since)); + } + const query = params.toString(); + const r = await fetch(`/api/messages?${query}`, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} + +/** + * Fetch neighbour information from the JSON API. + * + * @param {number} [limit=NODE_LIMIT] Maximum number of rows. + * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. + * @returns {Promise>} Parsed neighbour payloads. + */ +export async function fetchNeighbors(limit = NODE_LIMIT, since = 0) { + const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); + let url = `/api/neighbors?limit=${effectiveLimit}`; + if (since > 0) url += `&since=${since}`; + const r = await fetch(url, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} + +/** + * Fetch traceroute observations from the JSON API. + * + * @param {number} [limit=TRACE_LIMIT] Maximum number of records. + * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. + * @returns {Promise>} Parsed trace payloads. + */ +export async function fetchTraces(limit = TRACE_LIMIT, since = 0) { + const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TRACE_LIMIT; + const effectiveLimit = Math.min(safeLimit, NODE_LIMIT); + let url = `/api/traces?limit=${effectiveLimit}`; + if (since > 0) url += `&since=${since}`; + const r = await fetch(url, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + const traces = await r.json(); + return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS); +} + +/** + * Fetch telemetry entries from the JSON API. + * + * @param {number} [limit=NODE_LIMIT] Maximum number of rows. + * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. + * @returns {Promise>} Parsed telemetry payloads. + */ +export async function fetchTelemetry(limit = NODE_LIMIT, since = 0) { + const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); + let url = `/api/telemetry?limit=${effectiveLimit}`; + if (since > 0) url += `&since=${since}`; + const r = await fetch(url, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} + +/** + * Fetch position packets from the JSON API. + * + * @param {number} [limit=NODE_LIMIT] Maximum number of rows. + * @param {number} [since=0] Unix timestamp; only rows newer than this are returned. + * @returns {Promise>} Parsed position payloads. + */ +export async function fetchPositions(limit = NODE_LIMIT, since = 0) { + const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT); + let url = `/api/positions?limit=${effectiveLimit}`; + if (since > 0) url += `&since=${since}`; + const r = await fetch(url, { cache: 'default' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); +} diff --git a/web/public/assets/js/app/main/data-merge.js b/web/public/assets/js/app/main/data-merge.js new file mode 100644 index 0000000..f247f40 --- /dev/null +++ b/web/public/assets/js/app/main/data-merge.js @@ -0,0 +1,181 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pure data-merge helpers — fold position and telemetry packets into the + * node collection without touching any closure or DOM state. + * + * @module main/data-merge + */ + +import { resolveTimestampSeconds, toFiniteNumber } from './format-utils.js'; + +/** + * Merge recent position packets into the node list. + * + * Mutates each node entry in place, updating coordinates / altitude / + * position-time fields when the incoming packet carries a strictly newer + * timestamp. + * + * @param {Array} nodes Node payloads. + * @param {Array} positions Position entries. + * @returns {void} + */ +export function mergePositionsIntoNodes(nodes, positions) { + if (!Array.isArray(nodes) || !Array.isArray(positions) || nodes.length === 0) return; + + const nodesById = new Map(); + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + const key = typeof node.node_id === 'string' ? node.node_id : null; + if (key) nodesById.set(key, node); + } + + if (nodesById.size === 0) return; + + const updated = new Set(); + for (const pos of positions) { + if (!pos || typeof pos !== 'object') continue; + const nodeId = typeof pos.node_id === 'string' ? pos.node_id : null; + if (!nodeId || updated.has(nodeId)) continue; + const node = nodesById.get(nodeId); + if (!node) continue; + + const lat = toFiniteNumber(pos.latitude); + const lon = toFiniteNumber(pos.longitude); + if (lat == null || lon == null) continue; + + const currentTimestamp = resolveTimestampSeconds(node.position_time, node.pos_time_iso); + const incomingTimestamp = resolveTimestampSeconds(pos.position_time, pos.position_time_iso); + if (currentTimestamp != null) { + if (incomingTimestamp == null || incomingTimestamp <= currentTimestamp) { + continue; + } + } + + updated.add(nodeId); + node.latitude = lat; + node.longitude = lon; + + const alt = toFiniteNumber(pos.altitude); + if (alt != null) node.altitude = alt; + + const posTime = toFiniteNumber(pos.position_time); + if (posTime != null) { + node.position_time = posTime; + node.pos_time_iso = typeof pos.position_time_iso === 'string' && pos.position_time_iso.length + ? pos.position_time_iso + : new Date(posTime * 1000).toISOString(); + } else if (typeof pos.position_time_iso === 'string' && pos.position_time_iso.length) { + node.pos_time_iso = pos.position_time_iso; + } + + if (pos.location_source != null && pos.location_source !== '') { + node.location_source = pos.location_source; + } + + const precision = toFiniteNumber(pos.precision_bits); + if (precision != null) node.precision_bits = precision; + } +} + +/** + * Build a lookup table of telemetry entries keyed by node identifier. + * + * @param {Array} entries Telemetry payloads. + * @returns {{byNodeId: Map, byNodeNum: Map}} + * Indexed telemetry data. + */ +export function buildTelemetryIndex(entries) { + const byNodeId = new Map(); + const byNodeNum = new Map(); + if (!Array.isArray(entries)) { + return { byNodeId, byNodeNum }; + } + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const nodeId = typeof entry.node_id === 'string' ? entry.node_id : (typeof entry.nodeId === 'string' ? entry.nodeId : null); + const nodeNumRaw = entry.node_num ?? entry.nodeNum; + const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw); + const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime); + const telemetryTime = toFiniteNumber(entry.telemetry_time ?? entry.telemetryTime); + const timestamp = rxTime != null ? rxTime : telemetryTime != null ? telemetryTime : Number.NEGATIVE_INFINITY; + if (nodeId) { + const existing = byNodeId.get(nodeId); + if (!existing || timestamp > existing.timestamp) { + byNodeId.set(nodeId, { entry, timestamp }); + } + } + if (Number.isFinite(nodeNum)) { + const existing = byNodeNum.get(nodeNum); + if (!existing || timestamp > existing.timestamp) { + byNodeNum.set(nodeNum, { entry, timestamp }); + } + } + } + return { byNodeId, byNodeNum }; +} + +/** + * Merge telemetry metrics into the node list. + * + * Mutates each node entry in place, copying battery / voltage / channel + * utilisation / environmental fields from the freshest telemetry packet that + * matches by ``node_id`` or ``node_num``. + * + * @param {Array} nodes Node payloads. + * @param {Array} telemetryEntries Telemetry data. + * @returns {void} + */ +export function mergeTelemetryIntoNodes(nodes, telemetryEntries) { + if (!Array.isArray(nodes) || !nodes.length) return; + const { byNodeId, byNodeNum } = buildTelemetryIndex(telemetryEntries); + for (const node of nodes) { + if (!node || typeof node !== 'object') continue; + const nodeId = typeof node.node_id === 'string' ? node.node_id : (typeof node.nodeId === 'string' ? node.nodeId : null); + const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum; + const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw); + let telemetryEntry = null; + if (nodeId && byNodeId.has(nodeId)) { + telemetryEntry = byNodeId.get(nodeId).entry; + } else if (Number.isFinite(nodeNum) && byNodeNum.has(nodeNum)) { + telemetryEntry = byNodeNum.get(nodeNum).entry; + } + if (!telemetryEntry || typeof telemetryEntry !== 'object') continue; + const metrics = { + battery_level: toFiniteNumber(telemetryEntry.battery_level ?? telemetryEntry.batteryLevel), + voltage: toFiniteNumber(telemetryEntry.voltage), + uptime_seconds: toFiniteNumber(telemetryEntry.uptime_seconds ?? telemetryEntry.uptimeSeconds), + channel_utilization: toFiniteNumber(telemetryEntry.channel_utilization ?? telemetryEntry.channelUtilization), + air_util_tx: toFiniteNumber(telemetryEntry.air_util_tx ?? telemetryEntry.airUtilTx), + temperature: toFiniteNumber(telemetryEntry.temperature), + relative_humidity: toFiniteNumber(telemetryEntry.relative_humidity ?? telemetryEntry.relativeHumidity), + barometric_pressure: toFiniteNumber(telemetryEntry.barometric_pressure ?? telemetryEntry.barometricPressure), + }; + for (const [key, value] of Object.entries(metrics)) { + if (value == null) continue; + node[key] = value; + } + const telemetryTime = toFiniteNumber(telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime); + if (telemetryTime != null) { + node.telemetry_time = telemetryTime; + } + const rxTime = toFiniteNumber(telemetryEntry.rx_time ?? telemetryEntry.rxTime); + if (rxTime != null) { + node.telemetry_rx_time = rxTime; + } + } +} diff --git a/web/public/assets/js/app/main/filter-helpers.js b/web/public/assets/js/app/main/filter-helpers.js new file mode 100644 index 0000000..3e822df --- /dev/null +++ b/web/public/assets/js/app/main/filter-helpers.js @@ -0,0 +1,53 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Filter-key helpers used to disambiguate role buttons across protocols. + * + * @module main/filter-helpers + */ + +import { isMeshcoreProtocol } from '../protocol-helpers.js'; +import { getRoleKey } from '../role-helpers.js'; + +/** + * Canonical protocol token for use in compound filter keys. + * + * Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that + * pre-protocol legacy records land in the Meshtastic filter bucket. + * + * @param {string|null|undefined} protocol Raw protocol value. + * @returns {'meshtastic'|'meshcore'} Normalised protocol token. + */ +export function normalizeFilterProtocol(protocol) { + return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic'; +} + +/** + * Build a compound filter key that encodes both protocol and role. + * + * Using compound keys avoids collisions between role names that appear in + * both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter + * set stores these keys so that clicking the MeshCore SENSOR button only + * includes MeshCore SENSOR nodes, not Meshtastic ones. + * + * @param {*} role Raw role value from the API. + * @param {string|null|undefined} protocol Protocol string from the API. + * @returns {string} Compound key in the form ``":"``. + */ +export function makeRoleFilterKey(role, protocol) { + return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`; +} diff --git a/web/public/assets/js/app/main/format-utils.js b/web/public/assets/js/app/main/format-utils.js new file mode 100644 index 0000000..fd9da8c --- /dev/null +++ b/web/public/assets/js/app/main/format-utils.js @@ -0,0 +1,282 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pure formatting helpers used throughout the dashboard. + * + * Extracted from ``main.js`` so that submodules and unit tests can import + * them without dragging in the entire ``initializeApp`` closure. Every + * function here is deterministic and free of closure / DOM state. + * + * @module main/format-utils + */ + +/** + * Pad a numeric value with leading zeros. + * + * @param {number} n Numeric value. + * @returns {string} Padded string. + */ +export function pad(n) { + return String(n).padStart(2, '0'); +} + +/** + * Format a ``Date`` object as ``HH:MM:SS``. + * + * @param {Date} d Date instance. + * @returns {string} Time string. + */ +export function formatTime(d) { + return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); +} + +/** + * Format a ``Date`` object as ``YYYY-MM-DD``. + * + * @param {Date} d Date instance. + * @returns {string} Date string. + */ +export function formatDate(d) { + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); +} + +/** + * Format hardware model strings for display. + * + * @param {*} v Raw hardware model value. + * @returns {string} Sanitised string. + */ +export function fmtHw(v) { + return v && v !== 'UNSET' ? String(v) : ''; +} + +/** + * Format coordinate values with a configurable precision. + * + * @param {*} v Raw coordinate value. + * @param {number} [d=5] Decimal precision. + * @returns {string} Formatted coordinate string. + */ +export function fmtCoords(v, d = 5) { + if (v == null || v === '') return ''; + const n = Number(v); + return Number.isFinite(n) ? n.toFixed(d) : ''; +} + +/** + * Format SNR readings with a ``dB`` suffix. + * + * @param {*} value Raw SNR value. + * @returns {string} Formatted SNR string. + */ +export function formatSnrDisplay(value) { + if (value == null || value === '') return ''; + const n = Number(value); + if (!Number.isFinite(n)) return ''; + return `${n.toFixed(1)} dB`; +} + +/** + * Convert a duration in seconds into a human readable string. + * + * @param {number} unixSec Duration in seconds. + * @returns {string} Human readable representation. + */ +export function timeHum(unixSec) { + if (!unixSec) return ''; + if (unixSec < 0) return '0s'; + if (unixSec < 60) return `${unixSec}s`; + if (unixSec < 3600) return `${Math.floor(unixSec / 60)}m ${Math.floor((unixSec % 60))}s`; + if (unixSec < 86400) return `${Math.floor(unixSec / 3600)}h ${Math.floor((unixSec % 3600) / 60)}m`; + return `${Math.floor(unixSec / 86400)}d ${Math.floor((unixSec % 86400) / 3600)}h`; +} + +/** + * Return a relative time string describing how long ago an event occurred. + * + * @param {number} unixSec Timestamp in seconds. + * @param {number} [nowSec] Reference timestamp. + * @returns {string} Human readable relative time. + */ +export function timeAgo(unixSec, nowSec = Date.now() / 1000) { + if (!unixSec) return ''; + const diff = Math.floor(nowSec - Number(unixSec)); + if (diff < 0) return '0s'; + if (diff < 60) return `${diff}s`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ${Math.floor((diff % 60))}s`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + return `${Math.floor(diff / 86400)}d ${Math.floor((diff % 86400) / 3600)}h`; +} + +/** + * Convert arbitrary values to finite numbers when possible. + * + * @param {*} value Raw value. + * @returns {number|null} Finite number or null when conversion fails. + */ +export function toFiniteNumber(value) { + if (value == null || value === '') return null; + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Determine the best-effort timestamp in seconds from numeric or ISO values. + * + * @param {*} numeric Numeric timestamp. + * @param {*} isoString ISO formatted timestamp. + * @returns {number|null} Timestamp in seconds. + */ +export function resolveTimestampSeconds(numeric, isoString) { + const parsedNumeric = toFiniteNumber(numeric); + if (parsedNumeric != null) return parsedNumeric; + if (typeof isoString === 'string' && isoString.length) { + const parsedIso = Date.parse(isoString); + if (Number.isFinite(parsedIso)) { + return parsedIso / 1000; + } + } + return null; +} + +/** + * Escape a string for safe use as a CSS selector fragment. + * + * Falls back to a manual escape when ``CSS.escape`` is unavailable. + * + * @param {string} value Selector fragment. + * @returns {string} Escaped selector fragment safe for interpolation. + */ +export function cssEscape(value) { + if (typeof value !== 'string' || value.length === 0) { + return ''; + } + if (typeof window !== 'undefined' && window.CSS && typeof window.CSS.escape === 'function') { + return window.CSS.escape(value); + } + return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`); +} + +/** + * Format uptime values for the short-info overlay. + * + * @param {*} value Raw uptime value. + * @returns {string} Human readable uptime string. + */ +export function formatShortInfoUptime(value) { + if (value == null || value === '') return ''; + const num = Number(value); + if (!Number.isFinite(num)) return ''; + return num === 0 ? '0s' : timeHum(num); +} + +/** + * Format overlay values with an em dash fallback when blank. + * + * @param {*} value Candidate value. + * @returns {string} Formatted value or em dash. + */ +export function shortInfoValueOrDash(value) { + return value != null && value !== '' ? String(value) : '—'; +} + +/** + * Retrieve the first present property value from a collection of objects. + * + * @param {Array} sources Candidate objects. + * @param {Array} keys Ordered property names to inspect. + * @returns {*} First present non-blank value or ``null`` when absent. + */ +export function pickFirstProperty(sources, keys) { + if (!Array.isArray(sources) || !Array.isArray(keys)) { + return null; + } + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const value = source[key]; + if (value == null) continue; + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length === 0) { + continue; + } + return trimmed; + } + return value; + } + } + return null; +} + +/** + * Retrieve the first finite numeric property from candidate objects. + * + * @param {Array} sources Candidate objects. + * @param {Array} keys Ordered property names to inspect. + * @returns {?number} First finite number when available. + */ +export function pickNumericProperty(sources, keys) { + if (!Array.isArray(sources) || !Array.isArray(keys)) { + return null; + } + for (const source of sources) { + if (!source || typeof source !== 'object') continue; + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + const raw = source[key]; + if (raw == null || raw === '') continue; + const num = typeof raw === 'number' ? raw : Number(raw); + if (Number.isFinite(num)) { + return num; + } + } + } + return null; +} + +/** + * Parse a node identifier or numeric reference into a finite number. + * + * @param {*} ref Identifier or numeric reference. + * @returns {number|null} Parsed number or ``null``. + */ +export function parseNodeNumericRef(ref) { + if (ref == null) return null; + if (typeof ref === 'number') { + return Number.isFinite(ref) ? ref : null; + } + if (typeof ref === 'string') { + const trimmed = ref.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('!')) { + const hex = trimmed.slice(1); + if (!/^[0-9A-Fa-f]+$/.test(hex)) return null; + const parsedHex = Number.parseInt(hex, 16); + return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null; + } + if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) { + const parsedHex = Number.parseInt(trimmed, 16); + return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + const parsed = Number(ref); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/web/public/assets/js/app/main/fullscreen-helpers.js b/web/public/assets/js/app/main/fullscreen-helpers.js new file mode 100644 index 0000000..72160ca --- /dev/null +++ b/web/public/assets/js/app/main/fullscreen-helpers.js @@ -0,0 +1,54 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pure helpers for the Fullscreen API used by the map fullscreen toggle. + * + * @module main/fullscreen-helpers + */ + +/** + * Resolve the element currently being displayed in fullscreen mode. + * + * @returns {Element|null} Active fullscreen element if any. + */ +export function getActiveFullscreenElement() { + if (typeof document === 'undefined') return null; + return ( + document.fullscreenElement || + document.webkitFullscreenElement || + document.msFullscreenElement || + null + ); +} + +/** + * Wrap a legend button click handler so it always calls + * ``preventDefault`` and ``stopPropagation`` before running the body. + * + * Centralising this prevents the two-line boilerplate from repeating in every + * legend button handler, reducing token-level duplication. + * + * @param {function(Event): void} fn Handler body. + * @returns {function(Event): void} Full click listener. + */ +export function legendClickHandler(fn) { + return (event) => { + event.preventDefault(); + event.stopPropagation(); + fn(event); + }; +} diff --git a/web/public/assets/js/app/main/long-link-router.js b/web/public/assets/js/app/main/long-link-router.js new file mode 100644 index 0000000..4d1bb51 --- /dev/null +++ b/web/public/assets/js/app/main/long-link-router.js @@ -0,0 +1,125 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Helpers used by the long-name link click router and overlay name fallback. + * + * @module main/long-link-router + */ + +import { canonicalNodeIdentifier, normalizeNodeNameValue } from '../node-rendering.js'; + +/** + * Determine whether a long name link should trigger the overlay behaviour. + * + * @param {?Element} link Anchor element. + * @returns {boolean} ``true`` when the link participates in overlays. + */ +export function shouldHandleNodeLongLink(link) { + if (!link || !link.dataset) return false; + if ('nodeDetailLink' in link.dataset && link.dataset.nodeDetailLink === 'false') { + return false; + } + return true; +} + +/** + * Extract the canonical identifier from a node detail hyperlink. + * + * @param {string} href Link href attribute. + * @returns {string} Canonical identifier or ``''``. + */ +export function extractIdentifierFromHref(href) { + if (typeof href !== 'string' || href.length === 0) { + return ''; + } + const match = href.match(/\/nodes\/(![^/?#]+)/i); + if (!match || !match[1]) { + return ''; + } + try { + const decoded = decodeURIComponent(match[1]); + return canonicalNodeIdentifier(decoded) ?? ''; + } catch { + return canonicalNodeIdentifier(match[1]) ?? ''; + } +} + +/** + * Extract the canonical node identifier from the provided link element. + * + * @param {?Element} link Anchor element. + * @returns {string} Canonical node identifier or ``''`` when unavailable. + */ +export function getNodeIdentifierFromLink(link) { + if (!link) return ''; + const datasetIdentifier = link.dataset && typeof link.dataset.nodeId === 'string' + ? canonicalNodeIdentifier(link.dataset.nodeId) + : null; + if (datasetIdentifier) { + return datasetIdentifier; + } + if (typeof link.getAttribute === 'function') { + const attrHref = link.getAttribute('href'); + const canonicalFromAttr = extractIdentifierFromHref(attrHref); + if (canonicalFromAttr) { + return canonicalFromAttr; + } + } + if (typeof link.href === 'string') { + const canonicalFromProperty = extractIdentifierFromHref(link.href); + if (canonicalFromProperty) { + return canonicalFromProperty; + } + } + return ''; +} + +/** + * Determine the preferred display name for overlay content. + * + * @param {Object} node Node payload. + * @returns {string} Friendly display name. + */ +export function getNodeDisplayNameForOverlay(node) { + if (!node || typeof node !== 'object') return ''; + return ( + normalizeNodeNameValue(node.long_name ?? node.longName) || + normalizeNodeNameValue(node.short_name ?? node.shortName) || + (typeof node.node_id === 'string' ? node.node_id : '') + ); +} + +/** + * Populate missing node name fields with sensible defaults. + * + * @param {Object} node Node payload. + * @returns {void} + */ +export function applyNodeNameFallback(node) { + if (!node || typeof node !== 'object') return; + const short = normalizeNodeNameValue(node.short_name ?? node.shortName); + const long = normalizeNodeNameValue(node.long_name ?? node.longName); + if (short || long) return; + const nodeId = normalizeNodeNameValue(node.node_id ?? node.nodeId); + if (!nodeId) return; + const fallbackShort = nodeId.slice(-4); + const fallbackLong = `Meshtastic ${nodeId}`; + node.short_name = fallbackShort; + node.long_name = fallbackLong; + if ('shortName' in node) node.shortName = fallbackShort; + if ('longName' in node) node.longName = fallbackLong; +} diff --git a/web/public/assets/js/app/main/offline-tile-layer.js b/web/public/assets/js/app/main/offline-tile-layer.js new file mode 100644 index 0000000..af7985b --- /dev/null +++ b/web/public/assets/js/app/main/offline-tile-layer.js @@ -0,0 +1,134 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Offline-fallback Leaflet ``GridLayer`` factory. + * + * Receives the Leaflet global as a parameter so the module remains free of + * implicit closure dependencies while still rendering identical placeholder + * tiles when network basemaps are unavailable. + * + * @module main/offline-tile-layer + */ + +import { tileToLat, tileToLon } from './tile-coords.js'; + +/** + * Create a minimal Leaflet tile layer that renders offline tiles from cache. + * + * @param {Object|null} L Leaflet global, or ``null`` when Leaflet is unavailable. + * @returns {Object|null} Configured tile layer instance, or ``null`` when Leaflet is missing. + */ +export function createOfflineTileLayer(L) { + if (!L || typeof L.gridLayer !== 'function') return null; + const offlineLayer = L.gridLayer({ className: 'map-tiles map-tiles-offline' }); + /** @type {HTMLElement|null} */ + let cachedOfflineFallbackTile = null; + + /** + * Provide a minimal placeholder tile when canvas rendering is not available. + * + * @param {number} size Pixel width and height of the tile. + * @returns {HTMLElement} Cloned fallback element ready for Leaflet consumption. + */ + function getOfflineFallbackTile(size) { + if (!cachedOfflineFallbackTile) { + const placeholder = document.createElement('div'); + placeholder.className = 'offline-tile-fallback'; + placeholder.style.width = `${size}px`; + placeholder.style.height = `${size}px`; + placeholder.style.backgroundColor = 'rgba(33, 66, 110, 0.92)'; + placeholder.style.display = 'flex'; + placeholder.style.alignItems = 'center'; + placeholder.style.justifyContent = 'center'; + placeholder.style.color = 'rgba(255, 255, 255, 0.6)'; + placeholder.style.font = 'bold 14px system-ui, sans-serif'; + placeholder.style.textTransform = 'uppercase'; + placeholder.textContent = 'Offline tile'; + cachedOfflineFallbackTile = placeholder; + } + return /** @type {HTMLElement} */ (cachedOfflineFallbackTile.cloneNode(true)); + } + + /** + * Render a placeholder tile for offline map usage. + * + * @param {{x: number, y: number, z: number}} coords Tile coordinates supplied by Leaflet. + * @returns {HTMLElement} Tile node containing placeholder artwork. + */ + offlineLayer.createTile = coords => { + const size = 256; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) { + console.warn('Canvas 2D context unavailable for offline tile rendering. Using fallback placeholder.'); + return getOfflineFallbackTile(size); + } + try { + const gradient = ctx.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, 'rgba(33, 66, 110, 0.92)'); + gradient.addColorStop(1, 'rgba(64, 98, 144, 0.92)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + const steps = 4; + for (let i = 1; i < steps; i++) { + const pos = (size / steps) * i; + ctx.beginPath(); + ctx.moveTo(pos, 0); + ctx.lineTo(pos, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, pos); + ctx.lineTo(size, pos); + ctx.stroke(); + } + + const west = tileToLon(coords.x, coords.z); + const east = tileToLon(coords.x + 1, coords.z); + const north = tileToLat(coords.y, coords.z); + const south = tileToLat(coords.y + 1, coords.z); + + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = '12px system-ui, sans-serif'; + ctx.textBaseline = 'top'; + ctx.fillText(`${west.toFixed(1)}°`, 8, 8); + ctx.textBaseline = 'bottom'; + ctx.fillText(`${east.toFixed(1)}°`, 8, size - 8); + ctx.textAlign = 'right'; + ctx.textBaseline = 'top'; + ctx.fillText(`${north.toFixed(1)}°`, size - 8, 8); + ctx.textBaseline = 'bottom'; + ctx.fillText(`${south.toFixed(1)}°`, size - 8, size - 8); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.font = 'bold 22px system-ui, sans-serif'; + ctx.fillText('PotatoMesh offline basemap', size / 2, size / 2); + + return canvas; + } catch (error) { + console.error('Failed to render offline tile. Falling back to placeholder element.', error); + return getOfflineFallbackTile(size); + } + }; + return offlineLayer; +} diff --git a/web/public/assets/js/app/main/protocol-icons.js b/web/public/assets/js/app/main/protocol-icons.js new file mode 100644 index 0000000..cdbd3ec --- /dev/null +++ b/web/public/assets/js/app/main/protocol-icons.js @@ -0,0 +1,57 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Protocol-icon ```` builders shared between the legend and meta-row + * controls. + * + * @module main/protocol-icons + */ + +import { MESHTASTIC_ICON_SRC, MESHCORE_ICON_SRC } from '../protocol-helpers.js'; + +/** + * Build a protocol icon image element with consistent attributes. + * + * Both the legend and the meta-row protocol toggle use this helper so the + * output is identical regardless of insertion method. + * + * @param {string} src Absolute path to the SVG asset. + * @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``. + * @returns {HTMLImageElement} Icon element ready to append. + */ +export function buildProtocolIconImg(src, variantClass) { + const img = document.createElement('img'); + img.setAttribute('src', src); + img.setAttribute('alt', ''); + img.setAttribute('width', '12'); + img.setAttribute('height', '12'); + img.setAttribute('aria-hidden', 'true'); + img.setAttribute('loading', 'lazy'); + img.setAttribute('decoding', 'async'); + img.className = `protocol-icon ${variantClass}`; + return img; +} + +/** @returns {HTMLImageElement} Meshtastic protocol icon element. */ +export function buildMeshtasticIconImg() { + return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic'); +} + +/** @returns {HTMLImageElement} MeshCore protocol icon element. */ +export function buildMeshcoreIconImg() { + return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore'); +} diff --git a/web/public/assets/js/app/main/short-html-renderer.js b/web/public/assets/js/app/main/short-html-renderer.js new file mode 100644 index 0000000..ca66456 --- /dev/null +++ b/web/public/assets/js/app/main/short-html-renderer.js @@ -0,0 +1,92 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Render the role-aware short-name badge used by maps, tables, popups, and + * overlay surfaces. + * + * The function is deliberately dependency-free besides shared modules so it + * can be exposed via ``globalThis.PotatoMesh.renderShortHtml`` and consumed by + * the node-detail page without dragging the dashboard's closure state along. + * + * @module main/short-html-renderer + */ + +import { escapeHtml } from '../utils.js'; +import { collectTelemetryMetrics } from '../short-info-telemetry.js'; +import { getRoleColor, getRoleTextColor, normalizeRole } from '../role-helpers.js'; + +/** + * Render a short name badge with role-based styling. + * + * @param {string} short Short node identifier. + * @param {string} role Node role string. + * @param {string} longName Full node name. + * @param {?Object} nodeData Optional node metadata attached to the badge. + * @returns {string} HTML snippet describing the badge. + */ +export function renderShortHtml(short, role, longName, nodeData = null) { + const safeTitle = longName ? escapeHtml(String(longName)) : ''; + const titleAttr = safeTitle ? ` title="${safeTitle}"` : ''; + const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role)); + let infoAttr = ''; + if (nodeData && typeof nodeData === 'object') { + const info = { + nodeId: nodeData.node_id ?? nodeData.nodeId ?? '', + nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null, + shortName: short != null ? String(short) : (nodeData.short_name ?? ''), + longName: nodeData.long_name ?? longName ?? '', + role: roleValue, + hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '', + telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null, + }; + Object.assign(info, collectTelemetryMetrics(nodeData)); + const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`]; + const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : ''; + if (attrNodeIdRaw) { + attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`); + } + const attrNodeNum = Number(info.nodeNum); + if (Number.isFinite(attrNodeNum)) { + attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`); + } + infoAttr = attrParts.join(''); + } + if (!short) { + return ` ? `; + } + // Pad the label for the badge. For plain-ASCII names that are already + // 4 characters (meshtastic always stores exactly 4) no padding is added. + // Shorter names or names containing emoji/non-ASCII get a single space + // on each side — grapheme width varies too much for character-count + // centering to work reliably. + const raw = String(short); + const graphemeCount = typeof Intl !== 'undefined' && Intl.Segmenter + ? [...new Intl.Segmenter().segment(raw)].length + : raw.length; + let centred; + if (graphemeCount >= 4) { + centred = raw; + } else { + centred = ` ${raw} `; + } + const padded = escapeHtml(centred).replace(/ /g, ' '); + const protocol = nodeData?.protocol ?? null; + const color = getRoleColor(roleValue, protocol); + const textColor = getRoleTextColor(roleValue, protocol); + const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`; + return `${padded}`; +} diff --git a/web/public/assets/js/app/main/sort-comparators.js b/web/public/assets/js/app/main/sort-comparators.js new file mode 100644 index 0000000..facc0ca --- /dev/null +++ b/web/public/assets/js/app/main/sort-comparators.js @@ -0,0 +1,83 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Pure value-presence guards and comparators for the nodes table. + * + * @module main/sort-comparators + */ + +/** + * Determine whether a value should count as present when sorting strings. + * + * @param {*} value Candidate value extracted from a node record. + * @returns {boolean} True when the value is a non-empty string. + */ +export function hasStringValue(value) { + if (value == null) return false; + return String(value).trim().length > 0; +} + +/** + * Determine whether the provided value can be interpreted as a finite number. + * + * @param {*} value Candidate value extracted from a node record. + * @returns {boolean} True when the value parses to a finite number. + */ +export function hasNumberValue(value) { + if (value == null || value === '') return false; + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num); +} + +/** + * Locale-aware comparator for string table values. + * + * @param {*} a First value. + * @param {*} b Second value. + * @returns {number} Comparator result compatible with ``Array.prototype.sort``. + */ +export function compareString(a, b) { + const strA = (a == null ? '' : String(a)).trim(); + const strB = (b == null ? '' : String(b)).trim(); + const hasA = strA.length > 0; + const hasB = strB.length > 0; + if (!hasA && !hasB) return 0; + if (!hasA) return 1; + if (!hasB) return -1; + return strA.localeCompare(strB, undefined, { numeric: true, sensitivity: 'base' }); +} + +/** + * Comparator for numeric table values that tolerates string inputs. + * + * @param {*} a First value. + * @param {*} b Second value. + * @returns {number} Comparator result for ``Array.prototype.sort``. + */ +export function compareNumber(a, b) { + const numA = typeof a === 'number' ? a : Number(a); + const numB = typeof b === 'number' ? b : Number(b); + const validA = Number.isFinite(numA); + const validB = Number.isFinite(numB); + if (validA && validB) { + if (numA === numB) return 0; + return numA < numB ? -1 : 1; + } + if (validA) return -1; + if (validB) return 1; + return 0; +} diff --git a/web/public/assets/js/app/main/tile-coords.js b/web/public/assets/js/app/main/tile-coords.js new file mode 100644 index 0000000..df2f0d4 --- /dev/null +++ b/web/public/assets/js/app/main/tile-coords.js @@ -0,0 +1,44 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tile-index ↔ longitude/latitude conversions for slippy map tiles. + * + * @module main/tile-coords + */ + +/** + * Convert a tile X coordinate to longitude degrees. + * + * @param {number} x Tile X index. + * @param {number} z Zoom level. + * @returns {number} Longitude in degrees. + */ +export function tileToLon(x, z) { + return (x / Math.pow(2, z)) * 360 - 180; +} + +/** + * Convert a tile Y coordinate to latitude degrees. + * + * @param {number} y Tile Y index. + * @param {number} z Zoom level. + * @returns {number} Latitude in degrees. + */ +export function tileToLat(y, z) { + const n = Math.PI - (2 * Math.PI * y) / Math.pow(2, z); + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} diff --git a/web/public/assets/js/app/main/tooltip-html.js b/web/public/assets/js/app/main/tooltip-html.js new file mode 100644 index 0000000..5d3e73f --- /dev/null +++ b/web/public/assets/js/app/main/tooltip-html.js @@ -0,0 +1,78 @@ +/* + * Copyright © 2025-26 l5yth & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * HTML builders for trace and neighbour map tooltips. + * + * @module main/tooltip-html + */ + +import { normalizeNodeNameValue } from '../node-rendering.js'; +import { renderShortHtml } from './short-html-renderer.js'; + +/** + * Build tooltip HTML showing styled short-name badges for a trace path. + * + * @param {Array} pathNodes Ordered node payloads along the trace. + * @returns {string} HTML fragment or ``''`` when unavailable. + */ +export function buildTraceTooltipHtml(pathNodes) { + if (!Array.isArray(pathNodes) || pathNodes.length < 2) { + return ''; + } + const parts = pathNodes + .map(node => { + if (!node || typeof node !== 'object') { + return null; + } + const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : ''); + const long = normalizeNodeNameValue(node.long_name ?? node.longName) || ''; + return renderShortHtml(short, node.role, long, node); + }) + .filter(Boolean); + if (!parts.length) return ''; + const arrow = ''; + return `
${parts.join(arrow)}
`; +} + +/** + * Build tooltip HTML for a neighbor segment showing styled short-name badges. + * + * @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor. + * @returns {string} HTML fragment or ``''`` when unavailable. + */ +export function buildNeighborTooltipHtml(segment) { + if (!segment) return ''; + const sourceNode = segment.sourceNode || null; + const targetNode = segment.targetNode || null; + const sourceShort = normalizeNodeNameValue( + segment.sourceShortName || + (sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) || + (sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '') + ); + const targetShort = normalizeNodeNameValue( + segment.targetShortName || + (targetNode ? targetNode.short_name ?? targetNode.shortName : null) || + (targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '') + ); + if (!sourceShort || !targetShort) return ''; + const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || ''; + const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || ''; + const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {}); + const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {}); + const arrow = ''; + return `
${sourceHtml}${arrow}${targetHtml}
`; +}