Visualizer overhaul

This commit is contained in:
Jack Kingsman
2026-03-12 12:56:59 -07:00
parent 489950a2f7
commit b81f6ef89e
12 changed files with 1405 additions and 597 deletions
@@ -0,0 +1,209 @@
import { describe, expect, it, vi } from 'vitest';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import {
buildPacketNetworkContext,
createPacketNetworkState,
ingestPacketIntoPacketNetwork,
projectCanonicalPath,
projectPacketNetwork,
snapshotNeighborIds,
} from '../networkGraph/packetNetworkGraph';
import type { Contact, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
const { packetFixtures } = vi.hoisted(() => ({
packetFixtures: new Map<string, unknown>(),
}));
vi.mock('../utils/visualizerUtils', async () => {
const actual = await vi.importActual<typeof import('../utils/visualizerUtils')>(
'../utils/visualizerUtils'
);
return {
...actual,
parsePacket: vi.fn(
(hexData: string) => packetFixtures.get(hexData) ?? actual.parsePacket(hexData)
),
};
});
function createConfig(publicKey: string): RadioConfig {
return {
public_key: publicKey,
name: 'Me',
lat: 0,
lon: 0,
tx_power: 0,
max_tx_power: 0,
radio: { freq: 0, bw: 0, sf: 0, cr: 0 },
path_hash_mode: 0,
path_hash_mode_supported: true,
advert_location_source: 'off',
};
}
function createContact(publicKey: string, name: string, type = 1): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
function createPacket(data: string): RawPacket {
return {
id: 1,
observation_id: 1,
timestamp: 1_700_000_000,
data,
payload_type: 'TEXT',
snr: null,
rssi: null,
decrypted: false,
decrypted_info: null,
};
}
describe('packetNetworkGraph', () => {
it('preserves canonical adjacency while projection hides ambiguous repeaters', () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-semantic-hide', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-semantic-hide',
pathBytes: ['32'],
srcHash: 'aaaaaaaaaaaa',
dstHash: 'ffffffffffff',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const state = createPacketNetworkState('Me');
const context = buildPacketNetworkContext({
contacts: [createContact(aliceKey, 'Alice')],
config: createConfig(selfKey),
repeaterAdvertPaths: [],
splitAmbiguousByTraffic: false,
useAdvertPathHints: false,
});
ingestPacketIntoPacketNetwork(state, context, createPacket('dm-semantic-hide'));
const hiddenProjection = projectPacketNetwork(state, {
showAmbiguousNodes: false,
showAmbiguousPaths: false,
});
const shownProjection = projectPacketNetwork(state, {
showAmbiguousNodes: false,
showAmbiguousPaths: true,
});
expect(snapshotNeighborIds(state)).toEqual(
new Map([
['?32', ['aaaaaaaaaaaa', 'self']],
['aaaaaaaaaaaa', ['?32']],
['self', ['?32']],
])
);
expect(hiddenProjection.links.has('aaaaaaaaaaaa->self')).toBe(true);
expect(shownProjection.links.has('?32->aaaaaaaaaaaa')).toBe(true);
expect(shownProjection.links.has('?32->self')).toBe(true);
});
it('projects hidden ambiguous runs as dashed bridges but keeps later known repeaters visible', () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-hidden-chain', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-hidden-chain',
pathBytes: ['32', '565656565656'],
srcHash: 'aaaaaaaaaaaa',
dstHash: 'ffffffffffff',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const state = createPacketNetworkState('Me');
const context = buildPacketNetworkContext({
contacts: [
createContact(aliceKey, 'Alice'),
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
],
config: createConfig(selfKey),
repeaterAdvertPaths: [],
splitAmbiguousByTraffic: false,
useAdvertPathHints: false,
});
const ingested = ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-chain'));
expect(ingested?.canonicalPath).toEqual(['aaaaaaaaaaaa', '?32', '565656565656', 'self']);
const projectedPath = projectCanonicalPath(state, ingested!.canonicalPath, {
showAmbiguousNodes: false,
showAmbiguousPaths: false,
});
const projection = projectPacketNetwork(state, {
showAmbiguousNodes: false,
showAmbiguousPaths: false,
});
expect(projectedPath.nodes).toEqual(['aaaaaaaaaaaa', '565656565656', 'self']);
expect(Array.from(projectedPath.dashedLinkDetails.keys())).toEqual([
'565656565656->aaaaaaaaaaaa',
]);
expect(projection.links.get('565656565656->aaaaaaaaaaaa')?.hasHiddenIntermediate).toBe(true);
expect(projection.links.get('565656565656->self')?.hasDirectObservation).toBe(true);
});
it('replays real advert packets through the semantic layer', () => {
const state = createPacketNetworkState('Me');
const context = buildPacketNetworkContext({
contacts: [],
config: createConfig('ffffffffffff0000000000000000000000000000000000000000000000000000'),
repeaterAdvertPaths: [],
splitAmbiguousByTraffic: false,
useAdvertPathHints: false,
});
const packet = createPacket(
'1106538B1CD273868576DC7F679B493F9AB5AC316173E1A56D3388BC3BA75F583F63AB0D1BA2A8ABD0BC6669DBF719E67E4C8517BA4E0D6F8C96A323E9D13A77F2630DED965A5C17C3EC6ED1601EEFE857749DA24E9F39CBEACD722C3708F433DB5FA9BAF0BAF9BC5B1241069290FEEB029A839EF843616E204F204D657368203220F09FA5AB'
);
packet.payload_type = 'ADVERT';
const ingested = ingestPacketIntoPacketNetwork(state, context, packet);
expect(ingested?.canonicalPath).toEqual([
'8576dc7f679b',
'?53',
'?8b',
'?1c',
'?d2',
'?73',
'?86',
'self',
]);
expect(snapshotNeighborIds(state).get('?73')).toEqual(['?86', '?d2']);
});
});
@@ -113,6 +113,51 @@ afterEach(() => {
});
describe('useVisualizerData3D', () => {
it('keeps canonical adjacency stable when ambiguous repeaters are shown or hidden', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-canonical-stable', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-canonical-stable',
pathBytes: ['32'],
srcHash: 'aaaaaaaaaaaa',
dstHash: 'ffffffffffff',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const packets = [createPacket('dm-canonical-stable')];
const contacts = [createContact(aliceKey, 'Alice')];
const hidden = renderVisualizerData({
packets,
contacts,
config: createConfig(selfKey),
showAmbiguousPaths: false,
});
const shown = renderVisualizerData({
packets,
contacts,
config: createConfig(selfKey),
showAmbiguousPaths: true,
});
await waitFor(() =>
expect(hidden.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
);
await waitFor(() =>
expect(shown.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
);
expect(hidden.result.current.canonicalNeighborIds).toEqual(
shown.result.current.canonicalNeighborIds
);
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(true);
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(false);
expect(shown.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(true);
});
it('marks compressed hidden-repeater routes as dashed links instead of direct solid links', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
@@ -140,6 +185,9 @@ describe('useVisualizerData3D', () => {
expect(link).toBeDefined();
expect(link?.hasHiddenIntermediate).toBe(true);
expect(link?.hasDirectObservation).toBe(false);
expect(result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']);
expect(result.current.canonicalNeighborIds.get('self')).toEqual(['?32']);
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
});
it('does not append self after a resolved outgoing DM destination', async () => {
@@ -175,6 +223,44 @@ describe('useVisualizerData3D', () => {
expect(result.current.links.has(buildLinkKey('self', 'bbbbbbbbbbbb'))).toBe(false);
});
it('picks back up with known repeaters after hiding ambiguous repeater segments', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
packetFixtures.set('dm-hidden-then-known', {
payloadType: PayloadType.TextMessage,
messageHash: 'dm-hidden-then-known',
pathBytes: ['32', '565656565656'],
srcHash: 'aaaaaaaaaaaa',
dstHash: 'ffffffffffff',
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
});
const { result } = renderVisualizerData({
packets: [createPacket('dm-hidden-then-known')],
contacts: [
createContact(aliceKey, 'Alice'),
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
],
config: createConfig(selfKey),
showAmbiguousPaths: false,
});
await waitFor(() => expect(result.current.links.size).toBe(2));
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '565656565656'))).toBe(true);
expect(result.current.links.has(buildLinkKey('565656565656', 'self'))).toBe(true);
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(false);
expect(result.current.renderedNodeIds.has('565656565656')).toBe(true);
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
expect(result.current.canonicalNeighborIds.get('?32')).toEqual(
expect.arrayContaining(['aaaaaaaaaaaa', '565656565656'])
);
});
it('does not create a fake self edge for an unresolved outgoing direct DM', async () => {
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getSceneNodeLabel } from '../components/visualizer/shared';
describe('visualizer shared label helpers', () => {
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
expect(
getSceneNodeLabel({
id: '?32',
name: 'Likely Relay',
type: 'repeater',
isAmbiguous: true,
})
).toBe('Likely Relay (?)');
});
it('does not add an ambiguity suffix to unambiguous nodes', () => {
expect(
getSceneNodeLabel({
id: 'aaaaaaaaaaaa',
name: 'Alice',
type: 'client',
isAmbiguous: false,
})
).toBe('Alice');
});
});
+22 -8
View File
@@ -2,18 +2,17 @@ import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { VisualizerTooltip } from '../components/visualizer/VisualizerTooltip';
import type { GraphNode } from '../components/visualizer/shared';
import type { PacketNetworkNode } from '../networkGraph/packetNetworkGraph';
function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type'>): GraphNode {
function createNode(
overrides: Partial<PacketNetworkNode> & Pick<PacketNetworkNode, 'id' | 'type'>
): PacketNetworkNode {
return {
id: overrides.id,
type: overrides.type,
name: overrides.name ?? null,
isAmbiguous: overrides.isAmbiguous ?? false,
lastActivity: overrides.lastActivity ?? Date.now(),
x: overrides.x ?? 0,
y: overrides.y ?? 0,
z: overrides.z ?? 0,
probableIdentity: overrides.probableIdentity,
ambiguousNames: overrides.ambiguousNames,
lastActivityReason: overrides.lastActivityReason,
@@ -23,7 +22,12 @@ function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type
describe('VisualizerTooltip', () => {
it('renders nothing without an active node', () => {
const { container } = render(
<VisualizerTooltip activeNodeId={null} nodes={new Map()} neighborIds={[]} />
<VisualizerTooltip
activeNodeId={null}
canonicalNodes={new Map()}
canonicalNeighborIds={new Map()}
renderedNodeIds={new Set()}
/>
);
expect(container).toBeEmptyDOMElement();
@@ -49,17 +53,25 @@ describe('VisualizerTooltip', () => {
name: 'Neighbor Node',
ambiguousNames: ['Alt Neighbor'],
});
const hiddenRepeater = createNode({
id: '?44',
type: 'repeater',
name: '44',
isAmbiguous: true,
});
render(
<VisualizerTooltip
activeNodeId={node.id}
nodes={
canonicalNodes={
new Map([
[node.id, node],
[neighbor.id, neighbor],
[hiddenRepeater.id, hiddenRepeater],
])
}
neighborIds={[neighbor.id]}
canonicalNeighborIds={new Map([[node.id, [neighbor.id, hiddenRepeater.id]]])}
renderedNodeIds={new Set([node.id, neighbor.id])}
/>
);
@@ -72,6 +84,8 @@ describe('VisualizerTooltip', () => {
expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument();
expect(screen.getByText('Neighbor Node')).toBeInTheDocument();
expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument();
expect(screen.getByText('44')).toBeInTheDocument();
expect(screen.getByText('(hidden)')).toBeInTheDocument();
vi.useRealTimers();
});