mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 13:54:43 +00:00
5678874128
Fixes #935 ## Problem `buildPrefixMap()` indexed ALL nodes regardless of role, causing companions/sensors to appear as repeater hops when their pubkey prefix collided with a path-hop hash byte. ## Fix ### Server (`cmd/server/store.go`) - Added `canAppearInPath(role string) bool` — allowlist of roles that can forward packets (repeater, room_server, room) - `buildPrefixMap` now skips nodes that fail this check ### Client (`public/hop-resolver.js`) - Added matching `canAppearInPath(role)` helper - `init()` now only populates `prefixIdx` for path-eligible nodes - `pubkeyIdx` remains complete — `resolveFromServer()` still resolves any node type by full pubkey (for server-confirmed `resolved_path` arrays) ## Tests - `cmd/server/prefix_map_role_test.go`: 7 new tests covering role filtering in prefix map and resolveWithContext - `test-hop-resolver-affinity.js`: 4 new tests verifying client-side role filter + pubkeyIdx completeness - All existing tests updated to include `Role: "repeater"` where needed - `go test ./cmd/server/...` — PASS - `node test-hop-resolver-affinity.js` — 16/17 pass (1 pre-existing centroid failure unrelated to this change) ## Commits 1. `fix: filter prefix map to only repeater/room roles (#935)` — server implementation 2. `test: prefix map role filter coverage (#935)` — server tests 3. `ui: filter HopResolver prefix index to repeater/room roles (#935)` — client implementation 4. `test: hop-resolver role filter coverage (#935)` — client tests --------- Co-authored-by: you <you@example.com>
153 lines
7.6 KiB
JavaScript
153 lines
7.6 KiB
JavaScript
/**
|
|
* Unit tests for HopResolver affinity-aware hop resolution.
|
|
*/
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
|
|
// Load hop-resolver.js in a sandboxed context
|
|
const code = fs.readFileSync(__dirname + '/public/hop-resolver.js', 'utf8');
|
|
const sandbox = { window: {}, console, Math, Object, Array, Number, Date, Map, Set, parseInt, parseFloat, encodeURIComponent };
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(code, sandbox);
|
|
const HopResolver = sandbox.window.HopResolver;
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function assert(condition, msg) {
|
|
if (condition) { passed++; console.log(' ✓ ' + msg); }
|
|
else { failed++; console.error(' ✗ ' + msg); }
|
|
}
|
|
|
|
// ── Test nodes ──
|
|
// Two nodes share the same 1-byte prefix "ab"
|
|
const nodeA = { public_key: 'ab1111', name: 'NodeA', role: 'repeater', lat: 37.0, lon: -122.0 };
|
|
const nodeB = { public_key: 'ab2222', name: 'NodeB', role: 'repeater', lat: 38.0, lon: -123.0 };
|
|
const nodeC = { public_key: 'cd3333', name: 'NodeC', role: 'repeater', lat: 37.5, lon: -122.5 };
|
|
|
|
console.log('\n=== HopResolver Affinity Tests ===\n');
|
|
|
|
// Test 1: Affinity prefers neighbor candidate over geo-closest
|
|
console.log('Test 1: Affinity prefers neighbor over geo-closest');
|
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
|
HopResolver.setAffinity({
|
|
edges: [
|
|
{ source: 'cd3333', target: 'ab2222', score: 0.8 }
|
|
// NodeC is a neighbor of NodeB but NOT NodeA
|
|
]
|
|
});
|
|
|
|
// Resolve hop "ab" after NodeC was resolved — should pick NodeB (neighbor) not NodeA (geo-closer)
|
|
// Origin at NodeC's position so forward pass runs with NodeC as anchor
|
|
const result1 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
|
assert(result1['ab'].name === 'NodeB', 'Should pick NodeB (affinity neighbor of NodeC) — got: ' + result1['ab'].name);
|
|
|
|
// Test 2: Without affinity, falls back to geo-closest
|
|
console.log('\nTest 2: Cold start (no affinity) falls back to geo-closest');
|
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
|
HopResolver.setAffinity({}); // No edges
|
|
|
|
// With anchor at NodeC's position, NodeA is closer to NodeC than NodeB
|
|
const result2 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
|
// NodeA (37, -122) is closer to NodeC (37.5, -122.5) than NodeB (38, -123)
|
|
assert(result2['ab'].name === 'NodeA', 'Should pick NodeA (geo-closest) — got: ' + result2['ab'].name);
|
|
|
|
// Test 3: setAffinity with null/undefined doesn't crash
|
|
console.log('\nTest 3: setAffinity with null/undefined is safe');
|
|
HopResolver.setAffinity(null);
|
|
HopResolver.setAffinity(undefined);
|
|
HopResolver.setAffinity({});
|
|
assert(true, 'No crash on null/undefined/empty affinity');
|
|
|
|
// Test 4: getAffinity returns correct scores
|
|
console.log('\nTest 4: getAffinity returns correct scores');
|
|
HopResolver.setAffinity({
|
|
edges: [
|
|
{ source: 'aaa', target: 'bbb', score: 0.95 },
|
|
{ source: 'ccc', target: 'ddd', weight: 5 }
|
|
]
|
|
});
|
|
assert(HopResolver.getAffinity('aaa', 'bbb') === 0.95, 'aaa→bbb = 0.95');
|
|
assert(HopResolver.getAffinity('bbb', 'aaa') === 0.95, 'bbb→aaa = 0.95 (bidirectional)');
|
|
assert(HopResolver.getAffinity('ccc', 'ddd') === 5, 'ccc→ddd = 5 (weight fallback)');
|
|
assert(HopResolver.getAffinity('aaa', 'zzz') === 0, 'unknown pair = 0');
|
|
assert(HopResolver.getAffinity(null, 'bbb') === 0, 'null pubkey = 0');
|
|
|
|
// Test 5: Affinity with multiple neighbors — highest score wins
|
|
console.log('\nTest 5: Highest affinity score wins among neighbors');
|
|
HopResolver.init([nodeA, nodeB, nodeC]);
|
|
HopResolver.setAffinity({
|
|
edges: [
|
|
{ source: 'cd3333', target: 'ab1111', score: 0.3 },
|
|
{ source: 'cd3333', target: 'ab2222', score: 0.9 }
|
|
]
|
|
});
|
|
const result5 = HopResolver.resolve(['cd33', 'ab'], nodeC.lat, nodeC.lon, null, null, null);
|
|
assert(result5['ab'].name === 'NodeB', 'Should pick NodeB (highest affinity 0.9) — got: ' + result5['ab'].name);
|
|
|
|
// Test 6: Unambiguous hops are not affected by affinity
|
|
console.log('\nTest 6: Unambiguous hops unaffected by affinity');
|
|
const nodeD = { public_key: 'ee4444', name: 'NodeD', role: 'repeater', lat: 36.0, lon: -121.0 };
|
|
HopResolver.init([nodeA, nodeB, nodeC, nodeD]);
|
|
HopResolver.setAffinity({ edges: [] });
|
|
const result6 = HopResolver.resolve(['ee44'], null, null, null, null, null);
|
|
assert(result6['ee44'].name === 'NodeD', 'Unique prefix resolves directly — got: ' + result6['ee44'].name);
|
|
assert(!result6['ee44'].ambiguous, 'Should not be marked ambiguous');
|
|
|
|
// Test 7: lat=0 / lon=0 candidates are NOT excluded (equator/prime-meridian bug fix)
|
|
console.log('\nTest 7: lat=0 / lon=0 candidates are included in geo scoring');
|
|
const nodeEquator = { public_key: 'ab5555', name: 'EquatorNode', role: 'repeater', lat: 0, lon: 10 };
|
|
const nodeFar = { public_key: 'ab6666', name: 'FarNode', role: 'repeater', lat: 60, lon: 60 };
|
|
const anchorNearEq = { public_key: 'cd7777', name: 'AnchorEq', role: 'repeater', lat: 1, lon: 11 };
|
|
HopResolver.init([nodeEquator, nodeFar, anchorNearEq]);
|
|
HopResolver.setAffinity({});
|
|
// Anchor near equator — EquatorNode (0,10) should be geo-closest
|
|
const result7 = HopResolver.resolve(['cd77', 'ab'], 1.0, 11.0, null, null, null);
|
|
assert(result7['ab'].name === 'EquatorNode',
|
|
'lat=0 candidate should be included and win by geo — got: ' + result7['ab'].name);
|
|
|
|
// Test 8: lon=0 candidate is also included
|
|
console.log('\nTest 8: lon=0 candidate is included in geo scoring');
|
|
const nodePrime = { public_key: 'ab8888', name: 'PrimeMeridian', role: 'repeater', lat: 10, lon: 0 };
|
|
const anchorNearPM = { public_key: 'cd9999', name: 'AnchorPM', role: 'repeater', lat: 11, lon: 1 };
|
|
HopResolver.init([nodePrime, nodeFar, anchorNearPM]);
|
|
HopResolver.setAffinity({});
|
|
const result8 = HopResolver.resolve(['cd99', 'ab'], 11.0, 1.0, null, null, null);
|
|
assert(result8['ab'].name === 'PrimeMeridian',
|
|
'lon=0 candidate should be included and win by geo — got: ' + result8['ab'].name);
|
|
|
|
// ── Role filter tests (#935) ──
|
|
console.log('\nTest: Role filter — companions excluded from prefixIdx');
|
|
const companion = { public_key: 'ab9999', name: 'Companion1', role: 'companion', lat: 37.0, lon: -122.0 };
|
|
const sensor = { public_key: 'ab7777', name: 'Sensor1', role: 'sensor', lat: 37.0, lon: -122.0 };
|
|
const repeater = { public_key: 'ab1234', name: 'Repeater1', role: 'repeater', lat: 37.0, lon: -122.0 };
|
|
const roomSrv = { public_key: 'ff1234', name: 'RoomSrv1', role: 'room_server', lat: 37.0, lon: -122.0 };
|
|
|
|
HopResolver.init([companion, sensor, repeater, roomSrv]);
|
|
HopResolver.setAffinity({});
|
|
|
|
// Prefix 'ab' should only resolve to repeater (companion/sensor excluded)
|
|
const r1 = HopResolver.resolve(['ab12'], 0, 0, null, null, null);
|
|
assert(r1['ab12'] && r1['ab12'].name === 'Repeater1',
|
|
'prefix ab12 resolves to Repeater1 not companion — got: ' + (r1['ab12'] && r1['ab12'].name));
|
|
|
|
// Prefix 'ff' should resolve to room_server
|
|
const r2 = HopResolver.resolve(['ff12'], 0, 0, null, null, null);
|
|
assert(r2['ff12'] && r2['ff12'].name === 'RoomSrv1',
|
|
'prefix ff12 resolves to RoomSrv1 — got: ' + (r2['ff12'] && r2['ff12'].name));
|
|
|
|
// Prefix that only matches companion should return nothing
|
|
const r3 = HopResolver.resolve(['ab99'], 0, 0, null, null, null);
|
|
assert(!r3['ab99'] || !r3['ab99'].name,
|
|
'prefix ab99 (companion only) resolves to nothing — got: ' + (r3['ab99'] && r3['ab99'].name));
|
|
|
|
// pubkeyIdx should still have companion (full pubkey lookup)
|
|
console.log('\nTest: pubkeyIdx still includes all roles');
|
|
const fromServer = HopResolver.resolveFromServer(['ab99'], [companion.public_key]);
|
|
assert(fromServer['ab99'] && fromServer['ab99'].name === 'Companion1',
|
|
'resolveFromServer finds companion by full pubkey — got: ' + (fromServer['ab99'] && fromServer['ab99'].name));
|
|
|
|
console.log('\n' + (passed + failed) + ' tests, ' + passed + ' passed, ' + failed + ' failed\n');
|
|
process.exit(failed > 0 ? 1 : 0);
|