mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 02:33:04 +02:00
Add packet feed clickable packet inspection. Closes #75 again.
This commit is contained in:
@@ -3,7 +3,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketFeedView } from '../components/RawPacketFeedView';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import type { Contact, RawPacket } from '../types';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
|
||||
const GROUP_TEXT_PACKET_HEX =
|
||||
'1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818';
|
||||
|
||||
const TEST_CHANNEL: Channel = {
|
||||
key: '7ABA109EDCF304A84433CB71D0F3AB73',
|
||||
name: '#six77',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
...TEST_CHANNEL,
|
||||
name: '#collision',
|
||||
};
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<RawPacketStatsSessionState> = {}
|
||||
@@ -78,15 +94,34 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
};
|
||||
}
|
||||
|
||||
function renderView({
|
||||
packets = [],
|
||||
contacts = [],
|
||||
channels = [],
|
||||
rawPacketStatsSession = createSession(),
|
||||
}: {
|
||||
packets?: RawPacket[];
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
rawPacketStatsSession?: RawPacketStatsSessionState;
|
||||
} = {}) {
|
||||
return render(
|
||||
<RawPacketFeedView
|
||||
packets={packets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('RawPacketFeedView', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens a stats drawer with window controls and grouped summaries', () => {
|
||||
render(
|
||||
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
|
||||
);
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Packet Types')).not.toBeInTheDocument();
|
||||
@@ -95,6 +130,7 @@ describe('RawPacketFeedView', () => {
|
||||
|
||||
expect(screen.getByLabelText('Stats window')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument();
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
@@ -114,11 +150,10 @@ describe('RawPacketFeedView', () => {
|
||||
}))
|
||||
);
|
||||
|
||||
render(
|
||||
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
|
||||
);
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -161,13 +196,11 @@ describe('RawPacketFeedView', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<RawPacketFeedView
|
||||
packets={initialPackets}
|
||||
rawPacketStatsSession={initialSession}
|
||||
contacts={[]}
|
||||
/>
|
||||
);
|
||||
const { rerender } = renderView({
|
||||
packets: initialPackets,
|
||||
rawPacketStatsSession: initialSession,
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } });
|
||||
@@ -179,6 +212,7 @@ describe('RawPacketFeedView', () => {
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={initialSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
|
||||
@@ -195,7 +229,12 @@ describe('RawPacketFeedView', () => {
|
||||
],
|
||||
};
|
||||
rerender(
|
||||
<RawPacketFeedView packets={nextPackets} rawPacketStatsSession={nextSession} contacts={[]} />
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={nextSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
@@ -203,30 +242,27 @@ describe('RawPacketFeedView', () => {
|
||||
});
|
||||
|
||||
it('resolves neighbor labels from matching contacts when identity is available', () => {
|
||||
render(
|
||||
<RawPacketFeedView
|
||||
packets={[]}
|
||||
rawPacketStatsSession={createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11BB22CC33',
|
||||
sourceLabel: 'AA11BB22CC33',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
})}
|
||||
contacts={[createContact()]}
|
||||
/>
|
||||
);
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11BB22CC33',
|
||||
sourceLabel: 'AA11BB22CC33',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [createContact()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
@@ -234,33 +270,86 @@ describe('RawPacketFeedView', () => {
|
||||
});
|
||||
|
||||
it('marks unresolved neighbor identities explicitly', () => {
|
||||
render(
|
||||
<RawPacketFeedView
|
||||
packets={[]}
|
||||
rawPacketStatsSession={createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'DEADBEEF1234',
|
||||
sourceLabel: 'DEADBEEF1234',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
})}
|
||||
contacts={[]}
|
||||
/>
|
||||
);
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'DEADBEEF1234',
|
||||
sourceLabel: 'DEADBEEF1234',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText('Packet Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('#six77')).toBeInTheDocument();
|
||||
expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByText(/hello there; this hashtag room is essentially public/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('#six77')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('#collision')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector';
|
||||
|
||||
describe('rawPacketInspector helpers', () => {
|
||||
it('formats path hex as hop-delimited groups', () => {
|
||||
expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6');
|
||||
expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC');
|
||||
});
|
||||
|
||||
it('leaves non-hop-aligned hex unchanged', () => {
|
||||
expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3');
|
||||
expect(formatHexByHop('A1B2', null)).toBe('A1B2');
|
||||
});
|
||||
|
||||
it('describes undecryptable ciphertext with multiline bullets', () => {
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Timestamp (4 bytes)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Flags (1 byte)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain(
|
||||
'\n• Message (remaining bytes)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketList } from '../components/RawPacketList';
|
||||
import type { RawPacket } from '../types';
|
||||
@@ -23,5 +23,17 @@ describe('RawPacketList', () => {
|
||||
render(<RawPacketList packets={[createPacket()]} />);
|
||||
|
||||
expect(screen.getByText('TF')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes packet cards clickable only when an inspector handler is provided', () => {
|
||||
const packet = createPacket({ id: 9, observation_id: 22 });
|
||||
const onPacketClick = vi.fn();
|
||||
|
||||
render(<RawPacketList packets={[packet]} onPacketClick={onPacketClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(onPacketClick).toHaveBeenCalledWith(packet);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ function createSession(
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 2,
|
||||
pathSignature: '01>02',
|
||||
hopByteWidth: 1,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
@@ -38,6 +39,7 @@ function createSession(
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-3',
|
||||
@@ -51,6 +53,7 @@ function createSession(
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '02',
|
||||
hopByteWidth: 2,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-4',
|
||||
@@ -64,6 +67,7 @@ function createSession(
|
||||
sourceLabel: null,
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
@@ -88,6 +92,13 @@ describe('buildRawPacketStatsSnapshot', () => {
|
||||
expect.objectContaining({ label: 'Control', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopByteWidthProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'No path', count: 2 }),
|
||||
expect.objectContaining({ label: '1 byte / hop', count: 1 }),
|
||||
expect.objectContaining({ label: '2 bytes / hop', count: 1 }),
|
||||
])
|
||||
);
|
||||
expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 });
|
||||
expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 });
|
||||
expect(stats.windowFullyCovered).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user