mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Improve some coverage in integration form
This commit is contained in:
@@ -51,6 +51,7 @@ import { Toaster, toast } from './components/ui/sonner';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import { appendRawPacketUnique } from './utils/rawPacketIdentity';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { mergeContactIntoList } from './utils/contactMerge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
|
||||
|
||||
@@ -265,21 +266,7 @@ export function App() {
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => {
|
||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||
if (idx >= 0) {
|
||||
const existing = prev[idx];
|
||||
const merged = { ...existing, ...contact };
|
||||
const unchanged = (Object.keys(merged) as (keyof Contact)[]).every(
|
||||
(k) => existing[k] === merged[k]
|
||||
);
|
||||
if (unchanged) return prev;
|
||||
const updated = [...prev];
|
||||
updated[idx] = merged;
|
||||
return updated;
|
||||
}
|
||||
return [...prev, contact as Contact];
|
||||
});
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS));
|
||||
|
||||
@@ -12,8 +12,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import fixtures from './fixtures/websocket_events.json';
|
||||
import { getMessageContentKey } from '../hooks/useConversationMessages';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import * as messageCache from '../messageCache';
|
||||
import type { Message } from '../types';
|
||||
import type { Contact, Message } from '../types';
|
||||
|
||||
/**
|
||||
* Minimal state for testing message dedup and unread logic.
|
||||
@@ -263,3 +264,175 @@ describe('Integration: State Key Contract', () => {
|
||||
expect(stateKey).toBe(`contact-${publicKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Contact merge tests (imports real mergeContactIntoList) ---
|
||||
|
||||
function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
public_key: 'abc123',
|
||||
name: 'TestNode',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: true,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Integration: Contact Merge', () => {
|
||||
it('appends new contact to list', () => {
|
||||
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha' })];
|
||||
const incoming = makeContact({ public_key: 'bbb', name: 'Beta' });
|
||||
|
||||
const result = mergeContactIntoList(existing, incoming);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].name).toBe('Beta');
|
||||
});
|
||||
|
||||
it('merges existing contact (updates name, preserves other fields)', () => {
|
||||
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0 })];
|
||||
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha-Updated' });
|
||||
|
||||
const result = mergeContactIntoList(existing, incoming);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Alpha-Updated');
|
||||
// Spread semantics: incoming lat (null) overwrites existing lat
|
||||
expect(result[0].public_key).toBe('aaa');
|
||||
});
|
||||
|
||||
it('returns same array reference when contact is unchanged', () => {
|
||||
const contact = makeContact({ public_key: 'aaa', name: 'Alpha' });
|
||||
const existing = [contact];
|
||||
// Incoming with identical values
|
||||
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha' });
|
||||
|
||||
const result = mergeContactIntoList(existing, incoming);
|
||||
|
||||
expect(result).toBe(existing); // referential equality
|
||||
});
|
||||
|
||||
it('partial update merges without clobbering unrelated fields', () => {
|
||||
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0, lon: -122.0 })];
|
||||
// Incoming update only changes lat
|
||||
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha', lat: 48.0, lon: -122.0 });
|
||||
|
||||
const result = mergeContactIntoList(existing, incoming);
|
||||
|
||||
expect(result[0].lat).toBe(48.0);
|
||||
expect(result[0].lon).toBe(-122.0);
|
||||
expect(result[0].name).toBe('Alpha');
|
||||
});
|
||||
});
|
||||
|
||||
// --- ACK + messageCache propagation tests ---
|
||||
|
||||
describe('Integration: ACK + messageCache propagation', () => {
|
||||
beforeEach(() => {
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('updateAck updates acked count on cached message', () => {
|
||||
const msg: Message = {
|
||||
id: 100,
|
||||
type: 'PRIV',
|
||||
conversation_key: 'pk_abc',
|
||||
text: 'Hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-100');
|
||||
|
||||
messageCache.updateAck(100, 1);
|
||||
|
||||
const entry = messageCache.get('pk_abc');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry!.messages[0].acked).toBe(1);
|
||||
});
|
||||
|
||||
it('updateAck updates paths when longer', () => {
|
||||
const msg: Message = {
|
||||
id: 101,
|
||||
type: 'PRIV',
|
||||
conversation_key: 'pk_abc',
|
||||
text: 'Test',
|
||||
sender_timestamp: 1700000001,
|
||||
received_at: 1700000001,
|
||||
paths: [{ path: 'aa', received_at: 1700000001 }],
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 1,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-101');
|
||||
|
||||
const longerPaths = [
|
||||
{ path: 'aa', received_at: 1700000001 },
|
||||
{ path: 'bb', received_at: 1700000002 },
|
||||
];
|
||||
messageCache.updateAck(101, 2, longerPaths);
|
||||
|
||||
const entry = messageCache.get('pk_abc');
|
||||
expect(entry!.messages[0].paths).toHaveLength(2);
|
||||
expect(entry!.messages[0].acked).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves higher existing ack count (max semantics)', () => {
|
||||
const msg: Message = {
|
||||
id: 102,
|
||||
type: 'PRIV',
|
||||
conversation_key: 'pk_abc',
|
||||
text: 'Max test',
|
||||
sender_timestamp: 1700000002,
|
||||
received_at: 1700000002,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 5,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-102');
|
||||
|
||||
// Try to update with a lower ack count
|
||||
messageCache.updateAck(102, 3);
|
||||
|
||||
const entry = messageCache.get('pk_abc');
|
||||
expect(entry!.messages[0].acked).toBe(5); // max(5, 3) = 5
|
||||
});
|
||||
|
||||
it('is a no-op for unknown message ID', () => {
|
||||
const msg: Message = {
|
||||
id: 103,
|
||||
type: 'PRIV',
|
||||
conversation_key: 'pk_abc',
|
||||
text: 'Existing',
|
||||
sender_timestamp: 1700000003,
|
||||
received_at: 1700000003,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-103');
|
||||
|
||||
// Update a non-existent message ID — should not throw or modify anything
|
||||
messageCache.updateAck(999, 1);
|
||||
|
||||
const entry = messageCache.get('pk_abc');
|
||||
expect(entry!.messages[0].acked).toBe(0); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
198
frontend/src/test/useWebSocket.dispatch.test.ts
Normal file
198
frontend/src/test/useWebSocket.dispatch.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Integration tests for useWebSocket onmessage dispatch.
|
||||
*
|
||||
* Verifies that the switch statement in useWebSocket.ts:91-134 routes
|
||||
* incoming JSON messages to the correct handler callbacks with the
|
||||
* correct data shapes.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useWebSocket } from '../useWebSocket';
|
||||
import fixtures from './fixtures/websocket_events.json';
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
url: string;
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((error: unknown) => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
}
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
|
||||
/** Send a JSON message through the most recent MockWebSocket instance. */
|
||||
function fireMessage(data: unknown): void {
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => {
|
||||
ws.onmessage?.({ data: JSON.stringify(data) });
|
||||
});
|
||||
}
|
||||
|
||||
describe('useWebSocket dispatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
MockWebSocket.instances = [];
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('routes health message to onHealth', () => {
|
||||
const onHealth = vi.fn();
|
||||
renderHook(() => useWebSocket({ onHealth }));
|
||||
|
||||
const healthData = {
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
connection_info: 'TCP: 1.2.3.4:4000',
|
||||
database_size_mb: 1.5,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
};
|
||||
fireMessage({ type: 'health', data: healthData });
|
||||
|
||||
expect(onHealth).toHaveBeenCalledOnce();
|
||||
expect(onHealth).toHaveBeenCalledWith(healthData);
|
||||
});
|
||||
|
||||
it('routes message event to onMessage with correct Message shape', () => {
|
||||
const onMessage = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessage }));
|
||||
|
||||
const { type, data } = fixtures.channel_message.expected_ws_event;
|
||||
fireMessage({ type, data });
|
||||
|
||||
expect(onMessage).toHaveBeenCalledOnce();
|
||||
expect(onMessage).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
it('routes contact event to onContact with correct Contact shape', () => {
|
||||
const onContact = vi.fn();
|
||||
renderHook(() => useWebSocket({ onContact }));
|
||||
|
||||
const { type, data } = fixtures.advertisement_with_gps.expected_ws_event;
|
||||
fireMessage({ type, data });
|
||||
|
||||
expect(onContact).toHaveBeenCalledOnce();
|
||||
expect(onContact).toHaveBeenCalledWith(data);
|
||||
expect(onContact.mock.calls[0][0]).toHaveProperty('public_key');
|
||||
expect(onContact.mock.calls[0][0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
const { type, data } = fixtures.message_acked.expected_ws_event;
|
||||
fireMessage({ type, data });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledOnce();
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined);
|
||||
});
|
||||
|
||||
it('routes message_acked with paths', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
const paths = [{ path: 'aabb', received_at: 1700000000 }];
|
||||
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths);
|
||||
});
|
||||
|
||||
it('routes error event to onError', () => {
|
||||
const onError = vi.fn();
|
||||
renderHook(() => useWebSocket({ onError }));
|
||||
|
||||
const errorData = { message: 'Send failed', details: 'Radio busy' };
|
||||
fireMessage({ type: 'error', data: errorData });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError).toHaveBeenCalledWith(errorData);
|
||||
});
|
||||
|
||||
it('routes success event to onSuccess', () => {
|
||||
const onSuccess = vi.fn();
|
||||
renderHook(() => useWebSocket({ onSuccess }));
|
||||
|
||||
const successData = { message: 'Message sent' };
|
||||
fireMessage({ type: 'success', data: successData });
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledOnce();
|
||||
expect(onSuccess).toHaveBeenCalledWith(successData);
|
||||
});
|
||||
|
||||
it('pong message calls no handlers', () => {
|
||||
const handlers = {
|
||||
onHealth: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
onContact: vi.fn(),
|
||||
onMessageAcked: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
renderHook(() => useWebSocket(handlers));
|
||||
|
||||
fireMessage({ type: 'pong', data: null });
|
||||
|
||||
Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('unknown message type calls no handlers', () => {
|
||||
const handlers = {
|
||||
onHealth: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
onContact: vi.fn(),
|
||||
onMessageAcked: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
renderHook(() => useWebSocket(handlers));
|
||||
|
||||
fireMessage({ type: 'something_unexpected', data: {} });
|
||||
|
||||
Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('malformed JSON calls no handlers (catch branch)', () => {
|
||||
const handlers = {
|
||||
onHealth: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
onContact: vi.fn(),
|
||||
onMessageAcked: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
renderHook(() => useWebSocket(handlers));
|
||||
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => {
|
||||
ws.onmessage?.({ data: 'not valid json{{{' });
|
||||
});
|
||||
|
||||
Object.values(handlers).forEach((fn) => expect(fn).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
25
frontend/src/utils/contactMerge.ts
Normal file
25
frontend/src/utils/contactMerge.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Contact } from '../types';
|
||||
|
||||
/**
|
||||
* Merge an incoming contact into an existing list.
|
||||
*
|
||||
* - If the contact exists (matched by public_key), merge fields and return
|
||||
* a new array only if something changed (preserves referential equality
|
||||
* for React when nothing changed).
|
||||
* - If the contact is new, append it.
|
||||
*/
|
||||
export function mergeContactIntoList(contacts: Contact[], incoming: Contact): Contact[] {
|
||||
const idx = contacts.findIndex((c) => c.public_key === incoming.public_key);
|
||||
if (idx >= 0) {
|
||||
const existing = contacts[idx];
|
||||
const merged = { ...existing, ...incoming };
|
||||
const unchanged = (Object.keys(merged) as (keyof Contact)[]).every(
|
||||
(k) => existing[k] === merged[k]
|
||||
);
|
||||
if (unchanged) return contacts;
|
||||
const updated = [...contacts];
|
||||
updated[idx] = merged;
|
||||
return updated;
|
||||
}
|
||||
return [...contacts, incoming];
|
||||
}
|
||||
Reference in New Issue
Block a user