mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-05 20:21:35 +00:00
Compare commits
5 Commits
fix/backfi
...
feat/prefi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36be02a1b8 | ||
|
|
76c6b155c2 | ||
|
|
d0b597ff49 | ||
|
|
e19b0eba85 | ||
|
|
df75468a8b |
@@ -86,6 +86,7 @@
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -173,6 +174,7 @@
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
case 'prefix-tool': await renderPrefixTool(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -985,6 +987,8 @@
|
||||
<a href="#/analytics?tab=collisions§ion=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
|
||||
<span style="color:var(--border)">|</span>
|
||||
<a href="#/analytics?tab=collisions§ion=collisionRiskSection" style="color:var(--accent)">💥 Collision Risk</a>
|
||||
<span style="color:var(--border)">|</span>
|
||||
<a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">🔎 Check a prefix →</a>
|
||||
</nav>
|
||||
|
||||
<div class="analytics-card" id="inconsistentHashSection">
|
||||
@@ -2302,5 +2306,345 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
_ngState.animId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// --- Prefix Tool: Pure logic (exported for testing via _prefixToolExports) ---
|
||||
const PREFIX_SPACE_SIZES = { 1: 256, 2: 65536, 3: 16777216 };
|
||||
|
||||
/** Build 3-tier prefix indexes from deduplicated nodes. Returns { 1: Map, 2: Map, 3: Map } */
|
||||
function buildPrefixIndex(nodes) {
|
||||
const idx = { 1: new Map(), 2: new Map(), 3: new Map() };
|
||||
nodes.forEach(n => {
|
||||
const pk = n.public_key.toUpperCase();
|
||||
[1, 2, 3].forEach(b => {
|
||||
const p = pk.slice(0, b * 2);
|
||||
if (!idx[b].has(p)) idx[b].set(p, []);
|
||||
idx[b].get(p).push(n);
|
||||
});
|
||||
});
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Compute collision stats per tier */
|
||||
function computePrefixStats(idx) {
|
||||
const stats = {};
|
||||
[1, 2, 3].forEach(b => {
|
||||
stats[b] = {
|
||||
usedPrefixes: idx[b].size,
|
||||
collidingPrefixes: [...idx[b].values()].filter(arr => arr.length > 1).length,
|
||||
};
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Recommend prefix byte size based on network size */
|
||||
function recommendPrefixSize(totalNodes) {
|
||||
if (totalNodes < 20) {
|
||||
return { rec: '1-byte', detail: `With only ${totalNodes} nodes, 1-byte prefixes have low collision risk.` };
|
||||
} else if (totalNodes < 500) {
|
||||
return { rec: '2-byte', detail: `With ${totalNodes} nodes, 2-byte prefixes are recommended to avoid collisions.` };
|
||||
} else {
|
||||
return { rec: '3-byte', detail: `With ${totalNodes} nodes, 3-byte prefixes are recommended for collision-free operation.` };
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate prefix input. Returns { valid, error, input, isFullKey, tiers } */
|
||||
function validatePrefixInput(raw) {
|
||||
const input = raw.trim().toUpperCase();
|
||||
if (!input) return { valid: false, error: null, input, isEmpty: true };
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return { valid: false, error: 'Invalid input — hex characters only (0-9, A-F).', input };
|
||||
}
|
||||
if (input.length % 2 !== 0 || (input.length !== 2 && input.length !== 4 && input.length !== 6 && input.length < 8)) {
|
||||
return { valid: false, error: 'Prefix must be 2, 4, or 6 hex characters. For a full public key, use 64 characters.', input };
|
||||
}
|
||||
const isFullKey = input.length >= 8;
|
||||
const tiers = isFullKey
|
||||
? [{ b: 1, prefix: input.slice(0, 2) }, { b: 2, prefix: input.slice(0, 4) }, { b: 3, prefix: input.slice(0, 6) }]
|
||||
: [{ b: input.length / 2, prefix: input }];
|
||||
return { valid: true, input, isFullKey, tiers };
|
||||
}
|
||||
|
||||
/** Check a prefix against the index. Returns collision info per tier. */
|
||||
function checkPrefix(raw, idx, nodes) {
|
||||
const v = validatePrefixInput(raw);
|
||||
if (!v.valid) return v;
|
||||
const results = v.tiers.map(({ b, prefix }) => {
|
||||
const matches = idx[b].get(prefix) || [];
|
||||
const colliders = v.isFullKey ? matches.filter(n => n.public_key.toUpperCase() !== v.input) : matches;
|
||||
return { b, prefix, colliders, count: colliders.length };
|
||||
});
|
||||
const inNetwork = v.isFullKey ? nodes.some(n => n.public_key.toUpperCase() === v.input) : null;
|
||||
return { valid: true, input: v.input, isFullKey: v.isFullKey, results, inNetwork };
|
||||
}
|
||||
|
||||
/** Generate a collision-free prefix of the given byte size. Returns null if none available. */
|
||||
function generatePrefix(b, idx, randFn) {
|
||||
const hexLen = b * 2;
|
||||
const totalSpace = PREFIX_SPACE_SIZES[b];
|
||||
const available = totalSpace - idx[b].size;
|
||||
if (available === 0) return null;
|
||||
|
||||
const _rand = randFn || Math.random;
|
||||
if (b === 1) {
|
||||
const free = [];
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) free.push(p);
|
||||
}
|
||||
return free[Math.floor(_rand() * free.length)];
|
||||
}
|
||||
// Random sampling with fallback
|
||||
let attempts = 0, prefix;
|
||||
do {
|
||||
prefix = Math.floor(_rand() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
} while (idx[b].has(prefix) && ++attempts < 500);
|
||||
if (idx[b].has(prefix)) {
|
||||
for (let i = 0; i < totalSpace; i++) {
|
||||
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
|
||||
if (!idx[b].has(p)) return p;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// --- Prefix Tool: HTML helpers ---
|
||||
function renderNodeEntry(n, escFn) {
|
||||
const name = escFn(n.name || n.public_key.slice(0, 12));
|
||||
const role = n.role ? `<span class="text-muted" style="font-size:0.82em">${escFn(n.role)}</span>` : '';
|
||||
const when = n.last_seen ? ` <span class="text-muted" style="font-size:0.8em">${new Date(n.last_seen).toLocaleDateString()}</span>` : '';
|
||||
return `<div style="padding:3px 0"><a href="#/nodes/${encodeURIComponent(n.public_key)}" class="analytics-link">${name}</a> ${role}${when}</div>`;
|
||||
}
|
||||
|
||||
function renderSeverityBadge(count) {
|
||||
if (count === 0) return '<span style="color:var(--status-green)">✅ Unique</span>';
|
||||
if (count <= 2) return `<span style="color:var(--status-yellow)">⚠️ ${count} collision${count !== 1 ? 's' : ''}</span>`;
|
||||
return `<span style="color:var(--status-red)">🔴 ${count} collisions</span>`;
|
||||
}
|
||||
|
||||
function renderPrefixStatCard(b, stat, spaceSize) {
|
||||
const hasCollisions = stat.collidingPrefixes > 0;
|
||||
return `<div class="analytics-stat-card" style="flex:1;min-width:150px;border-color:${hasCollisions ? 'var(--status-red)' : 'var(--border)'}">
|
||||
<div class="analytics-stat-label">${b}-byte prefixes</div>
|
||||
<div class="analytics-stat-value" style="font-size:1em">
|
||||
${stat.usedPrefixes.toLocaleString()}
|
||||
<span class="text-muted" style="font-size:0.7em"> / ${spaceSize.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style="font-size:0.82em;margin-top:4px;color:${hasCollisions ? 'var(--status-red)' : 'var(--status-green)'}">
|
||||
${stat.collidingPrefixes === 0
|
||||
? '✅ No collisions'
|
||||
: `⚠️ ${stat.collidingPrefixes} prefix${stat.collidingPrefixes !== 1 ? 'es' : ''} collide`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNetworkOverview(totalNodes, stats, rec, recDetail, regionLabel) {
|
||||
const regionNote = regionLabel
|
||||
? `<p class="text-muted" style="font-size:0.85em;margin:4px 0 0">Showing data for region: <strong>${esc(regionLabel)}</strong>. <a href="#/analytics?tab=prefix-tool" style="color:var(--accent)">Check all nodes →</a></p>`
|
||||
: '';
|
||||
return `<div class="analytics-card" id="ptOverview">
|
||||
<div style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none" id="ptOverviewToggle">
|
||||
<span id="ptOverviewChevron" style="font-size:0.75em;color:var(--text-muted);transition:transform 0.2s">▶</span>
|
||||
<h3 style="margin:0">Network Overview</h3>
|
||||
</div>
|
||||
<div id="ptOverviewBody" style="display:none">
|
||||
${regionNote}
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:110px">
|
||||
<div class="analytics-stat-label">Total nodes</div>
|
||||
<div class="analytics-stat-value">${totalNodes.toLocaleString()}</div>
|
||||
</div>
|
||||
${[1, 2, 3].map(b => renderPrefixStatCard(b, stats[b], PREFIX_SPACE_SIZES[b])).join('')}
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary,var(--bg));border:1px solid var(--border);border-radius:6px;padding:10px 14px">
|
||||
<strong>Recommendation: ${rec} prefixes</strong> — ${recDetail}
|
||||
<span class="text-muted" style="font-size:0.8em;display:block;margin-top:4px">Hash size is configured per-node in firmware. Changing requires reflashing.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefixChecker(initPrefix) {
|
||||
return `<div class="analytics-card" id="ptChecker">
|
||||
<h3 style="margin-top:0">Check a Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Enter a 1-byte (2 hex chars), 2-byte (4 hex chars), or 3-byte (6 hex chars) prefix — or paste a full public key.</p>
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap">
|
||||
<input id="ptPrefixInput" type="text" placeholder="e.g. A3F1" maxlength="64"
|
||||
style="font-family:var(--mono);font-size:1em;padding:6px 10px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;min-width:180px;flex:1"
|
||||
value="${esc(initPrefix)}">
|
||||
<button id="ptCheckBtn" style="padding:6px 16px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Check</button>
|
||||
</div>
|
||||
<div id="ptCheckerResults" style="margin-top:14px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrefixGenerator(initGenerate) {
|
||||
return `<div class="analytics-card" id="ptGenerator">
|
||||
<h3 style="margin-top:0">Generate Available Prefix</h3>
|
||||
<p class="text-muted" style="margin-top:0;font-size:0.9em">Find a prefix with zero current collisions.</p>
|
||||
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="1" ${initGenerate === '1' ? 'checked' : ''}> 1-byte
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="2" ${initGenerate !== '1' && initGenerate !== '3' ? 'checked' : ''}> 2-byte
|
||||
<span class="text-muted" style="font-size:0.8em">(recommended)</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ptGenSize" value="3" ${initGenerate === '3' ? 'checked' : ''}> 3-byte
|
||||
</label>
|
||||
<button id="ptGenBtn" style="padding:6px 16px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:0.95em">Generate</button>
|
||||
</div>
|
||||
<div id="ptGenResult"></div>
|
||||
<div style="margin-top:14px;padding:10px 14px;border:1px solid var(--accent);border-radius:6px;background:var(--bg-secondary,var(--bg));font-size:0.88em">
|
||||
📖 <strong>New to multi-byte prefixes?</strong>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md#39-q-what-is-multi-byte-support--what-do-1-byte-2-byte-3-byte-adverts-and-messages-mean"
|
||||
target="_blank" rel="noopener noreferrer" style="color:var(--accent);margin-left:4px">
|
||||
Read the MeshCore FAQ on multi-byte support →
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCheckerResults(checkResult, escFn) {
|
||||
if (!checkResult.valid) {
|
||||
return checkResult.error
|
||||
? `<p style="color:var(--status-red);margin:0">${checkResult.error}</p>`
|
||||
: '';
|
||||
}
|
||||
let html = '';
|
||||
if (checkResult.isFullKey) {
|
||||
const inp = checkResult.input;
|
||||
html += `<p class="text-muted" style="font-size:0.85em;margin:0 0 10px">Derived prefixes: <code class="mono">${inp.slice(0,2)}</code> / <code class="mono">${inp.slice(0,4)}</code> / <code class="mono">${inp.slice(0,6)}</code>${checkResult.inNetwork === false ? ' — <em>this node is not yet in the network</em>' : ''}</p>`;
|
||||
}
|
||||
checkResult.results.forEach(({ b, prefix, colliders, count }) => {
|
||||
html += `<div style="margin-bottom:10px;padding:10px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<code class="mono" style="font-weight:700">${prefix}</code>
|
||||
<span class="text-muted" style="font-size:0.82em">${b}-byte</span>
|
||||
${renderSeverityBadge(count)}
|
||||
</div>
|
||||
${count === 0
|
||||
? '<div class="text-muted" style="font-size:0.85em">No existing nodes use this prefix.</div>'
|
||||
: `<div style="font-size:0.85em;max-height:140px;overflow-y:auto">${colliders.map(n => renderNodeEntry(n, escFn)).join('')}</div>`}
|
||||
</div>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- Prefix Tool: main render (orchestrates the above) ---
|
||||
async function renderPrefixTool(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading prefix data…</div>';
|
||||
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const regionLabel = rq ? (new URLSearchParams(rq.slice(1)).get('region') || '') : '';
|
||||
|
||||
let nodesResp;
|
||||
try {
|
||||
nodesResp = await api('/nodes?limit=10000&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList });
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="text-muted" role="alert" style="padding:40px">Failed to load: ${esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeMap = new Map();
|
||||
(nodesResp.nodes || nodesResp).forEach(n => {
|
||||
if (n.public_key && n.public_key.length >= 6 && !nodeMap.has(n.public_key)) {
|
||||
nodeMap.set(n.public_key, n);
|
||||
}
|
||||
});
|
||||
const nodes = [...nodeMap.values()];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
el.innerHTML = `<div class="analytics-card"><p class="text-muted">No nodes in the network yet. Any prefix is available!</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
const totalNodes = nodes.length;
|
||||
const { rec, detail: recDetail } = recommendPrefixSize(totalNodes);
|
||||
|
||||
const hashParams = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
const initPrefix = hashParams.get('prefix') || '';
|
||||
const initGenerate = hashParams.get('generate') || '';
|
||||
|
||||
el.innerHTML = renderNetworkOverview(totalNodes, stats, rec, recDetail, regionLabel)
|
||||
+ renderPrefixChecker(initPrefix)
|
||||
+ renderPrefixGenerator(initGenerate);
|
||||
|
||||
// --- Wire up checker ---
|
||||
const doCheck = (raw) => {
|
||||
const resultsEl = document.getElementById('ptCheckerResults');
|
||||
if (!resultsEl) return;
|
||||
const result = checkPrefix(raw, idx, nodes);
|
||||
resultsEl.innerHTML = renderCheckerResults(result, esc);
|
||||
};
|
||||
|
||||
document.getElementById('ptCheckBtn').addEventListener('click', () => doCheck(document.getElementById('ptPrefixInput').value));
|
||||
document.getElementById('ptPrefixInput').addEventListener('keydown', e => { if (e.key === 'Enter') doCheck(e.target.value); });
|
||||
|
||||
// --- Wire up generator ---
|
||||
const doGenerate = () => {
|
||||
const genResultEl = document.getElementById('ptGenResult');
|
||||
if (!genResultEl) return;
|
||||
const sizeInput = el.querySelector('input[name="ptGenSize"]:checked');
|
||||
const b = sizeInput ? parseInt(sizeInput.value) : 2;
|
||||
const prefix = generatePrefix(b, idx);
|
||||
|
||||
if (!prefix) {
|
||||
const next = b < 3 ? (b + 1) + '-byte' : 'a different size';
|
||||
genResultEl.innerHTML = `<p style="color:var(--status-red);margin:0">No collision-free ${b}-byte prefixes available. Try ${next}.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSpace = PREFIX_SPACE_SIZES[b];
|
||||
const available = totalSpace - idx[b].size;
|
||||
genResultEl.innerHTML = `
|
||||
<div style="padding:12px 16px;border:1px solid var(--status-green);border-radius:6px;background:var(--bg-secondary,var(--bg))">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<code class="mono" style="font-size:1.3em;font-weight:700;color:var(--status-green)">${prefix}</code>
|
||||
<span style="color:var(--status-green)">✅ No existing nodes use this prefix</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:0.85em;margin-top:6px">${available.toLocaleString()} of ${totalSpace.toLocaleString()} ${b}-byte prefixes are available.</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button id="ptRegenBtn" style="padding:5px 14px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.9em">Try another</button>
|
||||
<a href="https://agessaman.github.io/meshcore-web-keygen/?prefix=${prefix}" target="_blank" rel="noopener noreferrer"
|
||||
style="padding:5px 14px;background:var(--bg);color:var(--accent);border:1px solid var(--border);border-radius:4px;text-decoration:none;font-size:0.9em">
|
||||
Generate key with this prefix →
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('ptRegenBtn').addEventListener('click', doGenerate);
|
||||
};
|
||||
|
||||
document.getElementById('ptGenBtn').addEventListener('click', doGenerate);
|
||||
|
||||
// Network Overview toggle
|
||||
document.getElementById('ptOverviewToggle').addEventListener('click', () => {
|
||||
const body = document.getElementById('ptOverviewBody');
|
||||
const chevron = document.getElementById('ptOverviewChevron');
|
||||
const open = body.style.display === 'none';
|
||||
body.style.display = open ? '' : 'none';
|
||||
chevron.style.transform = open ? 'rotate(90deg)' : '';
|
||||
});
|
||||
|
||||
// Auto-run from URL params
|
||||
if (initPrefix) {
|
||||
doCheck(initPrefix);
|
||||
setTimeout(() => { document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
|
||||
} else if (initGenerate) {
|
||||
doGenerate();
|
||||
setTimeout(() => { document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
|
||||
}
|
||||
}
|
||||
|
||||
// Export pure functions for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._prefixToolExports = {
|
||||
buildPrefixIndex, computePrefixStats, recommendPrefixSize,
|
||||
validatePrefixInput, checkPrefix, generatePrefix,
|
||||
renderSeverityBadge, PREFIX_SPACE_SIZES
|
||||
};
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
|
||||
268
test-prefix-tool.js
Normal file
268
test-prefix-tool.js
Normal file
@@ -0,0 +1,268 @@
|
||||
/* Unit tests for prefix tool logic (analytics.js _prefixToolExports) */
|
||||
'use strict';
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function test(name, fn) {
|
||||
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
||||
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
||||
}
|
||||
|
||||
// Load analytics.js in a VM sandbox with minimal stubs
|
||||
const code = fs.readFileSync(__dirname + '/public/analytics.js', 'utf8');
|
||||
const sandbox = {
|
||||
window: {},
|
||||
document: { addEventListener() {} },
|
||||
location: { hash: '' },
|
||||
setTimeout: () => {},
|
||||
requestAnimationFrame: () => {},
|
||||
console,
|
||||
Map, Set, Array, Object, Number, Math, Date, JSON,
|
||||
encodeURIComponent,
|
||||
URLSearchParams,
|
||||
parseInt, parseFloat, isNaN, isFinite,
|
||||
RegExp, Error, TypeError, RangeError,
|
||||
Promise: { resolve: () => ({ then: () => ({}) }) },
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
|
||||
try {
|
||||
vm.runInNewContext(code, sandbox, { filename: 'analytics.js', timeout: 5000 });
|
||||
} catch (e) {
|
||||
// IIFE may throw due to missing DOM — that's fine, we just need the exports
|
||||
}
|
||||
|
||||
const ex = sandbox.window._prefixToolExports;
|
||||
if (!ex) {
|
||||
console.log('❌ _prefixToolExports not found on window');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { buildPrefixIndex, computePrefixStats, recommendPrefixSize,
|
||||
validatePrefixInput, checkPrefix, generatePrefix,
|
||||
renderSeverityBadge, PREFIX_SPACE_SIZES } = ex;
|
||||
|
||||
console.log('\n--- buildPrefixIndex ---');
|
||||
|
||||
test('builds 3-tier index from nodes', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'A1B2FFFFFF00' },
|
||||
{ public_key: 'FF00112233AA' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
assert.strictEqual(idx[1].size, 2); // A1, FF
|
||||
assert.strictEqual(idx[2].size, 2); // A1B2, FF00
|
||||
assert.strictEqual(idx[3].size, 3); // A1B2C3, A1B2FF, FF0011
|
||||
assert.strictEqual(idx[1].get('A1').length, 2);
|
||||
assert.strictEqual(idx[2].get('A1B2').length, 2);
|
||||
assert.strictEqual(idx[1].get('FF').length, 1);
|
||||
});
|
||||
|
||||
test('handles empty node list', () => {
|
||||
const idx = buildPrefixIndex([]);
|
||||
assert.strictEqual(idx[1].size, 0);
|
||||
assert.strictEqual(idx[2].size, 0);
|
||||
assert.strictEqual(idx[3].size, 0);
|
||||
});
|
||||
|
||||
console.log('\n--- computePrefixStats ---');
|
||||
|
||||
test('detects collisions', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'A1B2FFFFFF00' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
assert.strictEqual(stats[1].collidingPrefixes, 1); // A1 collides
|
||||
assert.strictEqual(stats[2].collidingPrefixes, 1); // A1B2 collides
|
||||
assert.strictEqual(stats[3].collidingPrefixes, 0); // no 3-byte collision
|
||||
});
|
||||
|
||||
test('no collisions when all unique', () => {
|
||||
const nodes = [
|
||||
{ public_key: 'A1B2C3D4E5F6' },
|
||||
{ public_key: 'B1B2C3D4E5F6' },
|
||||
];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const stats = computePrefixStats(idx);
|
||||
assert.strictEqual(stats[1].collidingPrefixes, 0);
|
||||
});
|
||||
|
||||
console.log('\n--- recommendPrefixSize ---');
|
||||
|
||||
test('recommends 1-byte for small networks (<20)', () => {
|
||||
const r = recommendPrefixSize(5);
|
||||
assert.strictEqual(r.rec, '1-byte');
|
||||
});
|
||||
|
||||
test('recommends 2-byte for medium networks (20-499)', () => {
|
||||
const r = recommendPrefixSize(100);
|
||||
assert.strictEqual(r.rec, '2-byte');
|
||||
});
|
||||
|
||||
test('recommends 3-byte for large networks (>=500)', () => {
|
||||
const r = recommendPrefixSize(500);
|
||||
assert.strictEqual(r.rec, '3-byte');
|
||||
});
|
||||
|
||||
test('recommends 3-byte for very large networks', () => {
|
||||
const r = recommendPrefixSize(5000);
|
||||
assert.strictEqual(r.rec, '3-byte');
|
||||
});
|
||||
|
||||
test('boundary: 19 nodes = 1-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(19).rec, '1-byte');
|
||||
});
|
||||
|
||||
test('boundary: 20 nodes = 2-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(20).rec, '2-byte');
|
||||
});
|
||||
|
||||
test('boundary: 499 nodes = 2-byte', () => {
|
||||
assert.strictEqual(recommendPrefixSize(499).rec, '2-byte');
|
||||
});
|
||||
|
||||
console.log('\n--- validatePrefixInput ---');
|
||||
|
||||
test('empty input', () => {
|
||||
const r = validatePrefixInput('');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert.strictEqual(r.isEmpty, true);
|
||||
});
|
||||
|
||||
test('valid 1-byte prefix', () => {
|
||||
const r = validatePrefixInput('A1');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers.length, 1);
|
||||
assert.strictEqual(r.tiers[0].b, 1);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1');
|
||||
});
|
||||
|
||||
test('valid 2-byte prefix', () => {
|
||||
const r = validatePrefixInput('a1b2');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1B2');
|
||||
assert.strictEqual(r.isFullKey, false);
|
||||
});
|
||||
|
||||
test('valid 3-byte prefix', () => {
|
||||
const r = validatePrefixInput('A1B2C3');
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.tiers[0].b, 3);
|
||||
});
|
||||
|
||||
test('full public key (64 chars) derives 3 tiers', () => {
|
||||
const pk = 'A1B2C3D4' + '0'.repeat(56);
|
||||
const r = validatePrefixInput(pk);
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.isFullKey, true);
|
||||
assert.strictEqual(r.tiers.length, 3);
|
||||
assert.strictEqual(r.tiers[0].prefix, 'A1');
|
||||
assert.strictEqual(r.tiers[1].prefix, 'A1B2');
|
||||
assert.strictEqual(r.tiers[2].prefix, 'A1B2C3');
|
||||
});
|
||||
|
||||
test('rejects non-hex', () => {
|
||||
const r = validatePrefixInput('ZZZZ');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert(r.error.includes('hex'));
|
||||
});
|
||||
|
||||
test('rejects odd-length input', () => {
|
||||
const r = validatePrefixInput('A1B');
|
||||
assert.strictEqual(r.valid, false);
|
||||
assert(r.error.includes('2, 4, or 6'));
|
||||
});
|
||||
|
||||
console.log('\n--- checkPrefix ---');
|
||||
|
||||
test('detects collision on 1-byte', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }, { public_key: 'A1FFFFFF0000' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix('A1', idx, nodes);
|
||||
assert.strictEqual(r.valid, true);
|
||||
assert.strictEqual(r.results[0].count, 2);
|
||||
});
|
||||
|
||||
test('no collision for unused prefix', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix('FF', idx, nodes);
|
||||
assert.strictEqual(r.results[0].count, 0);
|
||||
});
|
||||
|
||||
test('full key excludes self from colliders', () => {
|
||||
const pk = 'A1B2C3D4E5F60000';
|
||||
const nodes = [{ public_key: pk }, { public_key: 'A1B2FFFFFF000000' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const r = checkPrefix(pk, idx, nodes);
|
||||
assert.strictEqual(r.isFullKey, true);
|
||||
// 1-byte tier: A1 has both nodes, but self excluded = 1 collider
|
||||
assert.strictEqual(r.results[0].count, 1);
|
||||
});
|
||||
|
||||
console.log('\n--- generatePrefix ---');
|
||||
|
||||
test('generates a collision-free 1-byte prefix', () => {
|
||||
const nodes = [];
|
||||
// Fill all but one 1-byte prefix
|
||||
for (let i = 0; i < 255; i++) {
|
||||
nodes.push({ public_key: i.toString(16).toUpperCase().padStart(2, '0') + '0000000000' });
|
||||
}
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(1, idx, () => 0.5);
|
||||
assert.strictEqual(prefix, 'FF'); // only FF is free
|
||||
assert(!idx[1].has(prefix));
|
||||
});
|
||||
|
||||
test('returns null when no prefix available', () => {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
nodes.push({ public_key: i.toString(16).toUpperCase().padStart(2, '0') + '0000000000' });
|
||||
}
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(1, idx);
|
||||
assert.strictEqual(prefix, null);
|
||||
});
|
||||
|
||||
test('generates 2-byte prefix not in index', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const prefix = generatePrefix(2, idx, () => 0.5);
|
||||
assert.strictEqual(typeof prefix, 'string');
|
||||
assert.strictEqual(prefix.length, 4);
|
||||
assert(!idx[2].has(prefix));
|
||||
});
|
||||
|
||||
test('uses deterministic random function', () => {
|
||||
const nodes = [{ public_key: 'A1B2C3D4E5F6' }];
|
||||
const idx = buildPrefixIndex(nodes);
|
||||
const p1 = generatePrefix(2, idx, () => 0.1);
|
||||
const p2 = generatePrefix(2, idx, () => 0.1);
|
||||
assert.strictEqual(p1, p2);
|
||||
});
|
||||
|
||||
console.log('\n--- renderSeverityBadge ---');
|
||||
|
||||
test('unique badge for 0', () => {
|
||||
assert(renderSeverityBadge(0).includes('Unique'));
|
||||
});
|
||||
|
||||
test('warning badge for 1-2', () => {
|
||||
assert(renderSeverityBadge(1).includes('1 collision'));
|
||||
assert(renderSeverityBadge(2).includes('2 collisions'));
|
||||
});
|
||||
|
||||
test('red badge for 3+', () => {
|
||||
assert(renderSeverityBadge(5).includes('5 collisions'));
|
||||
assert(renderSeverityBadge(5).includes('status-red'));
|
||||
});
|
||||
|
||||
// --- Summary ---
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user