Add trace tool. Closes #130.

This commit is contained in:
Jack Kingsman
2026-03-30 19:26:12 -07:00
parent eb1f7ae638
commit 134e8d0d29
19 changed files with 1625 additions and 21 deletions
+47
View File
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
it('restores the trace tool from the URL hash', async () => {
window.location.hash = '#trace';
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
window.location.hash = '#trace';
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
window.location.hash = '';
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'trace',
id: 'trace',
name: 'Trace',
})
);
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
vi.mock('../components/TracePane', () => ({
TracePane: () => <div data-testid="trace-pane" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
});
});
it('renders the trace tool pane for trace conversations', () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'trace',
id: 'trace',
name: 'Trace',
},
})}
/>
);
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
+15 -2
View File
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
const view = render(
<Sidebar
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => {
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('shows the trace tool row and selects it', () => {
const { onSelectConversation } = renderSidebar();
fireEvent.click(screen.getByText('Trace'));
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'trace',
id: 'trace',
name: 'Trace',
});
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
+262
View File
@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
function makeContact(
publicKey: string,
name: string | null,
type = CONTACT_TYPE_REPEATER,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
const config: RadioConfig = {
public_key: 'ff'.repeat(32),
name: 'Base Radio',
lat: 10,
lon: 20,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: true,
};
describe('TracePane', () => {
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
config={config}
onRunTracePath={vi.fn()}
contacts={[
makeContact('11'.repeat(32), 'Relay Alpha'),
makeContact('22'.repeat(6), 'Prefix Relay'),
makeContact('33'.repeat(32), 'Client Node', 1),
makeContact('44'.repeat(32), 'Relay Beta'),
]}
/>
);
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
});
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayB.public_key,
name: relayB.name,
observed_hash: relayB.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 3.25,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 4.5,
nodes: [
{
role: 'custom',
public_key: null,
name: null,
observed_hash: 'ae',
snr: 4.0,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: '11',
snr: 2.0,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 3.0,
},
],
})
);
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(1, [
{ hop_hex: 'ae' },
{ public_key: relayA.public_key },
]);
});
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
const onRunTracePath = vi.fn(
() =>
new Promise<RadioTraceResponse>((resolve) => {
resolveTrace = resolve;
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
});
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
resolveTrace?.({
path_len: 1,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
});
});
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
});
});
+8
View File
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #trace as trace type', () => {
window.location.hash = '#trace';
const result = parseHashConversation();
expect(result).toEqual({ type: 'trace', name: 'trace' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';