mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 03:03:38 +00:00
GetPacketPath used to resolve only the single farthest-traveled observation and discard the rest. It now returns one branch per distinct station (kept at that station's own deepest observation), each with its own hop count and, where resolvable, relay chain -- so the map visualizes the packet's full flood spread instead of one route. Stations whose path never resolved, or who heard the packet directly (0 hops), still contribute a branch via their own position, rather than being silently dropped. The "View path" link in the ping-bot reply no longer requires hops > 0 to show, since even an all-direct ping still has observer spread worth plotting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
11 KiB
JavaScript
237 lines
11 KiB
JavaScript
/**
|
|
* Tests for public/packet-path-map.js — the on-demand "View path" modal
|
|
* that draws a packet's resolved relay path on a Leaflet map (backed by
|
|
* GET /api/packets/{hash}/path, cmd/server/db.go GetPacketPath).
|
|
*
|
|
* Two layers, matching this repo's established pattern for modal/DOM
|
|
* code (see test-channel-modal-ux.js): string-contract checks over the
|
|
* raw source for structural/safety properties, plus a functional smoke
|
|
* test using a minimal-but-real DOM mock (createElement/appendChild/
|
|
* getElementById/remove all actually work, unlike the channels.js test
|
|
* sandbox's inert stubs) to exercise open()/close() end-to-end on the
|
|
* two code paths that don't need Leaflet: a failed fetch, and a
|
|
* fetch that resolves with nothing plottable.
|
|
*/
|
|
'use strict';
|
|
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
const src = fs.readFileSync('public/packet-path-map.js', 'utf8');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(' ✅ ' + name); }
|
|
catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
console.log('\n=== packet-path-map.js: string-contract checks ===');
|
|
|
|
test('exports window.PacketPathMap.{open,close}', () => {
|
|
assert.ok(/window\.PacketPathMap\s*=\s*\{\s*open:\s*open,\s*close:\s*close\s*\}/.test(src));
|
|
});
|
|
|
|
test('fetches via the shared api() helper, not a raw fetch (picks up auth/base-URL handling)', () => {
|
|
assert.ok(/api\(\s*'\/packets\/'\s*\+\s*encodeURIComponent\(hash\)\s*\+\s*'\/path'\s*\)/.test(src));
|
|
});
|
|
|
|
test('escapes node/observer names before interpolating into tooltip HTML (operator-controlled data)', () => {
|
|
assert.ok(/escapeHtml\(pt\.name\)/.test(src), 'point tooltips must escape the name');
|
|
});
|
|
|
|
test('draws every branch, not just the deepest one', () => {
|
|
assert.ok(/branches\.map/.test(src), 'should iterate all branches from the response');
|
|
});
|
|
|
|
test('draws the deepest branch on top of the others (primary drawn last)', () => {
|
|
assert.ok(/a\.primary \? 1 : 0/.test(src) || /primary.*sort/.test(src), 'should reorder so the primary branch paints last');
|
|
});
|
|
|
|
test('handles Escape key and click-outside to close, matching other CoreScope modals', () => {
|
|
assert.ok(/e\.key === 'Escape'/.test(src));
|
|
assert.ok(/e\.target === overlay/.test(src));
|
|
});
|
|
|
|
test('degrades gracefully when the Leaflet global is unavailable, rather than throwing', () => {
|
|
assert.ok(/typeof L === 'undefined'/.test(src));
|
|
});
|
|
|
|
test('close() tears down the Leaflet map instance, not just the DOM overlay (avoids a leaked map on repeat opens)', () => {
|
|
assert.ok(/activeMap\.remove\(\)/.test(src));
|
|
});
|
|
|
|
console.log('\n=== packet-path-map.js: functional smoke test (no-Leaflet code paths) ===');
|
|
|
|
function makeSandbox(apiImpl) {
|
|
// A minimal but REAL DOM: elements track their own children/attributes
|
|
// so createElement -> appendChild -> getElementById -> remove() all
|
|
// actually work, unlike the inert stubs used for pure string-render
|
|
// testing elsewhere. Deliberately small: only what open()/close() touch.
|
|
function makeElement(tag) {
|
|
const el = {
|
|
tagName: tag, children: [], attributes: {}, style: {}, dataset: {},
|
|
_listeners: {},
|
|
get id() { return this.attributes.id || ''; },
|
|
set id(v) { this.attributes.id = v; },
|
|
// Real innerHTML would parse into a live child tree; this mock only
|
|
// needs id-addressable children with a settable textContent (all
|
|
// open()/close() read back), so it scans for id="..." occurrences
|
|
// and registers one lightweight child per id found.
|
|
set innerHTML(html) {
|
|
this._innerHTML = html;
|
|
this.children = [];
|
|
const re = /id="([^"]+)"/g;
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
const child = makeElement('div');
|
|
child.id = m[1];
|
|
this.appendChild(child);
|
|
}
|
|
},
|
|
get innerHTML() { return this._innerHTML || ''; },
|
|
set textContent(t) { this._text = t; },
|
|
get textContent() { return this._text || ''; },
|
|
appendChild(child) { this.children.push(child); child._parent = this; return child; },
|
|
remove() { if (this._parent) this._parent.children = this._parent.children.filter(c => c !== this); },
|
|
addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); },
|
|
removeEventListener(type, fn) { if (this._listeners[type]) this._listeners[type] = this._listeners[type].filter(f => f !== fn); },
|
|
querySelector() { return null; },
|
|
};
|
|
return el;
|
|
}
|
|
|
|
const body = makeElement('body');
|
|
const docListeners = {};
|
|
const doc = {
|
|
createElement: makeElement,
|
|
body,
|
|
documentElement: { style: {} },
|
|
getElementById(id) {
|
|
const search = (el) => {
|
|
if (el.id === id) return el;
|
|
for (const c of el.children) { const found = search(c); if (found) return found; }
|
|
return null;
|
|
};
|
|
return search(body);
|
|
},
|
|
addEventListener(type, fn) { (docListeners[type] = docListeners[type] || []).push(fn); },
|
|
removeEventListener(type, fn) { if (docListeners[type]) docListeners[type] = docListeners[type].filter(f => f !== fn); },
|
|
};
|
|
|
|
const ctx = {
|
|
window: {}, document: doc, console, Math, String, JSON, Promise, Error,
|
|
setTimeout, clearTimeout,
|
|
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
|
escapeHtml: (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
|
api: apiImpl,
|
|
L: undefined, // Leaflet deliberately absent -- these tests only cover the no-plot-data / no-Leaflet paths.
|
|
};
|
|
vm.createContext(ctx);
|
|
vm.runInContext(src, ctx);
|
|
return ctx;
|
|
}
|
|
|
|
(async () => {
|
|
await (async () => {
|
|
try {
|
|
const ctx = makeSandbox(() => Promise.reject(new Error('network down')));
|
|
await ctx.window.PacketPathMap.open('deadbeef');
|
|
const overlay = ctx.document.getElementById('packetPathModal');
|
|
const status = ctx.document.getElementById('packetPathStatus');
|
|
assert.ok(overlay, 'modal overlay should be created');
|
|
assert.ok(status.textContent.includes('Failed to load path'), 'should surface the fetch error, got: ' + status.textContent);
|
|
passed++;
|
|
console.log(' ✅ a failed fetch shows an error status without throwing');
|
|
} catch (e) { failed++; console.log(' ❌ a failed fetch shows an error status without throwing: ' + e.message); }
|
|
})();
|
|
|
|
await (async () => {
|
|
try {
|
|
const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', branches: [] }));
|
|
await ctx.window.PacketPathMap.open('deadbeef');
|
|
const status = ctx.document.getElementById('packetPathStatus');
|
|
assert.ok(status.textContent.includes('no observations'), 'should explain there is nothing to show yet, got: ' + status.textContent);
|
|
passed++;
|
|
console.log(' ✅ no branches at all shows a clear "nothing to show" status');
|
|
} catch (e) { failed++; console.log(' ❌ no branches at all shows a clear "nothing to show" status: ' + e.message); }
|
|
})();
|
|
|
|
await (async () => {
|
|
try {
|
|
const ctx = makeSandbox(() => Promise.resolve({
|
|
hash: 'deadbeef',
|
|
branches: [{ hops: 3, points: [{ publicKey: 'pk1', name: 'RepeaterA', lat: null, lon: null }], observer: null }],
|
|
}));
|
|
await ctx.window.PacketPathMap.open('deadbeef');
|
|
const status = ctx.document.getElementById('packetPathStatus');
|
|
assert.ok(status.textContent.includes('3 hop'), 'should mention the hop count even when no branch has a known position, got: ' + status.textContent);
|
|
passed++;
|
|
console.log(' ✅ hops with no known position at all still report the hop count, not a silent blank');
|
|
} catch (e) { failed++; console.log(' ❌ hops with no known position at all still report the hop count, not a silent blank: ' + e.message); }
|
|
})();
|
|
|
|
await (async () => {
|
|
try {
|
|
const ctx = makeSandbox(() => Promise.reject(new Error('boom')));
|
|
await ctx.window.PacketPathMap.open('deadbeef');
|
|
assert.ok(ctx.document.getElementById('packetPathModal'), 'modal should be open');
|
|
ctx.window.PacketPathMap.close();
|
|
assert.ok(!ctx.document.getElementById('packetPathModal'), 'modal should be removed after close()');
|
|
passed++;
|
|
console.log(' ✅ close() removes the modal overlay from the DOM');
|
|
} catch (e) { failed++; console.log(' ❌ close() removes the modal overlay from the DOM: ' + e.message); }
|
|
})();
|
|
|
|
await (async () => {
|
|
try {
|
|
// Two branches: a 2-hop chain (deepest, drawn primary) and a
|
|
// 0-hop direct observer with no resolvable relay names at all.
|
|
// Both should still get plotted -- this is the whole point of the
|
|
// "show every branch" rework (a station that heard the packet
|
|
// directly is real reach data even without a relay chain).
|
|
const ctx = makeSandbox(() => Promise.resolve({
|
|
hash: 'deadbeef',
|
|
branches: [
|
|
{
|
|
hops: 2,
|
|
points: [
|
|
{ publicKey: 'pk1', name: 'RepeaterA', lat: 56.0, lon: 10.0 },
|
|
{ publicKey: 'pk2', name: 'RepeaterB', lat: 56.1, lon: 10.1 },
|
|
],
|
|
observer: { name: 'FarObserver', lat: 56.2, lon: 10.2 },
|
|
},
|
|
{ hops: 0, points: [], observer: { name: 'NearObserver', lat: 55.9, lon: 9.9 } },
|
|
],
|
|
}));
|
|
|
|
let markerCount = 0, polylineCount = 0;
|
|
ctx.L = {
|
|
map: () => ({
|
|
setView() { return this; },
|
|
fitBounds() {},
|
|
invalidateSize() {},
|
|
remove() {},
|
|
}),
|
|
tileLayer: () => ({ addTo() { return this; } }),
|
|
circleMarker: () => { markerCount++; return { addTo() { return this; }, bindTooltip() { return this; } }; },
|
|
polyline: () => { polylineCount++; return { addTo() { return this; } }; },
|
|
};
|
|
|
|
await ctx.window.PacketPathMap.open('deadbeef');
|
|
const status = ctx.document.getElementById('packetPathStatus');
|
|
assert.strictEqual(markerCount, 4, 'expected 4 markers: 2 hops + observer for branch 1, 1 observer-only point for branch 2, got ' + markerCount);
|
|
assert.strictEqual(polylineCount, 1, 'expected exactly 1 polyline (only the 3-point branch has >1 point to connect), got ' + polylineCount);
|
|
assert.ok(status.textContent.includes('2 of 2 stations shown'), 'status should report both branches plotted, got: ' + status.textContent);
|
|
assert.ok(status.textContent.includes('deepest reached 2 hop'), 'status should report the deepest branch hop count, got: ' + status.textContent);
|
|
passed++;
|
|
console.log(' ✅ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest');
|
|
} catch (e) { failed++; console.log(' ❌ multiple branches (including a 0-hop direct observer) are all plotted, not just the deepest: ' + e.message); }
|
|
})();
|
|
|
|
console.log('\n════════════════════════════════════════');
|
|
console.log(` packet-path-map.js: ${passed} passed, ${failed} failed`);
|
|
console.log('════════════════════════════════════════');
|
|
if (failed > 0) process.exit(1);
|
|
})();
|