mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 18:23:20 +02:00
Add trace tool. Closes #130.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user