Files
Remote-Terminal-for-MeshCore/frontend/src/test/unreadCounts.test.ts
T
2026-01-19 00:01:25 -08:00

272 lines
8.7 KiB
TypeScript

/**
* 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';
/**
* 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) {
const key = `contact-${msg.conversation_key}`;
// Don't count if this contact is active
if (activeConversation?.type === 'contact' && 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 {
return unreadCounts[`${type}-${id}`] || 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(),
paths: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 0,
...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);
// State key uses full public key
expect(result).toEqual({
key: 'contact-abc123456789012345678901234567890123456789012345678901234567',
});
});
it('returns null for incoming direct message when viewing that contact', () => {
const fullKey = 'abc123456789012345678901234567890123456789012345678901234567';
const msg = createMessage({
type: 'PRIV',
conversation_key: fullKey,
});
const activeConversation: Conversation = {
type: 'contact',
id: fullKey, // Same full key - exact match required
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 full public key', () => {
const fullKey = 'abc123456789fullpublickey123456789012345678901234';
const counts = { [`contact-${fullKey}`]: 5 };
expect(getUnreadCount('contact', fullKey, counts)).toBe(5);
});
it('returns 0 for contact with no unread', () => {
const counts = { 'contact-abc123456789fullpublickey123456789012345678901234': 5 };
expect(
getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)
).toBe(0);
});
});
/**
* Check if a message text contains a mention of the given name in @[name] format.
* Extracted from useUnreadCounts.ts for testing.
*/
function messageContainsMention(text: string, name: string | null): boolean {
if (!name) return false;
// Escape special regex characters in the name
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionPattern = new RegExp(`@\\[${escaped}\\]`, 'i');
return mentionPattern.test(text);
}
describe('messageContainsMention', () => {
it('returns true when text contains mention of the name', () => {
expect(messageContainsMention('Hey @[Alice] check this out', 'Alice')).toBe(true);
});
it('returns false when text does not contain the mention', () => {
expect(messageContainsMention('Hey Alice check this out', 'Alice')).toBe(false);
});
it('returns false when name is null', () => {
expect(messageContainsMention('Hey @[Alice] check this out', null)).toBe(false);
});
it('returns false when text is empty', () => {
expect(messageContainsMention('', 'Alice')).toBe(false);
});
it('matches case insensitively', () => {
expect(messageContainsMention('Hey @[ALICE] check this out', 'alice')).toBe(true);
expect(messageContainsMention('Hey @[alice] check this out', 'ALICE')).toBe(true);
});
it('handles emojis in names', () => {
expect(messageContainsMention('Hey @[FlightlessDt🥝] nice!', 'FlightlessDt🥝')).toBe(true);
expect(messageContainsMention('Hey @[🎉Party🎉]', '🎉Party🎉')).toBe(true);
});
it('handles special regex characters in names', () => {
// Names with characters that have special meaning in regex
expect(messageContainsMention('Hey @[Test.User] hello', 'Test.User')).toBe(true);
expect(messageContainsMention('Hey @[User+1] hello', 'User+1')).toBe(true);
expect(messageContainsMention('Hey @[User*Star] hello', 'User*Star')).toBe(true);
expect(messageContainsMention('Hey @[What?] hello', 'What?')).toBe(true);
});
it('does not match partial names', () => {
// @[Alice] should not match a name of just "Ali"
expect(messageContainsMention('Hey @[Alice] check this', 'Ali')).toBe(false);
});
it('handles mention at start of text', () => {
expect(messageContainsMention('@[Bob] hello there', 'Bob')).toBe(true);
});
it('handles mention at end of text', () => {
expect(messageContainsMention('hello @[Bob]', 'Bob')).toBe(true);
});
it('handles multiple mentions - matches if user is mentioned', () => {
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Alice')).toBe(true);
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Bob')).toBe(true);
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Charlie')).toBe(false);
});
});