Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+99
View File
@@ -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);
});
});
+127
View File
@@ -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');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+177
View File
@@ -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);
});
});
+171
View File
@@ -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);
});
});