diff --git a/src/lib/__tests__/exportConversation.test.ts b/src/lib/__tests__/exportConversation.test.ts new file mode 100644 index 0000000..4486aed --- /dev/null +++ b/src/lib/__tests__/exportConversation.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { exportAsMarkdown } from '../exportConversation'; +import type { ChatMessage } from '../../types'; + +function makeMsg( + role: 'user' | 'assistant', + content: string, + blocks: ChatMessage['blocks'] = [], +): ChatMessage { + return { id: `msg-${Math.random()}`, role, content, timestamp: 1700000000000, blocks }; +} + +describe('exportAsMarkdown', () => { + it('exports a basic user + assistant exchange', () => { + const msgs = [ + makeMsg('user', 'Hello'), + makeMsg('assistant', 'Hi there!'), + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('# Conversation'); + expect(md).toContain('### 👤 User'); + expect(md).toContain('Hello'); + expect(md).toContain('### 🤖 Assistant'); + expect(md).toContain('Hi there!'); + }); + + it('uses custom session label as title', () => { + const md = exportAsMarkdown([], 'My Session'); + expect(md).toContain('# My Session'); + }); + + it('renders text blocks', () => { + const msgs = [ + makeMsg('assistant', '', [{ type: 'text', text: 'Block content' }]), + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('Block content'); + }); + + it('renders thinking blocks in details tags', () => { + const msgs = [ + makeMsg('assistant', '', [{ type: 'thinking', text: 'Deep thought' }]), + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('
'); + expect(md).toContain('💭 Thinking'); + expect(md).toContain('Deep thought'); + expect(md).toContain('
'); + }); + + it('renders tool_use blocks with JSON', () => { + const msgs = [ + makeMsg('assistant', '', [{ type: 'tool_use', name: 'exec', input: { command: 'ls' }, id: 't1' }]), + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('**🔧 Tool: `exec`**'); + expect(md).toContain('"command": "ls"'); + }); + + it('renders tool_result blocks truncated', () => { + const longContent = 'x'.repeat(3000); + const msgs = [ + makeMsg('assistant', '', [{ type: 'tool_result', content: longContent, tool_use_id: 't1' }]), + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('**📋 Result:**'); + expect(md).toContain('...(truncated)'); + // Should have at most 2000 chars of content + truncation + const resultLine = md.split('\n').find(l => l.startsWith('xxx')); + expect(resultLine!.length).toBeLessThanOrEqual(2000); + }); + + it('renders compaction separator', () => { + const msgs: ChatMessage[] = [ + { id: 'sep', role: 'assistant', content: '', timestamp: 0, blocks: [], isCompactionSeparator: true }, + ]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('---'); + expect(md).toContain('*Context compacted*'); + }); + + it('falls back to content when no blocks', () => { + const msgs = [makeMsg('user', 'Plain text')]; + const md = exportAsMarkdown(msgs); + expect(md).toContain('Plain text'); + }); +}); diff --git a/src/lib/__tests__/messageCache.test.ts b/src/lib/__tests__/messageCache.test.ts new file mode 100644 index 0000000..15580ff --- /dev/null +++ b/src/lib/__tests__/messageCache.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { mergeWithCache } from '../messageCache'; +import type { ChatMessage } from '../../types'; + +function makeMsg(id: string, role: 'user' | 'assistant' = 'user', ts = Date.now()): ChatMessage { + return { id, role, content: `msg-${id}`, timestamp: ts, blocks: [] }; +} + +describe('mergeWithCache', () => { + it('returns gateway messages unchanged when cache is empty', () => { + const gateway = [makeMsg('a'), makeMsg('b')]; + const result = mergeWithCache(gateway, []); + expect(result.messages).toEqual(gateway); + expect(result.wasCompacted).toBe(false); + }); + + it('returns gateway messages when cache has same messages', () => { + const msgs = [makeMsg('a'), makeMsg('b')]; + const result = mergeWithCache(msgs, msgs); + expect(result.messages).toEqual(msgs); + expect(result.wasCompacted).toBe(false); + }); + + it('detects compaction and merges old cached messages', () => { + const old1 = makeMsg('old1', 'user', 1000); + const old2 = makeMsg('old2', 'assistant', 2000); + const current = makeMsg('new1', 'user', 5000); + + const cached = [old1, old2, current]; + const gateway = [current]; // old messages compacted away + + const result = mergeWithCache(gateway, cached); + expect(result.wasCompacted).toBe(true); + // Should have: old1 (archived), old2 (archived), separator, new1 + expect(result.messages).toHaveLength(4); + expect(result.messages[0]).toMatchObject({ id: 'old1', isArchived: true }); + expect(result.messages[1]).toMatchObject({ id: 'old2', isArchived: true }); + expect(result.messages[2].isCompactionSeparator).toBe(true); + expect(result.messages[3].id).toBe('new1'); + }); + + it('separator timestamp is just before first gateway message', () => { + const cached = [makeMsg('old', 'user', 1000)]; + const gateway = [makeMsg('new', 'user', 5000)]; + + const result = mergeWithCache(gateway, cached); + const separator = result.messages.find(m => m.isCompactionSeparator); + expect(separator).toBeDefined(); + expect(separator!.timestamp).toBe(4999); + }); + + it('handles empty gateway messages with non-empty cache', () => { + const cached = [makeMsg('old', 'user', 1000)]; + const result = mergeWithCache([], cached); + expect(result.wasCompacted).toBe(true); + expect(result.messages).toHaveLength(2); // archived + separator + expect(result.messages[0]).toMatchObject({ id: 'old', isArchived: true }); + expect(result.messages[1].isCompactionSeparator).toBe(true); + }); +}); diff --git a/src/lib/__tests__/slashUtils.test.ts b/src/lib/__tests__/slashUtils.test.ts new file mode 100644 index 0000000..e9327ff --- /dev/null +++ b/src/lib/__tests__/slashUtils.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { shouldShowSlashMenu } from '../slashUtils'; + +describe('shouldShowSlashMenu', () => { + it('returns true for a slash at the start', () => { + expect(shouldShowSlashMenu('/status')).toBe(true); + }); + + it('returns true for slash with leading spaces', () => { + expect(shouldShowSlashMenu(' /help')).toBe(true); + }); + + it('returns false for text without slash', () => { + expect(shouldShowSlashMenu('hello')).toBe(false); + }); + + it('returns false if slash is not at start', () => { + expect(shouldShowSlashMenu('hello /cmd')).toBe(false); + }); + + it('returns false if text contains newline', () => { + expect(shouldShowSlashMenu('/cmd\nmore')).toBe(false); + }); + + it('returns true for just a slash', () => { + expect(shouldShowSlashMenu('/')).toBe(true); + }); +});