Files
Remote-Terminal-for-MeshCore/frontend/src/test/rawPacketStore.test.tsx
T
Ryan Gregg 5e5ab7db4d Keep overheard packet traffic out of the chat render path
Typing in a conversation got progressively slower as its history grew.
The cause was not the input: every packet the node overhears was stored
in App state, so each one re-rendered the whole tree, including the
un-memoized MessageList. That cost scales with history length, and on a
busy mesh it saturated the main thread so keystrokes queued behind it.

Measured in Chromium with the real components, cost per overheard packet:

  history    before    after
      50      7.3ms    ~0ms
     500     48.5ms    ~0ms
    1000     97.7ms    ~0ms
    2000    228.4ms    ~0ms

The packet stream now lives in a small external store that views subscribe
to individually, so only the map, visualizer, raw feed, and cracker re-render
when a packet arrives, so the chat view is no longer along for the ride.
This also retires useRawPacketStatsSession, whose session state was App
state for the same reason.
2026-07-25 12:32:14 -07:00

227 lines
6.3 KiB
TypeScript

import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
import {
clearRawPackets,
getRawPacketStatsSession,
getRawPackets,
recordRawPacket,
resetRawPacketStore,
useRawPackets,
} from '../stores/rawPacketStore';
import type {
Channel,
Contact,
Conversation,
HealthStatus,
Message,
RadioConfig,
RawPacket,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
}));
vi.mock('../components/MessageList', () => ({
MessageList: mocks.messageList,
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
return {
id: 1,
observation_id: 1,
timestamp: 1700000000,
data: 'aabb',
payload_type: 'GROUP_TEXT',
snr: 7.5,
rssi: -80,
decrypted: false,
decrypted_info: null,
...overrides,
};
}
const channel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
muted: false,
};
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
lat: 1,
lon: 2,
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,
};
const health: HealthStatus = {
status: 'ok',
radio_connected: true,
radio_initializing: false,
connection_info: 'serial',
database_size_mb: 1,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
};
const message: Message = {
id: 1,
type: 'CHAN',
conversation_key: channel.key,
text: 'hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: null,
};
const rawPacketStatsSession: RawPacketStatsSessionState = {
sessionStartedAt: 1_700_000_000_000,
totalObservedPackets: 0,
trimmedObservationCount: 0,
observations: [],
};
/* eslint-disable @typescript-eslint/no-explicit-any */
function chatPaneProps(): any {
return {
activeConversation: { type: 'channel', id: channel.key, name: channel.name } as Conversation,
contacts: [] as Contact[],
channels: [channel],
rawPacketStatsSession,
config,
health,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
messages: [message],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
targetMessageId: null,
hasNewerMessages: false,
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');
}),
onToggleFavorite: vi.fn(async () => {}),
onToggleMute: vi.fn(async () => {}),
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
onSelectConversation: vi.fn(),
onOpenContactInfo: vi.fn(),
onOpenChannelInfo: vi.fn(),
onSenderClick: vi.fn(),
onLoadOlder: vi.fn(async () => {}),
onResendChannelMessage: vi.fn(async () => {}),
onTargetReached: vi.fn(),
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
repeaterAutoLoginKey: null,
onClearRepeaterAutoLogin: vi.fn(),
};
}
describe('rawPacketStore', () => {
beforeEach(() => {
vi.clearAllMocks();
resetRawPacketStore();
});
it('appends packets and counts them in the session stats', () => {
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
recordRawPacket(createPacket({ id: 2, observation_id: 2 }));
expect(getRawPackets()).toHaveLength(2);
expect(getRawPacketStatsSession().totalObservedPackets).toBe(2);
});
it('ignores a repeat of the same observation', () => {
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
expect(getRawPackets()).toHaveLength(1);
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
});
it('caps the buffer at the requested size, keeping the newest packets', () => {
for (let i = 1; i <= 5; i++) {
recordRawPacket(createPacket({ id: i, observation_id: i }), 3);
}
expect(getRawPackets().map((p) => p.id)).toEqual([3, 4, 5]);
});
it('keeps session stats across a reconnect clear', () => {
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
clearRawPackets();
expect(getRawPackets()).toEqual([]);
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
});
it('notifies subscribed views when a packet arrives', () => {
function PacketCount() {
return <span data-testid="count">{useRawPackets().length}</span>;
}
render(<PacketCount />);
expect(screen.getByTestId('count').textContent).toBe('0');
act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 })));
expect(screen.getByTestId('count').textContent).toBe('1');
});
/**
* The reason this store exists: overheard traffic used to live in App state, so every
* packet re-rendered the whole message list. That cost scales with history length and
* made typing in a busy channel crawl.
*/
it('does not re-render the chat message list when packets arrive', () => {
render(<ConversationPane {...chatPaneProps()} />);
expect(screen.getByTestId('message-list')).toBeInTheDocument();
const rendersAfterMount = mocks.messageList.mock.calls.length;
act(() => {
for (let i = 1; i <= 25; i++) {
recordRawPacket(createPacket({ id: i, observation_id: i }));
}
});
expect(getRawPackets()).toHaveLength(25);
expect(mocks.messageList.mock.calls.length).toBe(rendersAfterMount);
});
});