From eae3f75162691df82eb8e9f6c6441e7c986c7a41 Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:57:58 +0200 Subject: [PATCH] web: keep map overlays open and repaint only what changed (#881) (#988) --- CHANGELOG.md | 1 + .../js/app/__tests__/live-map-harness.js | 348 ++++++++++++++ .../main-map-overlay-anchors.test.js | 280 +++++++++++ .../app/__tests__/main-repaint-scope.test.js | 427 ++++++++++++++++ web/public/assets/js/app/main.js | 227 ++++++++- .../__tests__/map-overlay-anchors.test.js | 214 +++++++++ .../marker-overlay-preservation.test.js | 6 + .../main/__tests__/repaint-planner.test.js | 402 ++++++++++++++++ .../assets/js/app/main/map-overlay-anchors.js | 183 +++++++ .../app/main/marker-overlay-preservation.js | 47 +- .../assets/js/app/main/repaint-planner.js | 454 ++++++++++++++++++ 11 files changed, 2537 insertions(+), 52 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/live-map-harness.js create mode 100644 web/public/assets/js/app/__tests__/main-map-overlay-anchors.test.js create mode 100644 web/public/assets/js/app/__tests__/main-repaint-scope.test.js create mode 100644 web/public/assets/js/app/main/__tests__/map-overlay-anchors.test.js create mode 100644 web/public/assets/js/app/main/__tests__/repaint-planner.test.js create mode 100644 web/public/assets/js/app/main/map-overlay-anchors.js create mode 100644 web/public/assets/js/app/main/repaint-planner.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3020f7bf..8f74341d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * Matrix: the bridge crate declares its crates.io package metadata, and its README screenshot no longer links outside the crate (#879) * Data: `TRANSPORT=udp` listens on both Meshtastic multicast groups by default, `239.0.0.69` (firmware 2.8+) and `224.0.0.69`; `MESH_UDP_GROUP` takes a comma-separated list. If you set `MESH_UDP_GROUP=224.0.0.69` before, remove it to also hear firmware 2.8+ nodes (#903) * Data: `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` reaches every packaged deployment - `docker-compose.yml`, both `data/Dockerfile` stages and the NixOS module (`meshcoreTelemetryPoll24hExempt`); setting it in `.env` had no effect under Compose or NixOS (SPEC PX5) +* Web: live refreshes keep map overlays, line tooltips and waypoint cards open, and repaint only the table, map or chat whose data changed (#881, SPEC DR2/DR4) * Docs: the README documents `MESHTASTIC_PSK_B64` (SPEC PX7) * CI: the env-surface check runs in pytest whenever a deployment surface changes, and also checks names with digits, names read through helpers, and retired names (SPEC PX6/PX8) * CI: test results reach Codecov also when tests fail; workflows use codecov-action v7 and checkout v7; Dependabot opens up to 20 GitHub Actions update PRs at a time (SPEC DW1/DW2/DW4) diff --git a/web/public/assets/js/app/__tests__/live-map-harness.js b/web/public/assets/js/app/__tests__/live-map-harness.js new file mode 100644 index 00000000..6905c0dd --- /dev/null +++ b/web/public/assets/js/app/__tests__/live-map-harness.js @@ -0,0 +1,348 @@ +/* + * 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. + */ + +/** + * Harness for the #881 map and refresh tests (SPEC DR2/DR4). + * + * Boots `initializeApp` over a fake `EventSource` and a stub `fetch` with the + * three surfaces a refresh can repaint: a `#nodes tbody` that counts its + * rebuilds, a `#chat` container, and a Leaflet stub whose markers, pins and + * lines own element stand-ins that leave the document when their layer group + * is cleared — the browser behaviour that closes an overlay anchored to them. + * The overlay template yields tracked overlay elements, so a test sees which + * short-info overlays are open and can press their close buttons. + * + * Kept beside the two suites that use it instead of widening the shared + * `main-app-leaflet-stub.js` / `sse-app-harness.js` helpers, which it wraps. + * + * @module __tests__/live-map-harness + */ + +import { createDomEnvironment } from './dom-environment.js'; +import { makeLeafletStub } from './main-app-leaflet-stub.js'; +import { SSE_BASE_CONFIG, makeFakeEventSource } from './sse-app-harness.js'; +import { initializeApp } from '../main.js'; + +/** Unix seconds at module load; fixtures are stamped relative to it. */ +export const NOW = Math.floor(Date.now() / 1000); + +/** + * Resolve after ``ms`` milliseconds, letting overlay positioning timers and + * coalesced repaints run. + * + * @param {number} [ms=40] Delay. + * @returns {Promise} + */ +export function settle(ms = 40) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Give a Leaflet stub layer an element stand-in that counts as in the + * document while the layer sits in a layer group (cleared groups detach it). + * + * @param {Object} layer Stub marker or polyline. + * @returns {Object} The same layer. + */ +function attachElement(layer) { + const element = { + layer, + getBoundingClientRect: () => ({ left: 10, top: 10, width: 8, height: 8 }), + __connected: () => Boolean(layer._addedTo && layer._addedTo._layers.includes(layer)), + }; + layer.getElement = () => element; + // Leaflet 1.9's tooltip surface on a layer with no tooltip bound: + // getTooltip() is undefined, openTooltip() is a no-op, and isTooltipOpen() + // dereferences the missing tooltip and throws. + layer.getTooltip = () => undefined; + layer.openTooltip = () => layer; + layer.isTooltipOpen = () => { + throw new TypeError("Cannot read properties of undefined (reading 'isOpen')"); + }; + return layer; +} + +/** + * Give a stub polyline Leaflet's event and tooltip surface. + * + * @param {Object} line Stub polyline. + * @returns {Object} The same line. + */ +function addLineSurface(line) { + const handlers = new Map(); + let tooltip = null; + line._eventHandlers = handlers; + line.on = (event, handler) => { + if (!handlers.has(event)) handlers.set(event, []); + handlers.get(event).push(handler); + return line; + }; + line.bindTooltip = (html, options) => { + tooltip = { html, options, open: false, latLng: null }; + tooltip.getLatLng = () => tooltip.latLng; + return line; + }; + line.getTooltip = () => tooltip || undefined; + // As in Leaflet, asking a line with no bound tooltip throws. + line.isTooltipOpen = () => tooltip.open; + line.openTooltip = latLng => { + if (!tooltip) return line; + tooltip.open = true; + tooltip.latLng = latLng || { lat: 0, lng: 0, centre: true }; + return line; + }; + line.closeTooltip = () => { + tooltip.open = false; + return line; + }; + return line; +} + +/** + * Build a Leaflet stub whose layers carry elements and line tooltips. + * + * @returns {Object} Leaflet stub (``_recorded`` lists every created layer). + */ +function makeLiveLeaflet() { + const leaflet = makeLeafletStub(); + const basePolyline = leaflet.polyline; + const baseCircle = leaflet.circleMarker; + const baseMarker = leaflet.marker; + leaflet.polyline = (latLngs, options) => addLineSurface(attachElement(basePolyline(latLngs, options))); + leaflet.circleMarker = (latLng, options) => attachElement(baseCircle(latLng, options)); + leaflet.marker = (latLng, options) => attachElement(baseMarker(latLng, options)); + return leaflet; +} + +/** + * Replace the overlay template with a factory of tracked overlay elements. + * + * @param {Object} env DOM environment from {@link createDomEnvironment}. + * @returns {Array} Every overlay element created, in order. + */ +function installOverlayTemplate(env) { + const body = env.document.body; + const created = []; + /** + * One overlay element with a close button and a content node. + * + * @returns {Object} Overlay element stand-in. + */ + function makeOverlay() { + const closeButton = { + listeners: [], + addEventListener(type, handler) { + if (type === 'click') closeButton.listeners.push(handler); + }, + }; + const content = { innerHTML: '' }; + const overlay = { + style: {}, + removed: false, + parentNode: null, + attributes: new Map(), + closeButton, + content, + querySelector: selector => { + if (selector === '.short-info-close') return closeButton; + if (selector === '.short-info-content') return content; + return null; + }, + setAttribute(name, value) { + overlay.attributes.set(name, String(value)); + }, + removeAttribute(name) { + overlay.attributes.delete(name); + }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 120, height: 60 }), + contains: node => node === overlay || node === closeButton || node === content, + remove() { + overlay.removed = true; + overlay.parentNode = null; + const index = body.childNodes.indexOf(overlay); + if (index >= 0) body.childNodes.splice(index, 1); + }, + }; + created.push(overlay); + return overlay; + } + env.registerElement('shortInfoOverlayTemplate', { + content: { firstElementChild: { cloneNode: () => makeOverlay() } }, + }); + // Appending sets parentNode, as the DOM does, so the stack attaches once. + const baseAppend = body.appendChild.bind(body); + body.appendChild = node => { + if (node && typeof node === 'object') node.parentNode = body; + return baseAppend(node); + }; + // A layer's element is in the document while its layer is on the map. + body.contains = node => { + if (!node) return false; + if (typeof node.__connected === 'function') return node.__connected(); + return body.childNodes.includes(node); + }; + return created; +} + +/** + * A stub ``fetch`` that answers from a URL-substring map and, like the + * network, hands out a fresh parse of the body on every request: the shared + * stub returns the body object itself, so a test editing a response would + * edit the dashboard's merged state behind its back. + * + * @param {Object} responses URL substring to JSON body, read at + * request time. + * @param {?Function} fetchOverride Hook answering first when it returns a + * promise. + * @returns {{ fetch: Function, calls: Array<{ url: string }> }} The stub and + * its request log. + */ +function buildCloningFetch(responses, fetchOverride) { + const calls = []; + /** @param {*} body JSON body. @returns {Promise} A 200 response parsing a fresh copy. */ + const respond = body => Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(JSON.parse(JSON.stringify(body))), + }); + return { + calls, + fetch(url) { + calls.push({ url }); + const answer = typeof fetchOverride === 'function' ? fetchOverride(url) : undefined; + if (answer !== undefined) return answer; + for (const [prefix, body] of Object.entries(responses)) { + if (url.includes(prefix)) return respond(body); + } + return respond([]); + }, + }; +} + +/** + * Invoke a stub Leaflet layer's ``click`` handlers with a Leaflet-shaped event. + * + * @param {Object} layer Marker or polyline from the stub. + * @returns {number} How many handlers ran. + */ +export function clickLayer(layer) { + const handlers = (layer._eventHandlers && layer._eventHandlers.get('click')) || []; + const event = { originalEvent: { preventDefault() {}, stopPropagation() {}, target: null } }; + for (const handler of handlers) handler(event); + return handlers.length; +} + +/** + * Boot the dashboard with a map, a nodes table and a chat, run ``fn``, then + * tear everything down. + * + * @param {{ + * responses: Object, + * fetchOverride?: (url: string) => (Promise|undefined), + * configOverrides?: Object, + * beforeBoot?: (env: Object) => void, + * }} options Stub-fetch payloads (URL substring to JSON body, read at request + * time so a test can edit them between refreshes), an optional fetch hook + * that answers first, config overrides, and a hook that registers extra + * elements before the app boots. + * @param {(ctx: Object) => Promise} fn Test body; receives ``testUtils``, + * ``leaflet``, ``tbody``, ``chat``, ``calls``, ``stream``, ``overlays()`` + * (open overlay elements), ``tableRepaints()`` and ``ping(...collections)``. + * @returns {Promise} + */ +export async function runMapApp({ responses, fetchOverride, configOverrides = {}, beforeBoot }, fn) { + const env = createDomEnvironment({ includeBody: true }); + env.window.matchMedia = () => ({ matches: false, media: '', addEventListener() {}, removeEventListener() {} }); + if (typeof beforeBoot === 'function') beforeBoot(env); + const overlaysCreated = installOverlayTemplate(env); + env.registerElement('map', env.createElement('div', 'map')); + const chat = env.createElement('div', 'chat'); + env.registerElement('chat', chat); + const tbody = env.document.createElement('tbody'); + let tableRepaints = 0; + const baseReplace = tbody.replaceChildren.bind(tbody); + tbody.replaceChildren = (...nodes) => { + tableRepaints += 1; + return baseReplace(...nodes); + }; + const table = env.document.createElement('table'); + table.querySelector = selector => (selector === 'tbody' ? tbody : null); + table.querySelectorAll = () => []; + env.registerElement('nodes', table); + env.document.querySelector = selector => (selector === '#nodes tbody' ? tbody : null); + + const leaflet = makeLiveLeaflet(); + const saved = { + fetch: globalThis.fetch, + EventSource: globalThis.EventSource, + indexedDB: globalThis.indexedDB, + L: globalThis.L, + }; + const stub = buildCloningFetch(responses, fetchOverride); + globalThis.fetch = stub.fetch; + globalThis.indexedDB = undefined; + const FakeEventSource = makeFakeEventSource(); + globalThis.EventSource = FakeEventSource; + env.window.L = leaflet; + globalThis.L = leaflet; + let testUtils = null; + try { + ({ _testUtils: testUtils } = initializeApp({ ...SSE_BASE_CONFIG, ...configOverrides })); + await testUtils.initialLoad; + await testUtils.flushBackfill(); + await settle(); + const stream = FakeEventSource.instances[0]; + await fn({ + env, + testUtils, + leaflet, + tbody, + chat, + stream, + calls: stub.calls, + overlays: () => overlaysCreated.filter(overlay => !overlay.removed), + tableRepaints: () => tableRepaints, + /** + * Deliver SSE change pings and wait for their refresh and timers. + * + * @param {...string} collections Changed collection names. + * @returns {Promise} + */ + async ping(...collections) { + for (const collection of collections) { + stream.dispatch('change', { data: JSON.stringify({ collection }) }); + } + await testUtils.flushLiveRefresh(); + await settle(); + }, + }); + } finally { + if (testUtils) { + // Let fire-and-forget tails (the stats callback, the cache write) run + // against the live DOM before it is torn down. + await testUtils.flushCacheWrites(); + await settle(20); + testUtils.stopAutoRefresh(); + } + globalThis.fetch = saved.fetch; + if (saved.EventSource === undefined) delete globalThis.EventSource; + else globalThis.EventSource = saved.EventSource; + globalThis.indexedDB = saved.indexedDB; + if (saved.L === undefined) delete globalThis.L; + else globalThis.L = saved.L; + env.cleanup(); + } +} diff --git a/web/public/assets/js/app/__tests__/main-map-overlay-anchors.test.js b/web/public/assets/js/app/__tests__/main-map-overlay-anchors.test.js new file mode 100644 index 00000000..e5c69da2 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-map-overlay-anchors.test.js @@ -0,0 +1,280 @@ +/* + * 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. + */ + +/** + * SPEC DR2 (#881): the map may rebuild, but every open overlay or tooltip + * moves to its rebuilt anchor, keyed by node id, segment key or waypoint key. + * + * Each test opens something on the map, lands a live refresh that changes + * node data (so the map really rebuilds: every marker, line and pin is a new + * layer with a new element, and the old elements leave the document), and + * checks that what the reader opened is still open on the new layer. Before + * DR2 only marker overlays survived (LD-A3): a neighbour-line overlay, a line + * tooltip and a waypoint card closed, the close button of a moved marker + * overlay did nothing, and node details that arrived after a rebuild never + * replaced "Loading…". + * + * @module app/__tests__/main-map-overlay-anchors + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NOW, clickLayer, runMapApp, settle } from './live-map-harness.js'; + +/** + * Two positioned nodes, a neighbour link and a trace between them, and one + * waypoint. + * + * @returns {Object} Stub-fetch responses a test may edit. + */ +function mapResponses() { + return { + 'encrypted=true': [], + '/api/nodes': [ + { node_id: '!a', short_name: 'A', long_name: 'Node A', role: 'CLIENT', protocol: 'meshtastic', last_heard: NOW - 30, latitude: 52.5, longitude: 13.4 }, + { node_id: '!b', short_name: 'B', long_name: 'Node B', role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60, latitude: 52.51, longitude: 13.42 }, + ], + '/api/messages': [], + '/api/neighbors': [{ node_id: '!a', neighbor_id: '!b', snr: 6.5, rx_time: NOW - 40 }], + '/api/traces': [{ id: 77, src: '!a', dest: '!b', hops: [], rx_time: NOW - 50 }], + '/api/waypoints': [ + { id: 5, protocol: 'meshtastic', name: 'Probe POI', node_id: '!a', latitude: 52.52, longitude: 13.41, expire: 0, rx_time: NOW - 45 }, + ], + }; +} + +/** + * Change node B so the next nodes ping rebuilds the map. + * + * @param {Object} responses Stub-fetch responses. + * @param {number} battery New battery level. + * @returns {void} + */ +function touchNodeB(responses, battery) { + responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], battery_level: battery }; +} + +/** + * The newest polyline the map drew with ``className``. + * + * @param {Object} leaflet Leaflet stub. + * @param {string} className Exact class list of the line. + * @returns {Object} Stub polyline. + */ +function newestLine(leaflet, className) { + return leaflet._recorded.polylines.filter(line => line.options.className === className).at(-1); +} + +/** + * The newest node marker drawn at ``lat``/``lon``. + * + * @param {Object} leaflet Leaflet stub. + * @param {number} lat Latitude. + * @param {number} lon Longitude. + * @returns {Object} Stub circle marker. + */ +function newestMarkerAt(leaflet, lat, lon) { + return leaflet._recorded.circleMarkers.filter(marker => marker._latLng[0] === lat && marker._latLng[1] === lon).at(-1); +} + +/** + * The newest waypoint pin (pins stack above node markers with offset 500). + * + * @param {Object} leaflet Leaflet stub. + * @returns {Object} Stub marker. + */ +function newestPin(leaflet) { + return leaflet._recorded.markers.filter(marker => marker.options.zIndexOffset === 500).at(-1); +} + +/** + * Press an overlay's close (×) button. + * + * @param {Object} overlay Tracked overlay element. + * @returns {void} + */ +function pressClose(overlay) { + for (const handler of overlay.closeButton.listeners) handler({ preventDefault() {}, stopPropagation() {} }); +} + +const NEIGHBOR_LINE = 'neighbor-connection-line'; +const TRACE_LINE = 'neighbor-connection-line trace-connection-line'; + +test('an open neighbour-line overlay and its tooltip follow the rebuilt line (DR2)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + const line = newestLine(ctx.leaflet, NEIGHBOR_LINE); + clickLayer(line); + await settle(); + const [overlay] = ctx.overlays(); + assert.ok(overlay, 'clicking the line opens its overlay'); + assert.equal(line.isTooltipOpen(), true, 'and its tooltip'); + + touchNodeB(responses, 55); + await ctx.ping('nodes'); + + const rebuiltLine = newestLine(ctx.leaflet, NEIGHBOR_LINE); + assert.notEqual(rebuiltLine, line, 'the map rebuilt the line'); + assert.equal(line.getElement().__connected(), false, 'the old line left the document'); + assert.equal(ctx.overlays().length, 1, 'the overlay stays open'); + assert.equal(ctx.overlays()[0], overlay, 'the same overlay, not a new one'); + assert.equal(rebuiltLine.isTooltipOpen(), true, 'the tooltip reopens on the rebuilt line'); + assert.deepEqual(rebuiltLine.getTooltip().latLng, line.getTooltip().latLng, 'where it stood'); + // The overlay is anchored to the rebuilt line: clicking it toggles it shut. + clickLayer(rebuiltLine); + assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt line toggles its overlay shut'); + }); +}); + +test('an open trace-line tooltip follows its rebuilt hop line (DR2)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + const line = newestLine(ctx.leaflet, TRACE_LINE); + clickLayer(line); + assert.equal(line.isTooltipOpen(), true); + touchNodeB(responses, 56); + await ctx.ping('nodes'); + const rebuiltLine = newestLine(ctx.leaflet, TRACE_LINE); + assert.notEqual(rebuiltLine, line); + assert.equal(rebuiltLine.isTooltipOpen(), true, 'the trace tooltip reopens on the rebuilt hop'); + }); +}); + +test('an open waypoint card follows the rebuilt pin (DR2)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + const pin = newestPin(ctx.leaflet); + clickLayer(pin); + await settle(); + const [card] = ctx.overlays(); + assert.ok(card && card.content.innerHTML.includes('Probe POI'), 'clicking the pin opens its card'); + + touchNodeB(responses, 57); + await ctx.ping('nodes'); + + const rebuiltPin = newestPin(ctx.leaflet); + assert.notEqual(rebuiltPin, pin, 'the map rebuilt the pin'); + assert.equal(ctx.overlays().length, 1, 'the card stays open'); + assert.equal(ctx.overlays()[0], card, 'the same card, not a new one'); + clickLayer(rebuiltPin); + assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt pin toggles its card shut'); + }); +}); + +test('node details that arrive after a rebuild land on the moved marker overlay (DR2, LD-A3)', async () => { + const responses = mapResponses(); + let release; + const held = new Promise(resolve => { + release = resolve; + }); + /** Hold the marker's node lookup until after the rebuild. @param {string} url Request URL. */ + const fetchOverride = url => (String(url).startsWith('/api/nodes/!a') + ? held.then(() => ({ ok: true, status: 200, json: () => Promise.resolve([{ ...responses['/api/nodes'][0] }]) })) + : undefined); + await runMapApp({ responses, fetchOverride }, async ctx => { + const marker = newestMarkerAt(ctx.leaflet, 52.5, 13.4); + clickLayer(marker); + await settle(); + assert.match(ctx.overlays()[0].content.innerHTML, /Loading/); + + touchNodeB(responses, 58); + await ctx.ping('nodes'); + assert.notEqual(newestMarkerAt(ctx.leaflet, 52.5, 13.4), marker, 'the map rebuilt the marker'); + assert.equal(ctx.overlays().length, 1, 'LD-A3 keeps the loading overlay open'); + + release(); + await settle(60); + const open = ctx.overlays(); + assert.equal(open.length, 1); + assert.doesNotMatch(open[0].content.innerHTML, /Loading/, 'the details replaced "Loading…"'); + assert.match(open[0].content.innerHTML, /Node A/); + // ... on the rebuilt marker, in its freshness pane (PD3). + const rebuiltMarker = newestMarkerAt(ctx.leaflet, 52.5, 13.4); + assert.equal(rebuiltMarker.options.pane, 'markers-live'); + clickLayer(rebuiltMarker); + assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt marker toggles its overlay shut'); + }); +}); + +test('the close button closes a marker overlay that a rebuild moved (DR2)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + const marker = newestMarkerAt(ctx.leaflet, 52.5, 13.4); + clickLayer(marker); + await settle(60); + touchNodeB(responses, 59); + await ctx.ping('nodes'); + assert.notEqual(newestMarkerAt(ctx.leaflet, 52.5, 13.4), marker, 'the map rebuilt the marker'); + const [overlay] = ctx.overlays(); + assert.ok(overlay, 'the marker overlay survived the rebuild (LD-A3)'); + pressClose(overlay); + assert.equal(ctx.overlays().length, 0, 'the × closes it where it now stands'); + }); +}); + +test('an overlay on a fanned-out colocated marker follows the rebuilt member (DR2)', async () => { + const responses = mapResponses(); + // B joins A's coordinate: a two-node colocated group behind a hub badge. + responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], latitude: 52.5, longitude: 13.4 }; + await runMapApp({ responses }, async ctx => { + const hub = ctx.leaflet._recorded.markers.filter(marker => marker.options.bubblingMouseEvents === false).at(-1); + clickLayer(hub); + const fanned = ctx.leaflet._recorded.circleMarkers.slice(-2); + assert.equal(fanned.length, 2, 'the expanded hub fans both members out'); + const member = fanned[0]; + clickLayer(member); + await settle(60); + assert.equal(ctx.overlays().length, 1); + + touchNodeB(responses, 60); + await ctx.ping('nodes'); + const rebuiltMembers = ctx.leaflet._recorded.circleMarkers.slice(-2); + assert.notEqual(rebuiltMembers[0], member, 'the members were rebuilt, still fanned out'); + assert.equal(ctx.overlays().length, 1, 'the overlay followed its member'); + const rebuiltSame = rebuiltMembers.find(marker => marker.options.fillColor === member.options.fillColor); + clickLayer(rebuiltSame); + assert.equal(ctx.overlays().length, 0, 'clicking the rebuilt member toggles its overlay shut'); + }); +}); + +test('an overlay on a line the rebuild drops is closed (DR2)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + clickLayer(newestLine(ctx.leaflet, NEIGHBOR_LINE)); + await settle(); + assert.equal(ctx.overlays().length, 1); + // B moves out of reach of any line: the neighbour row stays but its + // target loses its position, so the line is not drawn again. + responses['/api/nodes'][1] = { ...responses['/api/nodes'][1], latitude: null, longitude: null }; + await ctx.ping('nodes'); + assert.equal(ctx.overlays().length, 0, 'nothing to anchor to: cleanupOrphans closes it'); + }); +}); + +test('the live-flash wave starts from the rebuilt marker (DR2, LV5)', async () => { + const responses = mapResponses(); + await runMapApp({ responses }, async ctx => { + const marker = newestMarkerAt(ctx.leaflet, 52.51, 13.42); + touchNodeB(responses, 61); + await ctx.ping('nodes'); + const rebuilt = newestMarkerAt(ctx.leaflet, 52.51, 13.42); + assert.notEqual(rebuilt, marker, 'the map rebuilt the marker'); + const waves = ctx.leaflet._recorded.markers.filter( + layer => layer.options.icon && layer.options.icon.options.className === 'live-flash-wave-icon', + ); + assert.ok(waves.some(wave => wave.getLatLng() === rebuilt.getLatLng()), 'a wave rises from the rebuilt marker'); + }); +}); diff --git a/web/public/assets/js/app/__tests__/main-repaint-scope.test.js b/web/public/assets/js/app/__tests__/main-repaint-scope.test.js new file mode 100644 index 00000000..e29d4a37 --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-repaint-scope.test.js @@ -0,0 +1,427 @@ +/* + * 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. + */ + +/** + * SPEC DR4 (#881): a refresh repaints only the surfaces whose data changed. + * + * Each test boots the dashboard with a nodes table, a map and a chat, lands + * one refresh and counts what was rebuilt: `#nodes tbody` rebuilds, node + * markers and lines the map created, and what the chat shows. Before DR4 every + * refresh rebuilt all three, so a `messages` ping replaced every table row and + * every map layer, and ten destination pages repainted the table ten times. + * + * @module app/__tests__/main-repaint-scope + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { NOW, runMapApp, settle } from './live-map-harness.js'; + +/** + * Two meshtastic nodes with positions, one chat message, no neighbours. + * + * @returns {Object} Stub-fetch responses a test may edit. + */ +function baseResponses() { + return { + 'encrypted=true': [], + '/api/nodes': [ + { node_id: '!a', short_name: 'A', long_name: 'Node A', role: 'CLIENT', protocol: 'meshtastic', last_heard: NOW - 30, latitude: 52.5, longitude: 13.4 }, + { node_id: '!b', short_name: 'B', long_name: 'Node B', role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60, latitude: 52.51, longitude: 13.42 }, + ], + '/api/messages': [ + { id: 1, channel: 0, from_id: '!a', to_id: '^all', text: 'first message', rx_time: NOW - 20, protocol: 'meshtastic' }, + ], + '/api/neighbors': [], + }; +} + +/** + * Snapshot what has been rebuilt so far: ``#nodes tbody`` rebuilds, node + * markers and lines the map created, and the chat repaints the planner + * handed out (read last, so a missing planner fails on the DOM counts first). + * + * @param {Object} ctx Harness context. + * @returns {{ table: number, markers: number, lines: number, chat: (number|undefined) }} + */ +function rebuilt(ctx) { + return { + table: ctx.tableRepaints(), + markers: ctx.leaflet._recorded.circleMarkers.length, + lines: ctx.leaflet._recorded.polylines.length, + chat: ctx.testUtils.getSurfaceRenderCounts?.().chat, + }; +} + +/** + * Assert what a refresh rebuilt since ``start``: the DOM counts first, then + * the chat repaints. + * + * @param {Object} ctx Harness context. + * @param {Object} start Snapshot from {@link rebuilt}. + * @param {{ table: number, markers: number, lines: number, chat: number }} expected Deltas. + * @param {string} [label] Assertion context. + * @returns {void} + */ +function assertRebuilt(ctx, start, expected, label = '') { + const now = rebuilt(ctx); + const { chat, ...dom } = expected; + assert.deepEqual( + { table: now.table - start.table, markers: now.markers - start.markers, lines: now.lines - start.lines }, + dom, + `${label} table rebuilds, map markers and lines created`, + ); + assert.equal(now.chat - start.chat, chat, `${label} chat repaints`); +} + +test('a messages ping that changes no node, position or neighbour data repaints the chat alone (DR4)', async () => { + const responses = baseResponses(); + await runMapApp({ responses }, async ctx => { + const start = rebuilt(ctx); + responses['/api/messages'].push( + { id: 2, channel: 0, from_id: '!a', to_id: '^all', text: 'second message', rx_time: NOW, protocol: 'meshtastic' }, + ); + // A message ingest publishes messages and nodes; the node rows come back unchanged. + await ctx.ping('messages', 'nodes'); + + assert.ok(ctx.chat.innerHTML.includes('second message'), 'the chat shows the new message'); + assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 1 }, 'a messages-only refresh:'); + // The live feedback still lands on the elements already on screen (VF4). + assert.deepEqual([...ctx.testUtils.getLastFlashedMessageIds()].sort(), ['1', '2']); + assert.ok(ctx.testUtils.getLastFlashedNodeIds().includes('!a')); + }); +}); + +test('a refresh that changes nothing repaints nothing: ping, safety poll, resync (DR4)', async () => { + await runMapApp({ responses: baseResponses() }, async ctx => { + const start = rebuilt(ctx); + await ctx.ping('nodes'); + await ctx.ping('messages'); + await ctx.testUtils.refresh(); + ctx.stream.dispatch('open', {}); + await ctx.testUtils.flushLiveRefresh(); + await settle(); + assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 0 }, 'four idle refreshes:'); + }); +}); + +test('a node change repaints the table, the map and the chat (DR4)', async () => { + const responses = baseResponses(); + await runMapApp({ responses }, async ctx => { + const start = rebuilt(ctx); + responses['/api/nodes'][0] = { ...responses['/api/nodes'][0], last_heard: NOW, battery_level: 77 }; + await ctx.ping('nodes'); + assertRebuilt(ctx, start, { table: 1, markers: 2, lines: 0, chat: 1 }); + }); +}); + +test('a neighbour change repaints the map and the chat, not the table (DR4)', async () => { + const responses = baseResponses(); + await runMapApp({ responses }, async ctx => { + const start = rebuilt(ctx); + responses['/api/neighbors'].push({ node_id: '!a', neighbor_id: '!b', snr: 4.5, rx_time: NOW }); + await ctx.ping('neighbors'); + assertRebuilt(ctx, start, { table: 0, markers: 2, lines: 1, chat: 1 }); + }); +}); + +test('a filter edit repaints every surface without any new data (DR4)', async () => { + let filterInput = null; + /** Register the filter input before boot. @param {Object} env DOM environment. */ + const beforeBoot = env => { + filterInput = env.createElement('input', 'filterInput'); + filterInput.value = ''; + env.registerElement('filterInput', filterInput); + }; + await runMapApp({ responses: baseResponses(), beforeBoot }, async ctx => { + const start = rebuilt(ctx); + filterInput.value = 'node'; + for (const handler of filterInput._listeners.get('input')) handler({ type: 'input' }); + assertRebuilt(ctx, start, { table: 1, markers: 2, lines: 0, chat: 1 }); + }); +}); + +test('the map repaints once the clock moves a marker into its next freshness bucket (DR4)', async () => { + const realNow = Date.now; + await runMapApp({ responses: baseResponses() }, async ctx => { + const start = rebuilt(ctx); + await ctx.testUtils.refresh(); + assertRebuilt(ctx, start, { table: 0, markers: 0, lines: 0, chat: 0 }, 'no bucket moved yet:'); + try { + // Node A was heard 30 s before NOW; three hours on it leaves the live bucket. + Date.now = () => (NOW + 3 * 3600) * 1000; + await ctx.testUtils.refresh(); + } finally { + Date.now = realNow; + } + assertRebuilt(ctx, start, { table: 0, markers: 2, lines: 0, chat: 0 }, 'three hours on:'); + }); +}); + +test('destination pages coalesce into one table repaint, then refresh the counts (DR4)', async () => { + const identity = '!27716218'; + const responses = baseResponses(); + responses['/api/nodes'].push({ + node_id: identity, short_name: '2771', long_name: 'Department of Decentralization', role: 'NODE', + protocol: 'reticulum', last_heard: NOW - 90, + }); + /** One destination row of the identity. @param {number} i Row index. @returns {Object} Row. */ + const destination = i => ({ + id: `d${i}`, node_id: identity, aspect: 'lxmf.delivery', role: 'PEER', name: null, last_heard: NOW - 100 - i, + }); + // Two full pages and a short one: the walk takes all three, then stops. + const pages = [ + Array.from({ length: 250 }, (_, i) => destination(i)), + Array.from({ length: 250 }, (_, i) => destination(250 + i)), + Array.from({ length: 10 }, (_, i) => destination(500 + i)), + ]; + let release; + const gate = new Promise(resolve => { + release = resolve; + }); + let served = 0; + /** Serve the destination pages, held until released. @param {string} url Request URL. */ + const fetchOverride = url => { + if (!String(url).startsWith('/api/destinations')) return undefined; + const page = pages[served] || []; + served += 1; + return gate.then(() => ({ ok: true, status: 200, json: () => Promise.resolve(page) })); + }; + let toggleCount = null; + /** Register the Reticulum toggle count before boot. @param {Object} env DOM environment. */ + const beforeBoot = env => { + toggleCount = env.createElement('span', 'protocolToggleReticulumCount'); + env.registerElement('protocolToggleReticulumCount', toggleCount); + }; + await runMapApp({ responses, fetchOverride, beforeBoot }, async ctx => { + const start = rebuilt(ctx); + release(); + await settle(120); + assert.equal(served, 3, 'the walk fetched all three pages'); + assertRebuilt(ctx, start, { table: 1, markers: 0, lines: 0, chat: 0 }, 'three destination pages:'); + // The coalesced flush still refreshes the stats-driven counts, from the + // complete index (SPEC RA3). + assert.match(toggleCount.textContent, /\(510\)$/); + }); +}); + +test('each MS-A4 refresh path still repaints the chat when its data changed, and keeps the select (DR4, MS-A4)', async () => { + const responses = baseResponses(); + /** + * The first ``