mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
|
||||
|
||||
describe('getAvatarText', () => {
|
||||
it('returns first emoji when name contains emoji', () => {
|
||||
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
|
||||
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
|
||||
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
|
||||
});
|
||||
|
||||
it('returns initials when name has space', () => {
|
||||
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
|
||||
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
|
||||
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
|
||||
});
|
||||
|
||||
it('returns single letter when no space', () => {
|
||||
expect(getAvatarText('John', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('alice', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to public key when name is null', () => {
|
||||
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
|
||||
});
|
||||
|
||||
it('falls back to public key when name has no letters', () => {
|
||||
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
|
||||
expect(getAvatarText('---', 'def456')).toBe('DE');
|
||||
});
|
||||
|
||||
it('handles space but no letter after', () => {
|
||||
expect(getAvatarText('John ', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('A 123', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('emoji takes priority over initials', () => {
|
||||
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatarColor', () => {
|
||||
it('returns consistent colors for same public key', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('abc123def456');
|
||||
expect(color1).toEqual(color2);
|
||||
});
|
||||
|
||||
it('returns different colors for different public keys', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('xyz789uvw012');
|
||||
expect(color1.background).not.toBe(color2.background);
|
||||
});
|
||||
|
||||
it('returns valid HSL background color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
|
||||
it('returns white or black text color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(['#ffffff', '#000000']).toContain(color.text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
const avatar = getContactAvatar('John Doe', 'abc123def456');
|
||||
expect(avatar.text).toBe('JD');
|
||||
expect(avatar.background).toMatch(/^hsl\(/);
|
||||
expect(['#ffffff', '#000000']).toContain(avatar.textColor);
|
||||
});
|
||||
|
||||
it('handles null name', () => {
|
||||
const avatar = getContactAvatar(null, 'abc123def456');
|
||||
expect(avatar.text).toBe('AB');
|
||||
});
|
||||
|
||||
it('returns repeater avatar for type=2', () => {
|
||||
const avatar = getContactAvatar('Some Repeater', 'abc123def456', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar.text).toBe('🛜');
|
||||
expect(avatar.background).toBe('#444444');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('repeater avatar ignores name', () => {
|
||||
const avatar1 = getContactAvatar('🚀 Rocket', 'abc123', CONTACT_TYPE_REPEATER);
|
||||
const avatar2 = getContactAvatar(null, 'xyz789', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar1.text).toBe('🛜');
|
||||
expect(avatar2.text).toBe('🛜');
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
expect(avatar0.text).toBe('J');
|
||||
expect(avatar1.text).toBe('J');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for message deduplication in MessageList.
|
||||
*
|
||||
* Messages arriving via different packet paths should be deduplicated
|
||||
* based on (type, conversation_key, text, sender_timestamp).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message } from '../types';
|
||||
|
||||
/**
|
||||
* Deduplication logic extracted from MessageList for testing.
|
||||
* Same message via different paths = same (type, conversation_key, text, timestamp)
|
||||
*/
|
||||
function deduplicateMessages(messages: Message[]): Message[] {
|
||||
return messages.reduce<Message[]>((acc, msg) => {
|
||||
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
const existing = acc.find(m =>
|
||||
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
|
||||
);
|
||||
if (!existing) {
|
||||
acc.push(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test message',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Message Deduplication', () => {
|
||||
it('keeps unique messages', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Message 1', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Message 2', sender_timestamp: 2000 }),
|
||||
createMessage({ id: 3, text: 'Message 3', sender_timestamp: 3000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('deduplicates same channel message via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
createMessage({ id: 3, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1); // keeps first occurrence
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different timestamps', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Hello', sender_timestamp: 2000 }), // different timestamp
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different channels', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', text: 'Hello', sender_timestamp: 1000 }), // different channel
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deduplicates same DM via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps DMs from different senders with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'def456789012345678901234567890123456789012345678901234567890', text: 'Hi', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps channel message and DM with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hello', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = deduplicateMessages([]);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single message', () => {
|
||||
const messages = [createMessage({ id: 1 })];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for message parsing utilities.
|
||||
*
|
||||
* These tests verify the sender extraction logic used to parse
|
||||
* channel messages in "sender: message" format.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
describe('parseSenderFromText', () => {
|
||||
it('extracts sender and content from "sender: message" format', () => {
|
||||
const result = parseSenderFromText('Alice: Hello everyone!');
|
||||
|
||||
expect(result.sender).toBe('Alice');
|
||||
expect(result.content).toBe('Hello everyone!');
|
||||
});
|
||||
|
||||
it('handles sender names with spaces', () => {
|
||||
const result = parseSenderFromText('Bob Smith: How are you?');
|
||||
|
||||
expect(result.sender).toBe('Bob Smith');
|
||||
expect(result.content).toBe('How are you?');
|
||||
});
|
||||
|
||||
it('returns null sender for plain messages without colon-space', () => {
|
||||
const result = parseSenderFromText('Just a plain message');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('Just a plain message');
|
||||
});
|
||||
|
||||
it('returns null sender when colon has no space after', () => {
|
||||
const result = parseSenderFromText('Note:this is not a sender');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('Note:this is not a sender');
|
||||
});
|
||||
|
||||
it('rejects sender containing square brackets', () => {
|
||||
const result = parseSenderFromText('[System]: Alert message');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('[System]: Alert message');
|
||||
});
|
||||
|
||||
it('rejects sender containing colon', () => {
|
||||
const result = parseSenderFromText('12:30: Time announcement');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('12:30: Time announcement');
|
||||
});
|
||||
|
||||
it('rejects sender names longer than 50 characters', () => {
|
||||
const longName = 'A'.repeat(60);
|
||||
const result = parseSenderFromText(`${longName}: message`);
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const result = parseSenderFromText('');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('handles message with multiple colons', () => {
|
||||
const result = parseSenderFromText('User: Check this URL: https://example.com');
|
||||
|
||||
expect(result.sender).toBe('User');
|
||||
expect(result.content).toBe('Check this URL: https://example.com');
|
||||
});
|
||||
|
||||
it('handles colon at start of message', () => {
|
||||
const result = parseSenderFromText(': no sender here');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe(': no sender here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats today timestamp as time only', () => {
|
||||
// Use current time to ensure it's "today"
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const result = formatTime(now);
|
||||
|
||||
// Should be just time (HH:MM format)
|
||||
expect(result).toMatch(/^\d{1,2}:\d{2}( [AP]M)?$/);
|
||||
});
|
||||
|
||||
it('formats older timestamp with date and time', () => {
|
||||
// Use a timestamp from 2023 (definitely not today)
|
||||
const timestamp = 1700000000; // 2023-11-14
|
||||
|
||||
const result = formatTime(timestamp);
|
||||
|
||||
// Should contain month, day, and time
|
||||
expect(result).toMatch(/\w+ \d{1,2}/); // e.g., "Nov 14"
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStateKey', () => {
|
||||
it('creates channel state key with full id', () => {
|
||||
const key = getStateKey('channel', '5');
|
||||
|
||||
expect(key).toBe('channel-5');
|
||||
});
|
||||
|
||||
it('creates contact state key with 12-char prefix', () => {
|
||||
const fullKey = 'abcdef123456789012345678901234567890';
|
||||
const key = getStateKey('contact', fullKey);
|
||||
|
||||
expect(key).toBe('contact-abcdef123456');
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const shortKey = 'abc123';
|
||||
const key = getStateKey('contact', shortKey);
|
||||
|
||||
expect(key).toBe('contact-abc123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for unread count tracking logic.
|
||||
*
|
||||
* These tests verify the unread message counting behavior
|
||||
* without involving React component rendering.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message, Conversation } from '../types';
|
||||
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
|
||||
|
||||
/**
|
||||
* Determine if a message should increment unread count.
|
||||
* Extracted logic from App.tsx for testing.
|
||||
*/
|
||||
function shouldIncrementUnread(
|
||||
msg: Message,
|
||||
activeConversation: Conversation | null
|
||||
): { key: string } | null {
|
||||
// Only count incoming messages
|
||||
if (msg.outgoing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
const key = `channel-${msg.conversation_key}`;
|
||||
// Don't count if this channel is active
|
||||
if (activeConversation?.type === 'channel' && activeConversation?.id === msg.conversation_key) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// Use 12-char prefix for contact key
|
||||
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
|
||||
// Don't count if this contact is active (compare by prefix)
|
||||
if (activeConversation?.type === 'contact' && pubkeysMatch(activeConversation.id, msg.conversation_key)) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a conversation from the counts map.
|
||||
* Extracted logic from Sidebar.tsx for testing.
|
||||
*/
|
||||
function getUnreadCount(
|
||||
type: 'channel' | 'contact',
|
||||
id: string,
|
||||
unreadCounts: Record<string, number>
|
||||
): number {
|
||||
if (type === 'channel') {
|
||||
return unreadCounts[`channel-${id}`] || 0;
|
||||
}
|
||||
// For contacts, use prefix
|
||||
const prefix = `contact-${getPubkeyPrefix(id)}`;
|
||||
return unreadCounts[prefix] || 0;
|
||||
}
|
||||
|
||||
describe('shouldIncrementUnread', () => {
|
||||
const createMessage = (overrides: Partial<Message>): Message => ({
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test',
|
||||
sender_timestamp: null,
|
||||
received_at: Date.now(),
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns key for incoming channel message when not viewing that channel', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', name: 'other' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
});
|
||||
|
||||
it('returns null for incoming channel message when viewing that channel', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', name: '#test' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for outgoing messages', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns key for incoming direct message when not viewing that contact', () => {
|
||||
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
|
||||
const activeConversation: Conversation = { type: 'contact', id: 'xyz999999999012345678901234567890123456789012345678901234567', name: 'other' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'contact-abc123456789' });
|
||||
});
|
||||
|
||||
it('returns null for incoming direct message when viewing that contact', () => {
|
||||
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
|
||||
const activeConversation: Conversation = {
|
||||
type: 'contact',
|
||||
id: 'abc123456789fullkey12345678901234567890123456789012345678',
|
||||
name: 'Alice',
|
||||
};
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns key when no conversation is active', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
});
|
||||
|
||||
it('returns key when viewing raw packet feed', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('returns count for channel by exact key match', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', counts)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 for channel with no unread', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns count for contact using 12-char prefix', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
// Full public key lookup should match the prefix
|
||||
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const counts = { 'contact-short': 2 };
|
||||
|
||||
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for contact with no unread', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for WebSocket message parsing.
|
||||
*
|
||||
* These tests verify that WebSocket messages are correctly parsed
|
||||
* and routed to the appropriate handlers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
|
||||
|
||||
/**
|
||||
* Parse and route a WebSocket message.
|
||||
* Extracted logic from useWebSocket.ts for testing.
|
||||
*/
|
||||
function parseWebSocketMessage(
|
||||
data: string,
|
||||
handlers: {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
}
|
||||
): { type: string; handled: boolean } {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'health':
|
||||
handlers.onHealth?.(msg.data as HealthStatus);
|
||||
return { type: msg.type, handled: !!handlers.onHealth };
|
||||
case 'contacts':
|
||||
handlers.onContacts?.(msg.data as Contact[]);
|
||||
return { type: msg.type, handled: !!handlers.onContacts };
|
||||
case 'channels':
|
||||
handlers.onChannels?.(msg.data as Channel[]);
|
||||
return { type: msg.type, handled: !!handlers.onChannels };
|
||||
case 'message':
|
||||
handlers.onMessage?.(msg.data as Message);
|
||||
return { type: msg.type, handled: !!handlers.onMessage };
|
||||
case 'contact':
|
||||
handlers.onContact?.(msg.data as Contact);
|
||||
return { type: msg.type, handled: !!handlers.onContact };
|
||||
case 'raw_packet':
|
||||
handlers.onRawPacket?.(msg.data as RawPacket);
|
||||
return { type: msg.type, handled: !!handlers.onRawPacket };
|
||||
case 'message_acked':
|
||||
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
return { type: msg.type, handled: !!handlers.onMessageAcked };
|
||||
case 'pong':
|
||||
return { type: msg.type, handled: true };
|
||||
default:
|
||||
return { type: msg.type, handled: false };
|
||||
}
|
||||
} catch {
|
||||
return { type: 'error', handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseWebSocketMessage', () => {
|
||||
it('routes health message to onHealth handler', () => {
|
||||
const onHealth = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onHealth });
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onHealth).toHaveBeenCalledWith({
|
||||
radio_connected: true,
|
||||
serial_port: '/dev/ttyUSB0',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with message ID', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'message_acked',
|
||||
data: { message_id: 42 },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessageAcked });
|
||||
|
||||
expect(result.type).toBe('message_acked');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('routes new message to onMessage handler', () => {
|
||||
const onMessage = vi.fn();
|
||||
const messageData = {
|
||||
id: 123,
|
||||
type: 'CHAN',
|
||||
channel_idx: 0,
|
||||
text: 'Hello',
|
||||
received_at: 1700000000,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
};
|
||||
const data = JSON.stringify({ type: 'message', data: messageData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessage });
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessage).toHaveBeenCalledWith(messageData);
|
||||
});
|
||||
|
||||
it('handles pong messages silently', () => {
|
||||
const data = JSON.stringify({ type: 'pong' });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('pong');
|
||||
expect(result.handled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unhandled for unknown message types', () => {
|
||||
const data = JSON.stringify({ type: 'unknown_type', data: {} });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('unknown_type');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
const data = 'not valid json {';
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('error');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call handler when not provided', () => {
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes raw_packet to onRawPacket handler', () => {
|
||||
const onRawPacket = vi.fn();
|
||||
const packetData = {
|
||||
id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'deadbeef',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
decrypted: true,
|
||||
decrypted_info: { channel_name: '#test', sender: 'Alice' },
|
||||
};
|
||||
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onRawPacket });
|
||||
|
||||
expect(result.type).toBe('raw_packet');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onRawPacket).toHaveBeenCalledWith(packetData);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user