import { describe, expect, it } from 'vitest'; import type { ActiveChat } from '../packages/api-contracts/src/index.js'; import { DEFAULT_CONVERSATION_FILTERS, accountOptions, filterCounts, isConversationFilter, isConversationSort, isManualFlow, isUnread, isWaitingOnThem, manualFlowLabel, matchesFilter, matchesSearch, needsReply, partitionConversations, triageStatus, type ConversationFilters, } from '../apps/dashboard/src/features/conversations/conversation-triage.js'; function chat(overrides: Partial & { username: string }): ActiveChat { return { roomName: overrides.username, roomId: `room-${overrides.username}`, lastMessageTime: 1_000, lastMessageSnippet: `snippet for ${overrides.username}`, isFromUs: false, unread: false, ...overrides, }; } function filters(overrides: Partial = {}): ConversationFilters { return { ...DEFAULT_CONVERSATION_FILTERS, ...overrides }; } describe('conversation triage', () => { describe('derived state', () => { it('prefers application read state over the Matrix unread flag', () => { expect(isUnread(chat({ username: 'a', unread: true, hasUnreadInbound: false }))).toBe(false); expect(isUnread(chat({ username: 'a', unread: false, hasUnreadInbound: true }))).toBe(true); }); it('falls back to the Matrix flag when the server sends no read state', () => { expect(isUnread(chat({ username: 'a', unread: true }))).toBe(true); expect(isUnread(chat({ username: 'a', unread: false }))).toBe(false); }); it('treats an inbound newest message on an open conversation as needing a reply', () => { expect(needsReply(chat({ username: 'a', isFromUs: false }))).toBe(true); expect(needsReply(chat({ username: 'a', isFromUs: true }))).toBe(false); }); it('stops a resolved or snoozed conversation from needing a reply', () => { expect(needsReply(chat({ username: 'a', triageStatus: 'done' }))).toBe(false); expect(needsReply(chat({ username: 'a', triageStatus: 'snoozed' }))).toBe(false); }); it('separates waiting-on-them from needing a reply', () => { const waiting = chat({ username: 'a', isFromUs: true }); expect(isWaitingOnThem(waiting)).toBe(true); expect(needsReply(waiting)).toBe(false); }); it('defaults a conversation with no stored state to open', () => { expect(triageStatus(chat({ username: 'a' }))).toBe('open'); }); it('treats every handed-over reply-loop status as a manual conversation', () => { for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) { expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true); } }); it('treats a manual policy as manual even while the reply loop is still nominally active', () => { expect(isManualFlow(chat({ username: 'a', automationStatus: 'active', automationPolicyMode: 'manual', }))).toBe(true); }); it('does not call an actively automated or never-automated conversation manual', () => { expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false); expect(isManualFlow(chat({ username: 'b' }))).toBe(false); }); it('explains why a conversation is being handled by hand', () => { expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' }))) .toBe('Manual — you replied'); expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' }))) .toBe('Manual — image received'); expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' }))) .toBe('Manual by policy'); expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'active' }))).toBeNull(); }); }); describe('filters', () => { const open = chat({ username: 'open', hasUnreadInbound: true }); const replied = chat({ username: 'replied', isFromUs: true }); const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' }); const done = chat({ username: 'done', triageStatus: 'done' }); const withImage = chat({ username: 'image', hasReceivedImages: true }); const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' }); it('excludes snoozed and resolved conversations from the default view', () => { expect(matchesFilter(open, 'all')).toBe(true); expect(matchesFilter(replied, 'all')).toBe(true); expect(matchesFilter(snoozed, 'all')).toBe(false); expect(matchesFilter(done, 'all')).toBe(false); }); it('keeps snoozed and resolved conversations reachable through their own buckets', () => { expect(matchesFilter(snoozed, 'snoozed')).toBe(true); expect(matchesFilter(done, 'done')).toBe(true); }); it('moves deleted accounts out of actionable buckets into their own bucket', () => { expect(matchesFilter(deleted, 'all')).toBe(false); expect(matchesFilter(deleted, 'needs-reply')).toBe(false); expect(matchesFilter(deleted, 'account-deleted')).toBe(true); expect(needsReply(deleted)).toBe(false); }); it('scopes unread and image buckets to open conversations', () => { expect(matchesFilter(open, 'unread')).toBe(true); expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false); expect(matchesFilter(withImage, 'received-images')).toBe(true); expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false); }); it('collects conversations the operator is driving into the manual bucket', () => { const manual = chat({ username: 'manual', automationStatus: 'user-overridden' }); expect(matchesFilter(manual, 'manual')).toBe(true); expect(matchesFilter(open, 'manual')).toBe(false); // Resolved work leaves the manual queue the same way it leaves the others. expect(matchesFilter( chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }), 'manual', )).toBe(false); }); it('matches search against username, snippet and loaded message text', () => { const conversation = chat({ username: 'alice', lastMessageSnippet: 'about the weather', messages: [{ sender: 'alice', text: 'I live in Bristol', timestamp: 1, isSelf: false }], }); expect(matchesSearch(conversation, 'ALI')).toBe(true); expect(matchesSearch(conversation, 'weather')).toBe(true); expect(matchesSearch(conversation, 'bristol')).toBe(true); expect(matchesSearch(conversation, 'nothing')).toBe(false); expect(matchesSearch(conversation, ' ')).toBe(true); }); }); describe('partitionConversations', () => { it('sorts newest first by default', () => { const result = partitionConversations([ chat({ username: 'old', lastMessageTime: 100 }), chat({ username: 'new', lastMessageTime: 300 }), chat({ username: 'mid', lastMessageTime: 200 }), ], filters()); expect(result.visible.map((item) => item.username)).toEqual(['new', 'mid', 'old']); }); it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => { const result = partitionConversations([ chat({ username: 'recent-inbound', lastMessageTime: 300 }), chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }), chat({ username: 'stale-inbound', lastMessageTime: 100 }), ], filters({ sort: 'oldest-unanswered' })); // Unanswered first, oldest of those leading; everything else newest-first. expect(result.visible.map((item) => item.username)) .toEqual(['stale-inbound', 'recent-inbound', 'replied-old']); }); it('reports how many rows the seller filter removed', () => { const result = partitionConversations([ chat({ username: 'alice' }), chat({ username: 'seller-one', sellerDetected: true }), chat({ username: 'seller-two', sellerDetected: true }), ], filters()); expect(result.visible.map((item) => item.username)).toEqual(['alice']); expect(result.hiddenSellerCount).toBe(2); }); it('includes sellers without counting them as hidden once revealed', () => { const result = partitionConversations([ chat({ username: 'seller', sellerDetected: true }), ], filters({ showSellers: true })); expect(result.visible).toHaveLength(1); expect(result.hiddenSellerCount).toBe(0); }); it('narrows to a single owning account', () => { const result = partitionConversations([ chat({ username: 'a', owner: { accountId: 'one', redditUsername: 'bot-one' } }), chat({ username: 'b', owner: { accountId: 'two', redditUsername: 'bot-two' } }), ], filters({ account: 'two' })); expect(result.visible.map((item) => item.username)).toEqual(['b']); }); it('does not mutate the input array order', () => { const input = [ chat({ username: 'old', lastMessageTime: 100 }), chat({ username: 'new', lastMessageTime: 300 }), ]; partitionConversations(input, filters()); expect(input.map((item) => item.username)).toEqual(['old', 'new']); }); }); describe('filterCounts', () => { it('counts each bucket independently', () => { const counts = filterCounts([ chat({ username: 'unread-inbound', hasUnreadInbound: true }), chat({ username: 'read-inbound', hasUnreadInbound: false }), chat({ username: 'replied', isFromUs: true }), chat({ username: 'snoozed', triageStatus: 'snoozed' }), chat({ username: 'done', triageStatus: 'done' }), chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }), chat({ username: 'deleted', recipientAccountStatus: 'deleted' }), ], filters()); expect(counts.all).toBe(4); expect(counts.unread).toBe(1); expect(counts['needs-reply']).toBe(2); expect(counts['awaiting-reply']).toBe(2); expect(counts.manual).toBe(1); expect(counts.snoozed).toBe(1); expect(counts.done).toBe(1); expect(counts['account-deleted']).toBe(1); }); it('respects the search term so a pill never promises rows the list will not show', () => { const counts = filterCounts([ chat({ username: 'alice', hasUnreadInbound: true }), chat({ username: 'bob', hasUnreadInbound: true }), ], filters({ search: 'alice' })); expect(counts.unread).toBe(1); expect(counts.all).toBe(1); }); it('excludes hidden sellers from the counts', () => { const counts = filterCounts([ chat({ username: 'alice', hasUnreadInbound: true }), chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }), ], filters()); expect(counts.unread).toBe(1); }); }); describe('accountOptions', () => { it('lists distinct owners sorted by username', () => { expect(accountOptions([ chat({ username: 'a', owner: { accountId: 'two', redditUsername: 'zoe' } }), chat({ username: 'b', owner: { accountId: 'one', redditUsername: 'adam' } }), chat({ username: 'c', owner: { accountId: 'one', redditUsername: 'adam' } }), chat({ username: 'd' }), ])).toEqual([ { accountId: 'one', redditUsername: 'adam' }, { accountId: 'two', redditUsername: 'zoe' }, ]); }); }); describe('query-parameter guards', () => { it('accepts only known filter and sort values', () => { expect(isConversationFilter('needs-reply')).toBe(true); expect(isConversationFilter('bogus')).toBe(false); expect(isConversationFilter(null)).toBe(false); expect(isConversationSort('oldest-unanswered')).toBe(true); expect(isConversationSort('bogus')).toBe(false); }); }); });