Compare commits

...

5 Commits

Author SHA1 Message Date
you
36be02a1b8 refactor: split renderPrefixTool, fix recommendation logic, add tests
Address all 4 review items:

1. God function: split 290-line renderPrefixTool into 10+ smaller
   functions (buildPrefixIndex, computePrefixStats, recommendPrefixSize,
   validatePrefixInput, checkPrefix, generatePrefix, plus HTML helpers:
   renderNetworkOverview, renderPrefixChecker, renderPrefixGenerator,
   renderCheckerResults, renderNodeEntry, renderSeverityBadge,
   renderPrefixStatCard).

2. Inline HTML: extracted HTML template literals into dedicated builder
   functions that return HTML strings. Each section (overview, checker,
   generator, results) is its own function.

3. Dead recommendation logic: fixed >=500 nodes to recommend 3-byte
   prefixes instead of 2-byte (was dead code recommending the same
   thing for both branches).

4. Tests: added test-prefix-tool.js with 28 tests covering index
   building, collision detection, recommendation thresholds (including
   boundary values), input validation, prefix checking, generator
   logic (deterministic via injectable random fn), and severity badges.

Pure logic functions are exported via window._prefixToolExports for
testability without DOM dependencies.
2026-04-05 02:08:35 +00:00
efiten
76c6b155c2 feat: add multi-byte FAQ link to prefix generator section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:59:33 +00:00
efiten
d0b597ff49 feat: make Network Overview collapsible, collapsed by default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:59:33 +00:00
efiten
e19b0eba85 feat: link keygen button to meshcore-web-keygen with prefix pre-fill
Replace placeholder keygen link with https://agessaman.github.io/meshcore-web-keygen/
which supports ?prefix= URL param for pre-filling the generated prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:59:33 +00:00
efiten
df75468a8b feat: add Prefix Tool tab to Analytics page (#347)
Adds a new "Prefix Tool" tab to the Analytics page with three sections:

- Network Overview: per-hash-size collision stats and a size recommendation
  based on node count
- Prefix Checker: accepts a 1/2/3-byte hex prefix or full public key and
  shows which nodes share that prefix at each tier, with severity badges
- Prefix Generator: picks a random collision-free prefix at the chosen hash
  size, with a link to the MeshCore keygen tool

100% client-side — no new API endpoints. Reuses the existing /nodes list.
Supports deep links: ?tab=prefix-tool&prefix=A3F1 and ?generate=2.
Adds a "Check a prefix →" link to the Hash Issues tab nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:59:33 +00:00
2 changed files with 612 additions and 0 deletions

View File

@@ -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&section=hashMatrixSection" style="color:var(--accent)">🔢 Hash Matrix</a>
<span style="color:var(--border)">|</span>
<a href="#/analytics?tab=collisions&section=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
View 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);