mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 03:03:38 +00:00
New GET /api/nodes/{pubkey}/hop_analytics?days= returns, for each recent
transmission that passed through this node as a relay, the node's own
0-based index within the packet's resolved path -- the value MeshCore
firmware compares against flood_max/flood_max_advert/flood_max_unscoped
in allowPacketForward (Packet.cpp path_len = header & 63; MyMesh.cpp
getPathHashCount() >= flood_max). Deliberately distinct from the
existing hopDistribution field on /analytics, which measures path
length to whichever observer reported the packet -- a different,
unrelated number, confirmed via the upstream issue's multi-round
firmware-source discussion.
Candidate-finding reuses the same byPathHop-index + resolved_path
confirmation approach as handleNodePaths (routes.go), written fresh
rather than refactored out of that function to avoid any regression
risk in its older, correctness-critical prefix-collision handling
(#929, #1197, #1278, #1352). Only transmissions with a canonical
resolved_path contribute a data point -- no resolved_path means no
reliable hop index, so it's skipped rather than guessed.
Frontend: new "Relay Hop-Count" card on the node analytics page --
histogram (Chart.js) + a hand-drawn boxplot canvas above it sharing the
same x-axis (no Chart.js boxplot plugin is loaded), filterable by chip
matching the firmware knob names (flood default, flood_advert,
flood_unscoped, direct).
226 lines
9.2 KiB
JavaScript
226 lines
9.2 KiB
JavaScript
/**
|
|
* Tests for the "Relay Hop-Count" chart on the node analytics page
|
|
* (public/node-analytics.js), added for upstream issue #1812.
|
|
*
|
|
* hopQuantile is pure math and gets exact-value tests. The render path
|
|
* (chips, boxplot canvas, Chart.js histogram) is exercised against a
|
|
* minimal fake DOM sufficient to prove it doesn't throw and that filtering
|
|
* by transport chip actually narrows the packet set — not a pixel-level
|
|
* check of the hand-drawn boxplot, which isn't meaningfully assertable
|
|
* headless.
|
|
*/
|
|
'use strict';
|
|
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
let passed = 0, failed = 0;
|
|
async function testAsync(name, fn) {
|
|
try {
|
|
await fn();
|
|
passed++;
|
|
console.log(` ✅ ${name}`);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log(` ❌ ${name}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// A fake element capable enough for renderHopAnalyticsChips (innerHTML with
|
|
// data-hop-transport buttons, re-parsed by querySelectorAll) and for plain
|
|
// text/style containers (summary, empty banner) and canvases.
|
|
function makeFakeElement(id) {
|
|
const el = {
|
|
id,
|
|
_innerHTML: '',
|
|
textContent: '',
|
|
style: {},
|
|
dataset: {},
|
|
parentElement: { clientWidth: 400 },
|
|
_listeners: [],
|
|
addEventListener(type, fn) { this._listeners.push([type, fn]); },
|
|
get innerHTML() { return this._innerHTML; },
|
|
set innerHTML(html) {
|
|
this._innerHTML = html;
|
|
// Re-derive the button list from the freshly-set markup, matching
|
|
// real DOM behavior closely enough for renderHopAnalyticsChips'
|
|
// querySelectorAll('[data-hop-transport]') call right after.
|
|
this._buttons = [];
|
|
const re = /data-hop-transport="([^"]+)"/g;
|
|
let m;
|
|
while ((m = re.exec(html))) {
|
|
this._buttons.push(makeFakeButton(m[1]));
|
|
}
|
|
},
|
|
querySelectorAll(sel) {
|
|
if (sel === '[data-hop-transport]') return this._buttons || [];
|
|
return [];
|
|
},
|
|
getContext() {
|
|
// No-op 2D context — enough for drawHopBoxplot to run without throwing.
|
|
const noop = () => {};
|
|
return {
|
|
clearRect: noop, beginPath: noop, moveTo: noop, lineTo: noop, stroke: noop,
|
|
fillRect: noop, strokeRect: noop, setTransform: noop,
|
|
set fillStyle(v) {}, set strokeStyle(v) {}, set lineWidth(v) {},
|
|
};
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
function makeFakeButton(transportKey) {
|
|
const btn = { dataset: { hopTransport: transportKey }, _listeners: [] };
|
|
btn.addEventListener = (type, fn) => btn._listeners.push([type, fn]);
|
|
btn.click = () => btn._listeners.forEach(([t, fn]) => t === 'click' && fn());
|
|
return btn;
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const elements = {};
|
|
['hopAnalyticsChips', 'hopAnalyticsSummary', 'hopAnalyticsBoxplot', 'hopAnalyticsHistogram', 'hopAnalyticsEmpty']
|
|
.forEach(id => { elements[id] = makeFakeElement(id); });
|
|
|
|
const ctx = {
|
|
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
|
|
document: {
|
|
readyState: 'complete',
|
|
createElement: () => makeFakeElement(''),
|
|
head: { appendChild: () => {} },
|
|
getElementById: (id) => elements[id] || null,
|
|
documentElement: { getAttribute: () => null, setAttribute: () => {} },
|
|
addEventListener: () => {},
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
},
|
|
console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
|
|
Error, TypeError, parseInt, parseFloat, isNaN, isFinite,
|
|
encodeURIComponent, decodeURIComponent,
|
|
setTimeout: () => {}, clearTimeout: () => {},
|
|
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
|
|
performance: { now: () => Date.now() },
|
|
devicePixelRatio: 1,
|
|
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
|
localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(),
|
|
location: { hash: '' },
|
|
CustomEvent: class CustomEvent {},
|
|
Map, Promise, URLSearchParams,
|
|
addEventListener: () => {},
|
|
dispatchEvent: () => {},
|
|
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
|
};
|
|
vm.createContext(ctx);
|
|
ctx.__elements = elements;
|
|
return ctx;
|
|
}
|
|
|
|
function loadInCtx(ctx, file) {
|
|
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
}
|
|
|
|
function makeNodeAnalyticsSandbox(apiResponses) {
|
|
const ctx = makeSandbox();
|
|
ctx.registerPage = () => {};
|
|
ctx.escapeHtml = (s) => String(s);
|
|
ctx.timeAgo = () => '—';
|
|
ctx.formatChartAxisLabel = null;
|
|
// Minimal Chart.js stand-in: records construction, no-ops for the rest.
|
|
ctx.Chart = function (canvas, cfg) {
|
|
this.canvas = canvas; this.cfg = cfg;
|
|
this.destroy = () => {};
|
|
};
|
|
ctx.Chart.defaults = { color: '', borderColor: '' };
|
|
ctx.api = (path) => {
|
|
for (const key of Object.keys(apiResponses)) {
|
|
if (path.indexOf(key) === 0) return Promise.resolve(apiResponses[key]);
|
|
}
|
|
return Promise.reject(new Error('unhandled api path: ' + path));
|
|
};
|
|
loadInCtx(ctx, 'public/roles.js');
|
|
try { loadInCtx(ctx, 'public/node-analytics.js'); } catch (e) {
|
|
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
(async () => {
|
|
console.log('\n=== node-analytics.js: hopQuantile ===');
|
|
|
|
await testAsync('hopQuantile returns exact values at the sorted array boundaries', () => {
|
|
const ctx = makeNodeAnalyticsSandbox({});
|
|
const q = ctx.window._nodeAnalyticsHopQuantile;
|
|
const arr = [1, 2, 3, 4, 5];
|
|
assert.strictEqual(q(arr, 0), 1);
|
|
assert.strictEqual(q(arr, 1), 5);
|
|
assert.strictEqual(q(arr, 0.5), 3);
|
|
});
|
|
|
|
await testAsync('hopQuantile interpolates between the two nearest ranks', () => {
|
|
const ctx = makeNodeAnalyticsSandbox({});
|
|
const q = ctx.window._nodeAnalyticsHopQuantile;
|
|
// [1,2,3,4] -- Q1 index = 0.25*3 = 0.75 -> interpolate between arr[0]=1 and arr[1]=2
|
|
assert.strictEqual(q([1, 2, 3, 4], 0.25), 1.75);
|
|
});
|
|
|
|
await testAsync('hopQuantile handles a single-element array', () => {
|
|
const ctx = makeNodeAnalyticsSandbox({});
|
|
const q = ctx.window._nodeAnalyticsHopQuantile;
|
|
assert.strictEqual(q([7], 0.25), 7);
|
|
assert.strictEqual(q([7], 0.75), 7);
|
|
});
|
|
|
|
console.log('\n=== node-analytics.js: Relay Hop-Count chart ===');
|
|
|
|
await testAsync('loadHopAnalyticsChart fetches and stores packets, default filter is "flood"', async () => {
|
|
const packets = [
|
|
{ hash: 'a', tsMs: 1, hops: 1, transport: 'flood', scoped: true },
|
|
{ hash: 'b', tsMs: 2, hops: 2, transport: 'flood', scoped: true },
|
|
{ hash: 'c', tsMs: 3, hops: 0, transport: 'direct', scoped: false },
|
|
];
|
|
const ctx = makeNodeAnalyticsSandbox({ '/nodes/pk1/hop_analytics': { packets } });
|
|
await ctx.window._nodeAnalyticsLoadHopChart('pk1', 7);
|
|
assert.strictEqual(ctx.window._nodeAnalyticsGetHopFilter(), 'flood');
|
|
assert.deepStrictEqual(ctx.window._nodeAnalyticsGetHopPackets(), packets);
|
|
});
|
|
|
|
await testAsync('renderHopAnalyticsChips wires a click that changes the active filter and re-renders', async () => {
|
|
const packets = [
|
|
{ hash: 'a', tsMs: 1, hops: 1, transport: 'flood', scoped: true },
|
|
{ hash: 'b', tsMs: 2, hops: 3, transport: 'direct', scoped: false },
|
|
];
|
|
const ctx = makeNodeAnalyticsSandbox({ '/nodes/pk1/hop_analytics': { packets } });
|
|
await ctx.window._nodeAnalyticsLoadHopChart('pk1', 7);
|
|
|
|
const chipsEl = ctx.__elements.hopAnalyticsChips;
|
|
const directBtn = (chipsEl._buttons || []).find(b => b.dataset.hopTransport === 'direct');
|
|
assert.ok(directBtn, 'a "direct" chip button should have been rendered');
|
|
directBtn.click();
|
|
|
|
assert.strictEqual(ctx.window._nodeAnalyticsGetHopFilter(), 'direct');
|
|
// Summary text should now reflect only the 1 "direct" packet (hops=3).
|
|
assert.ok(ctx.__elements.hopAnalyticsSummary.textContent.indexOf('1 packets') !== -1,
|
|
'summary should reflect the single direct-filtered packet, got: ' + ctx.__elements.hopAnalyticsSummary.textContent);
|
|
});
|
|
|
|
await testAsync('renderHopAnalyticsChart shows the empty state when no packet matches the active filter', async () => {
|
|
const packets = [{ hash: 'a', tsMs: 1, hops: 1, transport: 'flood_unscoped', scoped: false }];
|
|
const ctx = makeNodeAnalyticsSandbox({ '/nodes/pk1/hop_analytics': { packets } });
|
|
await ctx.window._nodeAnalyticsLoadHopChart('pk1', 7); // default filter "flood" matches nothing here
|
|
assert.strictEqual(ctx.__elements.hopAnalyticsEmpty.style.display, 'block');
|
|
});
|
|
|
|
await testAsync('a fetch failure shows the empty banner with an error message instead of throwing', async () => {
|
|
const ctx = makeNodeAnalyticsSandbox({}); // no matching api() route -> rejects
|
|
await ctx.window._nodeAnalyticsLoadHopChart('unknown-pk', 7);
|
|
assert.strictEqual(ctx.__elements.hopAnalyticsEmpty.style.display, 'block');
|
|
assert.ok(ctx.__elements.hopAnalyticsEmpty.textContent.indexOf('unavailable') !== -1);
|
|
});
|
|
|
|
console.log('\n════════════════════════════════════════');
|
|
console.log(` Node Analytics Hop Chart: ${passed} passed, ${failed} failed`);
|
|
console.log('════════════════════════════════════════');
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
})();
|