feat: add Vitest unit tests for utility functions
- Set up Vitest with 27 tests across 3 test suites - relativeTime: edge cases, time buckets, future timestamps - sessionDisplayName: labels, kinds, channels, UUID truncation - messagesToMarkdown: roles, blocks, tool calls, system events - Add test and test:watch npm scripts - Add test step to CI workflow
This commit is contained in:
84
src/lib/__tests__/exportChat.test.ts
Normal file
84
src/lib/__tests__/exportChat.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { messagesToMarkdown } from '../exportChat';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return {
|
||||
id: '1',
|
||||
role: 'user',
|
||||
content: 'Hello world',
|
||||
timestamp: new Date('2026-01-15T10:30:00Z').getTime(),
|
||||
blocks: [],
|
||||
isSystemEvent: false,
|
||||
...overrides,
|
||||
} as ChatMessage;
|
||||
}
|
||||
|
||||
describe('messagesToMarkdown', () => {
|
||||
it('includes session label as heading', () => {
|
||||
const md = messagesToMarkdown([], 'Test Session');
|
||||
expect(md).toContain('# Test Session');
|
||||
});
|
||||
|
||||
it('includes export timestamp', () => {
|
||||
const md = messagesToMarkdown([]);
|
||||
expect(md).toContain('Exported from PinchChat on');
|
||||
});
|
||||
|
||||
it('labels user messages with 👤 User', () => {
|
||||
const md = messagesToMarkdown([makeMessage({ role: 'user' })]);
|
||||
expect(md).toContain('👤 User');
|
||||
});
|
||||
|
||||
it('labels assistant messages with 🤖 Assistant', () => {
|
||||
const md = messagesToMarkdown([makeMessage({ role: 'assistant' })]);
|
||||
expect(md).toContain('🤖 Assistant');
|
||||
});
|
||||
|
||||
it('labels system events with ⚙️ System Event', () => {
|
||||
const md = messagesToMarkdown([makeMessage({ role: 'user', isSystemEvent: true })]);
|
||||
expect(md).toContain('⚙️ System Event');
|
||||
});
|
||||
|
||||
it('renders text blocks', () => {
|
||||
const md = messagesToMarkdown([makeMessage({
|
||||
content: '',
|
||||
blocks: [{ type: 'text', text: 'Some content here' }],
|
||||
})]);
|
||||
expect(md).toContain('Some content here');
|
||||
});
|
||||
|
||||
it('renders tool_use blocks with name and input', () => {
|
||||
const md = messagesToMarkdown([makeMessage({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
blocks: [{ type: 'tool_use', name: 'exec', input: { command: 'ls' } }],
|
||||
})]);
|
||||
expect(md).toContain('`exec`');
|
||||
expect(md).toContain('"command": "ls"');
|
||||
});
|
||||
|
||||
it('falls back to content when blocks are empty', () => {
|
||||
const md = messagesToMarkdown([makeMessage({ content: 'Fallback text', blocks: [] })]);
|
||||
expect(md).toContain('Fallback text');
|
||||
});
|
||||
|
||||
it('renders image blocks as placeholder', () => {
|
||||
const md = messagesToMarkdown([makeMessage({
|
||||
content: '',
|
||||
blocks: [{ type: 'image', mediaType: 'image/png' }],
|
||||
})]);
|
||||
expect(md).toContain('*[Image]*');
|
||||
});
|
||||
|
||||
it('wraps thinking blocks in details tags', () => {
|
||||
vi.stubGlobal('Date', globalThis.Date);
|
||||
const md = messagesToMarkdown([makeMessage({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
blocks: [{ type: 'thinking', text: 'Let me think...' }],
|
||||
})]);
|
||||
expect(md).toContain('<details><summary>💭 Thinking</summary>');
|
||||
expect(md).toContain('Let me think...');
|
||||
});
|
||||
});
|
||||
57
src/lib/__tests__/relativeTime.test.ts
Normal file
57
src/lib/__tests__/relativeTime.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { relativeTime } from '../relativeTime';
|
||||
|
||||
describe('relativeTime', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(relativeTime(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for 0', () => {
|
||||
expect(relativeTime(0)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "<1m" for timestamps less than 60s ago', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now - 30_000)).toBe('<1m');
|
||||
expect(relativeTime(now)).toBe('<1m');
|
||||
});
|
||||
|
||||
it('returns minutes for 1-59 minutes ago', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now - 60_000)).toBe('1m');
|
||||
expect(relativeTime(now - 45 * 60_000)).toBe('45m');
|
||||
});
|
||||
|
||||
it('returns hours for 1-23 hours ago', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now - 3_600_000)).toBe('1h');
|
||||
expect(relativeTime(now - 23 * 3_600_000)).toBe('23h');
|
||||
});
|
||||
|
||||
it('returns days for 1-29 days ago', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now - 86_400_000)).toBe('1d');
|
||||
expect(relativeTime(now - 7 * 86_400_000)).toBe('7d');
|
||||
});
|
||||
|
||||
it('returns months for 30+ days ago', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now - 30 * 86_400_000)).toBe('1mo');
|
||||
expect(relativeTime(now - 90 * 86_400_000)).toBe('3mo');
|
||||
});
|
||||
|
||||
it('clamps negative diff to 0 (future timestamps)', () => {
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
expect(relativeTime(now + 60_000)).toBe('<1m');
|
||||
});
|
||||
});
|
||||
61
src/lib/__tests__/sessionName.test.ts
Normal file
61
src/lib/__tests__/sessionName.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sessionDisplayName } from '../sessionName';
|
||||
import type { Session } from '../../types';
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
key: 'agent:main:test',
|
||||
kind: 'main',
|
||||
channel: '',
|
||||
lastActivity: Date.now(),
|
||||
...overrides,
|
||||
} as Session;
|
||||
}
|
||||
|
||||
describe('sessionDisplayName', () => {
|
||||
it('returns label when set', () => {
|
||||
expect(sessionDisplayName(makeSession({ label: 'My Task' }))).toBe('My Task');
|
||||
});
|
||||
|
||||
it('returns "Main" for main sessions without channel', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: 'main', channel: '' }))).toBe('Main');
|
||||
});
|
||||
|
||||
it('returns "Main · Discord" for main sessions with channel', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: 'main', channel: 'discord' }))).toBe('Main · Discord');
|
||||
});
|
||||
|
||||
it('returns "Cron" for cron sessions without channel', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: 'cron', channel: '' }))).toBe('Cron');
|
||||
});
|
||||
|
||||
it('returns "Cron · Telegram" for cron sessions with channel', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: 'cron', channel: 'telegram' }))).toBe('Cron · Telegram');
|
||||
});
|
||||
|
||||
it('returns "Task · Discord" for isolated sessions', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: 'isolated', channel: 'discord' }))).toBe('Task · Discord');
|
||||
});
|
||||
|
||||
it('returns capitalized channel as fallback', () => {
|
||||
expect(sessionDisplayName(makeSession({ kind: undefined as unknown as string, channel: 'slack' } as Partial<Session>))).toBe('Slack');
|
||||
});
|
||||
|
||||
it('truncates UUID session keys', () => {
|
||||
const name = sessionDisplayName(makeSession({
|
||||
kind: undefined as unknown as string,
|
||||
channel: '',
|
||||
key: 'agent:main:a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
} as Partial<Session>));
|
||||
expect(name).toBe('a1b2c3d4…');
|
||||
});
|
||||
|
||||
it('strips agent prefix from non-UUID keys', () => {
|
||||
const name = sessionDisplayName(makeSession({
|
||||
kind: undefined as unknown as string,
|
||||
channel: '',
|
||||
key: 'agent:bot:mycustomkey',
|
||||
} as Partial<Session>));
|
||||
expect(name).toBe('mycustomkey');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user