Merge pull request 'test: extend test coverage for backend and frontend' (!2) from feature/test-coverage into dev
This commit was merged in pull request #2.
This commit is contained in:
154
frontend/src/lib/components/chat/BranchNavigator.test.ts
Normal file
154
frontend/src/lib/components/chat/BranchNavigator.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* BranchNavigator component tests
|
||||
*
|
||||
* Tests the message branch navigation component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import BranchNavigator from './BranchNavigator.svelte';
|
||||
|
||||
describe('BranchNavigator', () => {
|
||||
const defaultBranchInfo = {
|
||||
currentIndex: 0,
|
||||
totalCount: 3,
|
||||
siblingIds: ['msg-1', 'msg-2', 'msg-3']
|
||||
};
|
||||
|
||||
it('renders with branch info', () => {
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo
|
||||
}
|
||||
});
|
||||
|
||||
// Should show 1/3 (currentIndex + 1)
|
||||
expect(screen.getByText('1/3')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders navigation role', () => {
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo
|
||||
}
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeDefined();
|
||||
expect(nav.getAttribute('aria-label')).toContain('branch navigation');
|
||||
});
|
||||
|
||||
it('has prev and next buttons', () => {
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0].getAttribute('aria-label')).toContain('Previous');
|
||||
expect(buttons[1].getAttribute('aria-label')).toContain('Next');
|
||||
});
|
||||
|
||||
it('calls onSwitch with prev when prev button clicked', async () => {
|
||||
const onSwitch = vi.fn();
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo,
|
||||
onSwitch
|
||||
}
|
||||
});
|
||||
|
||||
const prevButton = screen.getAllByRole('button')[0];
|
||||
await fireEvent.click(prevButton);
|
||||
|
||||
expect(onSwitch).toHaveBeenCalledWith('prev');
|
||||
});
|
||||
|
||||
it('calls onSwitch with next when next button clicked', async () => {
|
||||
const onSwitch = vi.fn();
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo,
|
||||
onSwitch
|
||||
}
|
||||
});
|
||||
|
||||
const nextButton = screen.getAllByRole('button')[1];
|
||||
await fireEvent.click(nextButton);
|
||||
|
||||
expect(onSwitch).toHaveBeenCalledWith('next');
|
||||
});
|
||||
|
||||
it('updates display when currentIndex changes', () => {
|
||||
const { rerender } = render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: { ...defaultBranchInfo, currentIndex: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('2/3')).toBeDefined();
|
||||
|
||||
rerender({
|
||||
branchInfo: { ...defaultBranchInfo, currentIndex: 2 }
|
||||
});
|
||||
|
||||
expect(screen.getByText('3/3')).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles keyboard navigation with left arrow', async () => {
|
||||
const onSwitch = vi.fn();
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo,
|
||||
onSwitch
|
||||
}
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
await fireEvent.keyDown(nav, { key: 'ArrowLeft' });
|
||||
|
||||
expect(onSwitch).toHaveBeenCalledWith('prev');
|
||||
});
|
||||
|
||||
it('handles keyboard navigation with right arrow', async () => {
|
||||
const onSwitch = vi.fn();
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo,
|
||||
onSwitch
|
||||
}
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
await fireEvent.keyDown(nav, { key: 'ArrowRight' });
|
||||
|
||||
expect(onSwitch).toHaveBeenCalledWith('next');
|
||||
});
|
||||
|
||||
it('is focusable for keyboard navigation', () => {
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: defaultBranchInfo
|
||||
}
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
|
||||
it('shows correct count for single message', () => {
|
||||
render(BranchNavigator, {
|
||||
props: {
|
||||
branchInfo: {
|
||||
currentIndex: 0,
|
||||
totalCount: 1,
|
||||
siblingIds: ['msg-1']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('1/1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
121
frontend/src/lib/components/chat/ThinkingBlock.test.ts
Normal file
121
frontend/src/lib/components/chat/ThinkingBlock.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* ThinkingBlock component tests
|
||||
*
|
||||
* Tests the collapsible thinking/reasoning display component
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import ThinkingBlock from './ThinkingBlock.svelte';
|
||||
|
||||
describe('ThinkingBlock', () => {
|
||||
it('renders collapsed by default', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Some thinking content'
|
||||
}
|
||||
});
|
||||
|
||||
// Should show the header
|
||||
expect(screen.getByText('Reasoning')).toBeDefined();
|
||||
// Content should not be visible when collapsed
|
||||
expect(screen.queryByText('Some thinking content')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders expanded when defaultExpanded is true', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Some thinking content',
|
||||
defaultExpanded: true
|
||||
}
|
||||
});
|
||||
|
||||
// Content should be visible when expanded
|
||||
// The content is rendered as HTML, so we check for the container
|
||||
const content = screen.getByText(/Click to collapse/);
|
||||
expect(content).toBeDefined();
|
||||
});
|
||||
|
||||
it('toggles expand/collapse on click', async () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Toggle content'
|
||||
}
|
||||
});
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.getByText('Click to expand')).toBeDefined();
|
||||
|
||||
// Click to expand
|
||||
const button = screen.getByRole('button');
|
||||
await fireEvent.click(button);
|
||||
|
||||
// Should show collapse option
|
||||
expect(screen.getByText('Click to collapse')).toBeDefined();
|
||||
|
||||
// Click to collapse
|
||||
await fireEvent.click(button);
|
||||
|
||||
// Should show expand option again
|
||||
expect(screen.getByText('Click to expand')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows thinking indicator when in progress', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Current thinking...',
|
||||
inProgress: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Thinking...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows reasoning text when not in progress', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Completed thoughts',
|
||||
inProgress: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Reasoning')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows brain emoji when not in progress', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Content',
|
||||
inProgress: false
|
||||
}
|
||||
});
|
||||
|
||||
// The brain emoji is rendered as text
|
||||
const brainEmoji = screen.queryByText('🧠');
|
||||
expect(brainEmoji).toBeDefined();
|
||||
});
|
||||
|
||||
it('has appropriate styling when in progress', () => {
|
||||
const { container } = render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'In progress content',
|
||||
inProgress: true
|
||||
}
|
||||
});
|
||||
|
||||
// Should have ring class for in-progress state
|
||||
const wrapper = container.querySelector('.ring-1');
|
||||
expect(wrapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('button is accessible', () => {
|
||||
render(ThinkingBlock, {
|
||||
props: {
|
||||
content: 'Accessible content'
|
||||
}
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button.getAttribute('type')).toBe('button');
|
||||
});
|
||||
});
|
||||
156
frontend/src/lib/components/shared/ConfirmDialog.test.ts
Normal file
156
frontend/src/lib/components/shared/ConfirmDialog.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* ConfirmDialog component tests
|
||||
*
|
||||
* Tests the confirmation dialog component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn()
|
||||
};
|
||||
|
||||
it('does not render when closed', () => {
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
isOpen: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders when open', () => {
|
||||
render(ConfirmDialog, { props: defaultProps });
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('displays title and message', () => {
|
||||
render(ConfirmDialog, { props: defaultProps });
|
||||
|
||||
expect(screen.getByText('Confirm Action')).toBeDefined();
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses default button text', () => {
|
||||
render(ConfirmDialog, { props: defaultProps });
|
||||
|
||||
expect(screen.getByText('Confirm')).toBeDefined();
|
||||
expect(screen.getByText('Cancel')).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses custom button text', () => {
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Keep'
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Delete')).toBeDefined();
|
||||
expect(screen.getByText('Keep')).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls onConfirm when confirm button clicked', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
onConfirm
|
||||
}
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Confirm');
|
||||
await fireEvent.click(confirmButton);
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button clicked', async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
onCancel
|
||||
}
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
await fireEvent.click(cancelButton);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onCancel when Escape key pressed', async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
onCancel
|
||||
}
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
await fireEvent.keyDown(dialog, { key: 'Escape' });
|
||||
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('has proper aria attributes', () => {
|
||||
render(ConfirmDialog, { props: defaultProps });
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.getAttribute('aria-labelledby')).toBe('confirm-dialog-title');
|
||||
expect(dialog.getAttribute('aria-describedby')).toBe('confirm-dialog-description');
|
||||
});
|
||||
|
||||
describe('variants', () => {
|
||||
it('renders danger variant with red styling', () => {
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
variant: 'danger'
|
||||
}
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Confirm');
|
||||
expect(confirmButton.className).toContain('bg-red-600');
|
||||
});
|
||||
|
||||
it('renders warning variant with amber styling', () => {
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
variant: 'warning'
|
||||
}
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Confirm');
|
||||
expect(confirmButton.className).toContain('bg-amber-600');
|
||||
});
|
||||
|
||||
it('renders info variant with emerald styling', () => {
|
||||
render(ConfirmDialog, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
variant: 'info'
|
||||
}
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText('Confirm');
|
||||
expect(confirmButton.className).toContain('bg-emerald-600');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
frontend/src/lib/components/shared/Skeleton.test.ts
Normal file
67
frontend/src/lib/components/shared/Skeleton.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Skeleton component tests
|
||||
*
|
||||
* Tests the loading placeholder component
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import Skeleton from './Skeleton.svelte';
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default props', () => {
|
||||
render(Skeleton);
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton).toBeDefined();
|
||||
expect(skeleton.getAttribute('aria-label')).toBe('Loading...');
|
||||
});
|
||||
|
||||
it('renders with custom width and height', () => {
|
||||
render(Skeleton, { props: { width: '200px', height: '50px' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.style.width).toBe('200px');
|
||||
expect(skeleton.style.height).toBe('50px');
|
||||
});
|
||||
|
||||
it('renders circular variant', () => {
|
||||
render(Skeleton, { props: { variant: 'circular' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('rounded-full');
|
||||
});
|
||||
|
||||
it('renders rectangular variant', () => {
|
||||
render(Skeleton, { props: { variant: 'rectangular' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('rounded-none');
|
||||
});
|
||||
|
||||
it('renders rounded variant', () => {
|
||||
render(Skeleton, { props: { variant: 'rounded' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('rounded-lg');
|
||||
});
|
||||
|
||||
it('renders text variant by default', () => {
|
||||
render(Skeleton, { props: { variant: 'text' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('rounded');
|
||||
});
|
||||
|
||||
it('renders multiple lines for text variant', () => {
|
||||
render(Skeleton, { props: { variant: 'text', lines: 3 } });
|
||||
const skeletons = screen.getAllByRole('status');
|
||||
expect(skeletons).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('applies custom class', () => {
|
||||
render(Skeleton, { props: { class: 'my-custom-class' } });
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('my-custom-class');
|
||||
});
|
||||
|
||||
it('has animate-pulse class for loading effect', () => {
|
||||
render(Skeleton);
|
||||
const skeleton = screen.getByRole('status');
|
||||
expect(skeleton.className).toContain('animate-pulse');
|
||||
});
|
||||
});
|
||||
243
frontend/src/lib/memory/chunker.test.ts
Normal file
243
frontend/src/lib/memory/chunker.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Chunker tests
|
||||
*
|
||||
* Tests the text chunking utilities for RAG
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
chunkText,
|
||||
splitByParagraphs,
|
||||
splitBySentences,
|
||||
estimateChunkTokens,
|
||||
mergeSmallChunks
|
||||
} from './chunker';
|
||||
import type { DocumentChunk } from './types';
|
||||
|
||||
// Mock crypto.randomUUID for deterministic tests
|
||||
let uuidCounter = 0;
|
||||
beforeEach(() => {
|
||||
uuidCounter = 0;
|
||||
vi.spyOn(crypto, 'randomUUID').mockImplementation(() => `test-uuid-${++uuidCounter}`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('splitByParagraphs', () => {
|
||||
it('splits text by double newlines', () => {
|
||||
const text = 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.';
|
||||
const result = splitByParagraphs(text);
|
||||
|
||||
expect(result).toEqual([
|
||||
'First paragraph.',
|
||||
'Second paragraph.',
|
||||
'Third paragraph.'
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles extra whitespace between paragraphs', () => {
|
||||
const text = 'First.\n\n\n\nSecond.\n \n \nThird.';
|
||||
const result = splitByParagraphs(text);
|
||||
|
||||
expect(result).toEqual(['First.', 'Second.', 'Third.']);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(splitByParagraphs('')).toEqual([]);
|
||||
expect(splitByParagraphs(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single element for text without paragraph breaks', () => {
|
||||
const text = 'Single paragraph with no breaks.';
|
||||
const result = splitByParagraphs(text);
|
||||
|
||||
expect(result).toEqual(['Single paragraph with no breaks.']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitBySentences', () => {
|
||||
it('splits by periods', () => {
|
||||
const text = 'First sentence. Second sentence. Third sentence.';
|
||||
const result = splitBySentences(text);
|
||||
|
||||
expect(result).toEqual([
|
||||
'First sentence.',
|
||||
'Second sentence.',
|
||||
'Third sentence.'
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits by exclamation marks', () => {
|
||||
const text = 'Wow! That is amazing! Really!';
|
||||
const result = splitBySentences(text);
|
||||
|
||||
expect(result).toEqual(['Wow!', 'That is amazing!', 'Really!']);
|
||||
});
|
||||
|
||||
it('splits by question marks', () => {
|
||||
const text = 'Is this working? Are you sure? Yes.';
|
||||
const result = splitBySentences(text);
|
||||
|
||||
expect(result).toEqual(['Is this working?', 'Are you sure?', 'Yes.']);
|
||||
});
|
||||
|
||||
it('handles mixed punctuation', () => {
|
||||
const text = 'Hello. How are you? Great! Thanks.';
|
||||
const result = splitBySentences(text);
|
||||
|
||||
expect(result).toEqual(['Hello.', 'How are you?', 'Great!', 'Thanks.']);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(splitBySentences('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateChunkTokens', () => {
|
||||
it('estimates roughly 4 characters per token', () => {
|
||||
// 100 characters should be ~25 tokens
|
||||
const text = 'a'.repeat(100);
|
||||
expect(estimateChunkTokens(text)).toBe(25);
|
||||
});
|
||||
|
||||
it('rounds up for partial tokens', () => {
|
||||
// 10 characters = 2.5 tokens, rounds to 3
|
||||
const text = 'a'.repeat(10);
|
||||
expect(estimateChunkTokens(text)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(estimateChunkTokens('')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkText', () => {
|
||||
const DOC_ID = 'test-doc';
|
||||
|
||||
it('returns empty array for empty text', () => {
|
||||
expect(chunkText('', DOC_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single chunk for short text', () => {
|
||||
const text = 'Short text that fits in one chunk.';
|
||||
const result = chunkText(text, DOC_ID, { chunkSize: 512 });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe(text);
|
||||
expect(result[0].documentId).toBe(DOC_ID);
|
||||
expect(result[0].startIndex).toBe(0);
|
||||
expect(result[0].endIndex).toBe(text.length);
|
||||
});
|
||||
|
||||
it('splits long text into multiple chunks', () => {
|
||||
// Create text longer than chunk size
|
||||
const text = 'This is sentence one. '.repeat(50);
|
||||
const result = chunkText(text, DOC_ID, { chunkSize: 200, overlap: 20 });
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
|
||||
// Each chunk should be roughly chunk size (allowing for break points)
|
||||
for (const chunk of result) {
|
||||
expect(chunk.content.length).toBeLessThanOrEqual(250); // Some flexibility for break points
|
||||
expect(chunk.documentId).toBe(DOC_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects sentence boundaries when enabled', () => {
|
||||
const text = 'First sentence here. Second sentence here. Third sentence here. Fourth sentence here.';
|
||||
const result = chunkText(text, DOC_ID, {
|
||||
chunkSize: 50,
|
||||
overlap: 10,
|
||||
respectSentences: true
|
||||
});
|
||||
|
||||
// Chunks should not split mid-sentence
|
||||
for (const chunk of result) {
|
||||
// Each chunk should end with punctuation or be the last chunk
|
||||
const endsWithPunctuation = /[.!?]$/.test(chunk.content);
|
||||
const isLastChunk = chunk === result[result.length - 1];
|
||||
expect(endsWithPunctuation || isLastChunk).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates chunks with correct indices', () => {
|
||||
const text = 'A'.repeat(100) + ' ' + 'B'.repeat(100);
|
||||
const result = chunkText(text, DOC_ID, { chunkSize: 100, overlap: 10 });
|
||||
|
||||
// Verify indices are valid
|
||||
for (const chunk of result) {
|
||||
expect(chunk.startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(chunk.endIndex).toBeLessThanOrEqual(text.length);
|
||||
expect(chunk.startIndex).toBeLessThan(chunk.endIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates unique IDs for each chunk', () => {
|
||||
const text = 'Sentence one. Sentence two. Sentence three. Sentence four. Sentence five.';
|
||||
const result = chunkText(text, DOC_ID, { chunkSize: 30, overlap: 5 });
|
||||
|
||||
const ids = result.map(c => c.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSmallChunks', () => {
|
||||
function makeChunk(content: string, startIndex: number = 0): DocumentChunk {
|
||||
return {
|
||||
id: `chunk-${content.slice(0, 10)}`,
|
||||
documentId: 'doc-1',
|
||||
content,
|
||||
startIndex,
|
||||
endIndex: startIndex + content.length
|
||||
};
|
||||
}
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(mergeSmallChunks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single chunk unchanged', () => {
|
||||
const chunks = [makeChunk('Single chunk content.')];
|
||||
const result = mergeSmallChunks(chunks);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('Single chunk content.');
|
||||
});
|
||||
|
||||
it('merges adjacent small chunks', () => {
|
||||
const chunks = [
|
||||
makeChunk('Small.', 0),
|
||||
makeChunk('Also small.', 10)
|
||||
];
|
||||
const result = mergeSmallChunks(chunks, 200);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('Small.\n\nAlso small.');
|
||||
});
|
||||
|
||||
it('does not merge chunks that exceed minSize together', () => {
|
||||
const chunks = [
|
||||
makeChunk('A'.repeat(100), 0),
|
||||
makeChunk('B'.repeat(100), 100)
|
||||
];
|
||||
const result = mergeSmallChunks(chunks, 150);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves startIndex from first chunk and endIndex from last when merging', () => {
|
||||
const chunks = [
|
||||
makeChunk('First chunk.', 0),
|
||||
makeChunk('Second chunk.', 15)
|
||||
];
|
||||
const result = mergeSmallChunks(chunks, 200);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].startIndex).toBe(0);
|
||||
expect(result[0].endIndex).toBe(15 + 'Second chunk.'.length);
|
||||
});
|
||||
});
|
||||
194
frontend/src/lib/memory/embeddings.test.ts
Normal file
194
frontend/src/lib/memory/embeddings.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Embeddings utility tests
|
||||
*
|
||||
* Tests the pure vector math functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cosineSimilarity,
|
||||
findSimilar,
|
||||
normalizeVector,
|
||||
getEmbeddingDimension
|
||||
} from './embeddings';
|
||||
|
||||
describe('cosineSimilarity', () => {
|
||||
it('returns 1 for identical vectors', () => {
|
||||
const v = [1, 2, 3];
|
||||
expect(cosineSimilarity(v, v)).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it('returns -1 for opposite vectors', () => {
|
||||
const a = [1, 2, 3];
|
||||
const b = [-1, -2, -3];
|
||||
expect(cosineSimilarity(a, b)).toBeCloseTo(-1, 10);
|
||||
});
|
||||
|
||||
it('returns 0 for orthogonal vectors', () => {
|
||||
const a = [1, 0];
|
||||
const b = [0, 1];
|
||||
expect(cosineSimilarity(a, b)).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it('handles normalized vectors', () => {
|
||||
const a = [0.6, 0.8];
|
||||
const b = [0.8, 0.6];
|
||||
const sim = cosineSimilarity(a, b);
|
||||
expect(sim).toBeGreaterThan(0);
|
||||
expect(sim).toBeLessThan(1);
|
||||
expect(sim).toBeCloseTo(0.96, 2);
|
||||
});
|
||||
|
||||
it('throws for mismatched dimensions', () => {
|
||||
const a = [1, 2, 3];
|
||||
const b = [1, 2];
|
||||
expect(() => cosineSimilarity(a, b)).toThrow("Vector dimensions don't match");
|
||||
});
|
||||
|
||||
it('returns 0 for zero vectors', () => {
|
||||
const a = [0, 0, 0];
|
||||
const b = [1, 2, 3];
|
||||
expect(cosineSimilarity(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles large vectors', () => {
|
||||
const size = 768;
|
||||
const a = Array(size)
|
||||
.fill(0)
|
||||
.map(() => Math.random());
|
||||
const b = Array(size)
|
||||
.fill(0)
|
||||
.map(() => Math.random());
|
||||
const sim = cosineSimilarity(a, b);
|
||||
expect(sim).toBeGreaterThanOrEqual(-1);
|
||||
expect(sim).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeVector', () => {
|
||||
it('converts to unit vector', () => {
|
||||
const v = [3, 4];
|
||||
const normalized = normalizeVector(v);
|
||||
|
||||
// Check it's a unit vector
|
||||
const magnitude = Math.sqrt(normalized.reduce((sum, x) => sum + x * x, 0));
|
||||
expect(magnitude).toBeCloseTo(1, 10);
|
||||
});
|
||||
|
||||
it('preserves direction', () => {
|
||||
const v = [3, 4];
|
||||
const normalized = normalizeVector(v);
|
||||
|
||||
expect(normalized[0]).toBeCloseTo(0.6, 10);
|
||||
expect(normalized[1]).toBeCloseTo(0.8, 10);
|
||||
});
|
||||
|
||||
it('handles zero vector', () => {
|
||||
const v = [0, 0, 0];
|
||||
const normalized = normalizeVector(v);
|
||||
|
||||
expect(normalized).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('handles already-normalized vector', () => {
|
||||
const v = [0.6, 0.8];
|
||||
const normalized = normalizeVector(v);
|
||||
|
||||
expect(normalized[0]).toBeCloseTo(0.6, 10);
|
||||
expect(normalized[1]).toBeCloseTo(0.8, 10);
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
const v = [-3, 4];
|
||||
const normalized = normalizeVector(v);
|
||||
|
||||
expect(normalized[0]).toBeCloseTo(-0.6, 10);
|
||||
expect(normalized[1]).toBeCloseTo(0.8, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSimilar', () => {
|
||||
const candidates = [
|
||||
{ id: 1, embedding: [1, 0, 0] },
|
||||
{ id: 2, embedding: [0.9, 0.1, 0] },
|
||||
{ id: 3, embedding: [0, 1, 0] },
|
||||
{ id: 4, embedding: [0, 0, 1] },
|
||||
{ id: 5, embedding: [-1, 0, 0] }
|
||||
];
|
||||
|
||||
it('returns most similar items', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 3, 0);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].id).toBe(1); // Exact match
|
||||
expect(results[1].id).toBe(2); // Very similar
|
||||
expect(results[0].similarity).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('respects threshold', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 10, 0.8);
|
||||
|
||||
// Only items with similarity >= 0.8
|
||||
expect(results.every((r) => r.similarity >= 0.8)).toBe(true);
|
||||
});
|
||||
|
||||
it('respects topK limit', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 2, 0);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns empty array for no matches above threshold', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 10, 0.999);
|
||||
|
||||
// Only exact match should pass 0.999 threshold
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty candidates', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, [], 5, 0);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts by similarity descending', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 5, -1);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].similarity).toBeGreaterThanOrEqual(results[i].similarity);
|
||||
}
|
||||
});
|
||||
|
||||
it('adds similarity property to results', () => {
|
||||
const query = [1, 0, 0];
|
||||
const results = findSimilar(query, candidates, 1, 0);
|
||||
|
||||
expect(results[0]).toHaveProperty('similarity');
|
||||
expect(typeof results[0].similarity).toBe('number');
|
||||
expect(results[0]).toHaveProperty('id');
|
||||
expect(results[0]).toHaveProperty('embedding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmbeddingDimension', () => {
|
||||
it('returns correct dimensions for known models', () => {
|
||||
expect(getEmbeddingDimension('nomic-embed-text')).toBe(768);
|
||||
expect(getEmbeddingDimension('mxbai-embed-large')).toBe(1024);
|
||||
expect(getEmbeddingDimension('all-minilm')).toBe(384);
|
||||
expect(getEmbeddingDimension('snowflake-arctic-embed')).toBe(1024);
|
||||
expect(getEmbeddingDimension('embeddinggemma:latest')).toBe(768);
|
||||
expect(getEmbeddingDimension('embeddinggemma')).toBe(768);
|
||||
});
|
||||
|
||||
it('returns default 768 for unknown models', () => {
|
||||
expect(getEmbeddingDimension('unknown-model')).toBe(768);
|
||||
expect(getEmbeddingDimension('')).toBe(768);
|
||||
expect(getEmbeddingDimension('custom-embed-model')).toBe(768);
|
||||
});
|
||||
});
|
||||
187
frontend/src/lib/memory/model-limits.test.ts
Normal file
187
frontend/src/lib/memory/model-limits.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Model limits tests
|
||||
*
|
||||
* Tests model context window detection and capability checks
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getModelContextLimit,
|
||||
modelSupportsTools,
|
||||
modelSupportsVision,
|
||||
formatContextSize
|
||||
} from './model-limits';
|
||||
|
||||
describe('getModelContextLimit', () => {
|
||||
describe('Llama models', () => {
|
||||
it('returns 128K for llama 3.2', () => {
|
||||
expect(getModelContextLimit('llama3.2:8b')).toBe(128000);
|
||||
expect(getModelContextLimit('llama-3.2:70b')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 128K for llama 3.1', () => {
|
||||
expect(getModelContextLimit('llama3.1:8b')).toBe(128000);
|
||||
expect(getModelContextLimit('llama-3.1:405b')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 8K for llama 3 base', () => {
|
||||
expect(getModelContextLimit('llama3:8b')).toBe(8192);
|
||||
expect(getModelContextLimit('llama-3:70b')).toBe(8192);
|
||||
});
|
||||
|
||||
it('returns 4K for llama 2', () => {
|
||||
expect(getModelContextLimit('llama2:7b')).toBe(4096);
|
||||
expect(getModelContextLimit('llama-2:13b')).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mistral models', () => {
|
||||
it('returns 128K for mistral-large', () => {
|
||||
expect(getModelContextLimit('mistral-large:latest')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 128K for mistral nemo', () => {
|
||||
expect(getModelContextLimit('mistral-nemo:12b')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 32K for base mistral', () => {
|
||||
expect(getModelContextLimit('mistral:7b')).toBe(32000);
|
||||
expect(getModelContextLimit('mistral:latest')).toBe(32000);
|
||||
});
|
||||
|
||||
it('returns 32K for mixtral', () => {
|
||||
expect(getModelContextLimit('mixtral:8x7b')).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Qwen models', () => {
|
||||
it('returns 128K for qwen 2.5', () => {
|
||||
expect(getModelContextLimit('qwen2.5:7b')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 32K for qwen 2', () => {
|
||||
expect(getModelContextLimit('qwen2:7b')).toBe(32000);
|
||||
});
|
||||
|
||||
it('returns 8K for older qwen', () => {
|
||||
expect(getModelContextLimit('qwen:14b')).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other models', () => {
|
||||
it('returns 128K for phi-3', () => {
|
||||
expect(getModelContextLimit('phi-3:mini')).toBe(128000);
|
||||
});
|
||||
|
||||
it('returns 16K for codellama', () => {
|
||||
expect(getModelContextLimit('codellama:34b')).toBe(16384);
|
||||
});
|
||||
|
||||
it('returns 200K for yi models', () => {
|
||||
expect(getModelContextLimit('yi:34b')).toBe(200000);
|
||||
});
|
||||
|
||||
it('returns 4K for llava vision models', () => {
|
||||
expect(getModelContextLimit('llava:7b')).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default fallback', () => {
|
||||
it('returns 4K for unknown models', () => {
|
||||
expect(getModelContextLimit('unknown-model:latest')).toBe(4096);
|
||||
expect(getModelContextLimit('custom-finetune')).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(getModelContextLimit('LLAMA3.1:8B')).toBe(128000);
|
||||
expect(getModelContextLimit('Mistral:Latest')).toBe(32000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelSupportsTools', () => {
|
||||
it('returns true for llama 3.1+', () => {
|
||||
expect(modelSupportsTools('llama3.1:8b')).toBe(true);
|
||||
expect(modelSupportsTools('llama3.2:3b')).toBe(true);
|
||||
expect(modelSupportsTools('llama-3.1:70b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mistral with tool support', () => {
|
||||
expect(modelSupportsTools('mistral:7b')).toBe(true);
|
||||
expect(modelSupportsTools('mistral-large:latest')).toBe(true);
|
||||
expect(modelSupportsTools('mistral-nemo:12b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mixtral', () => {
|
||||
expect(modelSupportsTools('mixtral:8x7b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for qwen2', () => {
|
||||
expect(modelSupportsTools('qwen2:7b')).toBe(true);
|
||||
expect(modelSupportsTools('qwen2.5:14b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for command-r', () => {
|
||||
expect(modelSupportsTools('command-r:latest')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for deepseek', () => {
|
||||
expect(modelSupportsTools('deepseek-coder:6.7b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for llama 3 base (no tools)', () => {
|
||||
expect(modelSupportsTools('llama3:8b')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for older models', () => {
|
||||
expect(modelSupportsTools('llama2:7b')).toBe(false);
|
||||
expect(modelSupportsTools('vicuna:13b')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelSupportsVision', () => {
|
||||
it('returns true for llava models', () => {
|
||||
expect(modelSupportsVision('llava:7b')).toBe(true);
|
||||
expect(modelSupportsVision('llava:13b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for bakllava', () => {
|
||||
expect(modelSupportsVision('bakllava:7b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for llama 3.2 vision', () => {
|
||||
expect(modelSupportsVision('llama3.2-vision:11b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for moondream', () => {
|
||||
expect(modelSupportsVision('moondream:1.8b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for text-only models', () => {
|
||||
expect(modelSupportsVision('llama3:8b')).toBe(false);
|
||||
expect(modelSupportsVision('mistral:7b')).toBe(false);
|
||||
expect(modelSupportsVision('codellama:34b')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatContextSize', () => {
|
||||
it('formats large numbers with K suffix', () => {
|
||||
expect(formatContextSize(128000)).toBe('128K');
|
||||
expect(formatContextSize(100000)).toBe('100K');
|
||||
});
|
||||
|
||||
it('formats medium numbers with K suffix', () => {
|
||||
expect(formatContextSize(32000)).toBe('32K');
|
||||
expect(formatContextSize(8192)).toBe('8K');
|
||||
expect(formatContextSize(4096)).toBe('4K');
|
||||
});
|
||||
|
||||
it('formats small numbers without suffix', () => {
|
||||
expect(formatContextSize(512)).toBe('512');
|
||||
expect(formatContextSize(100)).toBe('100');
|
||||
});
|
||||
|
||||
it('rounds large numbers', () => {
|
||||
expect(formatContextSize(128000)).toBe('128K');
|
||||
});
|
||||
});
|
||||
214
frontend/src/lib/memory/summarizer.test.ts
Normal file
214
frontend/src/lib/memory/summarizer.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Summarizer utility tests
|
||||
*
|
||||
* Tests the pure functions for conversation summarization
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
selectMessagesForSummarization,
|
||||
calculateTokenSavings,
|
||||
createSummaryRecord,
|
||||
shouldSummarize,
|
||||
formatSummaryAsContext
|
||||
} from './summarizer';
|
||||
import type { MessageNode } from '$lib/types/chat';
|
||||
|
||||
// Helper to create message nodes
|
||||
function createMessageNode(
|
||||
role: 'user' | 'assistant' | 'system',
|
||||
content: string,
|
||||
id?: string
|
||||
): MessageNode {
|
||||
return {
|
||||
id: id || crypto.randomUUID(),
|
||||
parentId: null,
|
||||
siblingIds: [],
|
||||
message: {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectMessagesForSummarization', () => {
|
||||
it('returns empty toSummarize when messages <= preserveCount', () => {
|
||||
const messages = [
|
||||
createMessageNode('user', 'Hi'),
|
||||
createMessageNode('assistant', 'Hello'),
|
||||
createMessageNode('user', 'How are you?'),
|
||||
createMessageNode('assistant', 'Good')
|
||||
];
|
||||
|
||||
const result = selectMessagesForSummarization(messages, 1000, 4);
|
||||
|
||||
expect(result.toSummarize).toHaveLength(0);
|
||||
expect(result.toKeep).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('keeps recent messages and marks older for summarization', () => {
|
||||
const messages = [
|
||||
createMessageNode('user', 'Message 1'),
|
||||
createMessageNode('assistant', 'Response 1'),
|
||||
createMessageNode('user', 'Message 2'),
|
||||
createMessageNode('assistant', 'Response 2'),
|
||||
createMessageNode('user', 'Message 3'),
|
||||
createMessageNode('assistant', 'Response 3'),
|
||||
createMessageNode('user', 'Message 4'),
|
||||
createMessageNode('assistant', 'Response 4')
|
||||
];
|
||||
|
||||
const result = selectMessagesForSummarization(messages, 1000, 4);
|
||||
|
||||
expect(result.toSummarize).toHaveLength(4);
|
||||
expect(result.toKeep).toHaveLength(4);
|
||||
expect(result.toSummarize[0].message.content).toBe('Message 1');
|
||||
expect(result.toKeep[0].message.content).toBe('Message 3');
|
||||
});
|
||||
|
||||
it('preserves system messages in toKeep', () => {
|
||||
const messages = [
|
||||
createMessageNode('system', 'System prompt'),
|
||||
createMessageNode('user', 'Message 1'),
|
||||
createMessageNode('assistant', 'Response 1'),
|
||||
createMessageNode('user', 'Message 2'),
|
||||
createMessageNode('assistant', 'Response 2'),
|
||||
createMessageNode('user', 'Message 3'),
|
||||
createMessageNode('assistant', 'Response 3')
|
||||
];
|
||||
|
||||
const result = selectMessagesForSummarization(messages, 1000, 4);
|
||||
|
||||
// System message should be in toKeep even though it's at the start
|
||||
expect(result.toKeep.some((m) => m.message.role === 'system')).toBe(true);
|
||||
expect(result.toSummarize.every((m) => m.message.role !== 'system')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses default preserveCount of 4', () => {
|
||||
const messages = [
|
||||
createMessageNode('user', 'M1'),
|
||||
createMessageNode('assistant', 'R1'),
|
||||
createMessageNode('user', 'M2'),
|
||||
createMessageNode('assistant', 'R2'),
|
||||
createMessageNode('user', 'M3'),
|
||||
createMessageNode('assistant', 'R3'),
|
||||
createMessageNode('user', 'M4'),
|
||||
createMessageNode('assistant', 'R4')
|
||||
];
|
||||
|
||||
const result = selectMessagesForSummarization(messages, 1000);
|
||||
|
||||
expect(result.toKeep).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('handles empty messages array', () => {
|
||||
const result = selectMessagesForSummarization([], 1000);
|
||||
|
||||
expect(result.toSummarize).toHaveLength(0);
|
||||
expect(result.toKeep).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single message', () => {
|
||||
const messages = [createMessageNode('user', 'Only message')];
|
||||
|
||||
const result = selectMessagesForSummarization(messages, 1000, 4);
|
||||
|
||||
expect(result.toSummarize).toHaveLength(0);
|
||||
expect(result.toKeep).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTokenSavings', () => {
|
||||
it('calculates positive savings for longer original', () => {
|
||||
const originalMessages = [
|
||||
createMessageNode('user', 'This is a longer message with more words'),
|
||||
createMessageNode('assistant', 'This is also a longer response with content')
|
||||
];
|
||||
|
||||
const shortSummary = 'Brief summary.';
|
||||
const savings = calculateTokenSavings(originalMessages, shortSummary);
|
||||
|
||||
expect(savings).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns zero when summary is longer', () => {
|
||||
const originalMessages = [createMessageNode('user', 'Hi')];
|
||||
|
||||
const longSummary =
|
||||
'This is a very long summary that is much longer than the original message which was just a simple greeting.';
|
||||
const savings = calculateTokenSavings(originalMessages, longSummary);
|
||||
|
||||
expect(savings).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSummaryRecord', () => {
|
||||
it('creates a valid summary record', () => {
|
||||
const record = createSummaryRecord('conv-123', 'This is the summary', 10, 500);
|
||||
|
||||
expect(record.id).toBeDefined();
|
||||
expect(record.conversationId).toBe('conv-123');
|
||||
expect(record.summary).toBe('This is the summary');
|
||||
expect(record.originalMessageCount).toBe(10);
|
||||
expect(record.tokensSaved).toBe(500);
|
||||
expect(record.summarizedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('generates unique IDs', () => {
|
||||
const record1 = createSummaryRecord('conv-1', 'Summary 1', 5, 100);
|
||||
const record2 = createSummaryRecord('conv-2', 'Summary 2', 5, 100);
|
||||
|
||||
expect(record1.id).not.toBe(record2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSummarize', () => {
|
||||
it('returns false when message count is too low', () => {
|
||||
expect(shouldSummarize(8000, 10000, 4)).toBe(false);
|
||||
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when summarizable messages are too few', () => {
|
||||
// 6 messages total - 4 preserved = 2 to summarize (minimum)
|
||||
// But with < 6 total messages, should return false
|
||||
expect(shouldSummarize(8000, 10000, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when usage is high and enough messages', () => {
|
||||
// 8000/10000 = 80%
|
||||
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
|
||||
expect(shouldSummarize(9000, 10000, 8)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when usage is below 80%', () => {
|
||||
expect(shouldSummarize(7000, 10000, 10)).toBe(false);
|
||||
expect(shouldSummarize(5000, 10000, 20)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true at exactly 80%', () => {
|
||||
expect(shouldSummarize(8000, 10000, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSummaryAsContext', () => {
|
||||
it('formats summary as context prefix', () => {
|
||||
const result = formatSummaryAsContext('User asked about weather');
|
||||
|
||||
expect(result).toBe('[Previous conversation summary: User asked about weather]');
|
||||
});
|
||||
|
||||
it('handles empty summary', () => {
|
||||
const result = formatSummaryAsContext('');
|
||||
|
||||
expect(result).toBe('[Previous conversation summary: ]');
|
||||
});
|
||||
|
||||
it('preserves special characters in summary', () => {
|
||||
const result = formatSummaryAsContext('User said "hello" & asked about <code>');
|
||||
|
||||
expect(result).toContain('"hello"');
|
||||
expect(result).toContain('&');
|
||||
expect(result).toContain('<code>');
|
||||
});
|
||||
});
|
||||
191
frontend/src/lib/memory/tokenizer.test.ts
Normal file
191
frontend/src/lib/memory/tokenizer.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tokenizer utility tests
|
||||
*
|
||||
* Tests token estimation heuristics and formatting
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
estimateTokensFromChars,
|
||||
estimateTokensFromWords,
|
||||
estimateTokens,
|
||||
estimateImageTokens,
|
||||
estimateMessageTokens,
|
||||
estimateFormatOverhead,
|
||||
estimateConversationTokens,
|
||||
formatTokenCount
|
||||
} from './tokenizer';
|
||||
|
||||
describe('estimateTokensFromChars', () => {
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(estimateTokensFromChars('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for null/undefined', () => {
|
||||
expect(estimateTokensFromChars(null as unknown as string)).toBe(0);
|
||||
expect(estimateTokensFromChars(undefined as unknown as string)).toBe(0);
|
||||
});
|
||||
|
||||
it('estimates tokens for short text', () => {
|
||||
// ~3.7 chars per token, so 10 chars ≈ 3 tokens
|
||||
const result = estimateTokensFromChars('hello worl');
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('estimates tokens for longer text', () => {
|
||||
// 100 chars / 3.7 = 27.027, rounds up to 28
|
||||
const text = 'a'.repeat(100);
|
||||
expect(estimateTokensFromChars(text)).toBe(28);
|
||||
});
|
||||
|
||||
it('rounds up partial tokens', () => {
|
||||
// 1 char / 3.7 = 0.27, should round to 1
|
||||
expect(estimateTokensFromChars('a')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateTokensFromWords', () => {
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(estimateTokensFromWords('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for null/undefined', () => {
|
||||
expect(estimateTokensFromWords(null as unknown as string)).toBe(0);
|
||||
});
|
||||
|
||||
it('estimates tokens for single word', () => {
|
||||
// 1 word * 1.3 = 1.3, rounds to 2
|
||||
expect(estimateTokensFromWords('hello')).toBe(2);
|
||||
});
|
||||
|
||||
it('estimates tokens for multiple words', () => {
|
||||
// 5 words * 1.3 = 6.5, rounds to 7
|
||||
expect(estimateTokensFromWords('the quick brown fox jumps')).toBe(7);
|
||||
});
|
||||
|
||||
it('handles multiple spaces between words', () => {
|
||||
expect(estimateTokensFromWords('hello world')).toBe(3); // 2 words * 1.3
|
||||
});
|
||||
|
||||
it('handles leading/trailing whitespace', () => {
|
||||
expect(estimateTokensFromWords(' hello world ')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateTokens', () => {
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(estimateTokens('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns weighted average of char and word estimates', () => {
|
||||
// For "hello world" (11 chars, 2 words):
|
||||
// charEstimate: 11 / 3.7 ≈ 3
|
||||
// wordEstimate: 2 * 1.3 ≈ 3
|
||||
// hybrid: (3 * 0.6 + 3 * 0.4) = 3
|
||||
const result = estimateTokens('hello world');
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles code with special characters', () => {
|
||||
const code = 'function test() { return 42; }';
|
||||
const result = estimateTokens(code);
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateImageTokens', () => {
|
||||
it('returns 0 for no images', () => {
|
||||
expect(estimateImageTokens(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 765 tokens per image', () => {
|
||||
expect(estimateImageTokens(1)).toBe(765);
|
||||
expect(estimateImageTokens(2)).toBe(1530);
|
||||
expect(estimateImageTokens(5)).toBe(3825);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateMessageTokens', () => {
|
||||
it('handles text-only message', () => {
|
||||
const result = estimateMessageTokens('hello world');
|
||||
expect(result.textTokens).toBeGreaterThan(0);
|
||||
expect(result.imageTokens).toBe(0);
|
||||
expect(result.totalTokens).toBe(result.textTokens);
|
||||
});
|
||||
|
||||
it('handles message with images', () => {
|
||||
const result = estimateMessageTokens('hello', ['base64img1', 'base64img2']);
|
||||
expect(result.textTokens).toBeGreaterThan(0);
|
||||
expect(result.imageTokens).toBe(1530); // 2 * 765
|
||||
expect(result.totalTokens).toBe(result.textTokens + result.imageTokens);
|
||||
});
|
||||
|
||||
it('handles undefined images', () => {
|
||||
const result = estimateMessageTokens('hello', undefined);
|
||||
expect(result.imageTokens).toBe(0);
|
||||
});
|
||||
|
||||
it('handles empty images array', () => {
|
||||
const result = estimateMessageTokens('hello', []);
|
||||
expect(result.imageTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateFormatOverhead', () => {
|
||||
it('returns 0 for no messages', () => {
|
||||
expect(estimateFormatOverhead(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 4 tokens per message', () => {
|
||||
expect(estimateFormatOverhead(1)).toBe(4);
|
||||
expect(estimateFormatOverhead(5)).toBe(20);
|
||||
expect(estimateFormatOverhead(10)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateConversationTokens', () => {
|
||||
it('returns 0 for empty conversation', () => {
|
||||
expect(estimateConversationTokens([])).toBe(0);
|
||||
});
|
||||
|
||||
it('sums tokens across messages plus overhead', () => {
|
||||
const messages = [
|
||||
{ content: 'hello' },
|
||||
{ content: 'world' }
|
||||
];
|
||||
const result = estimateConversationTokens(messages);
|
||||
// Should include text tokens for both messages + 8 format overhead
|
||||
expect(result).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it('includes image tokens', () => {
|
||||
const messagesWithoutImages = [{ content: 'hello' }];
|
||||
const messagesWithImages = [{ content: 'hello', images: ['img1'] }];
|
||||
|
||||
const withoutImages = estimateConversationTokens(messagesWithoutImages);
|
||||
const withImages = estimateConversationTokens(messagesWithImages);
|
||||
|
||||
expect(withImages).toBe(withoutImages + 765);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTokenCount', () => {
|
||||
it('formats small numbers as-is', () => {
|
||||
expect(formatTokenCount(0)).toBe('0');
|
||||
expect(formatTokenCount(100)).toBe('100');
|
||||
expect(formatTokenCount(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('formats thousands with K and one decimal', () => {
|
||||
expect(formatTokenCount(1000)).toBe('1.0K');
|
||||
expect(formatTokenCount(1500)).toBe('1.5K');
|
||||
expect(formatTokenCount(2350)).toBe('2.4K'); // rounds
|
||||
expect(formatTokenCount(9999)).toBe('10.0K');
|
||||
});
|
||||
|
||||
it('formats large numbers with K and no decimal', () => {
|
||||
expect(formatTokenCount(10000)).toBe('10K');
|
||||
expect(formatTokenCount(50000)).toBe('50K');
|
||||
expect(formatTokenCount(128000)).toBe('128K');
|
||||
});
|
||||
});
|
||||
127
frontend/src/lib/memory/vector-store.test.ts
Normal file
127
frontend/src/lib/memory/vector-store.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Vector store utility tests
|
||||
*
|
||||
* Tests the pure utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatResultsAsContext } from './vector-store';
|
||||
import type { SearchResult } from './vector-store';
|
||||
import type { StoredChunk, StoredDocument } from '$lib/storage/db';
|
||||
|
||||
// Helper to create mock search results
|
||||
function createSearchResult(
|
||||
documentName: string,
|
||||
chunkContent: string,
|
||||
similarity: number
|
||||
): SearchResult {
|
||||
const doc: StoredDocument = {
|
||||
id: 'doc-' + Math.random().toString(36).slice(2),
|
||||
name: documentName,
|
||||
mimeType: 'text/plain',
|
||||
size: chunkContent.length,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
chunkCount: 1,
|
||||
embeddingModel: 'nomic-embed-text',
|
||||
projectId: null,
|
||||
embeddingStatus: 'ready'
|
||||
};
|
||||
|
||||
const chunk: StoredChunk = {
|
||||
id: 'chunk-' + Math.random().toString(36).slice(2),
|
||||
documentId: doc.id,
|
||||
content: chunkContent,
|
||||
embedding: [],
|
||||
startIndex: 0,
|
||||
endIndex: chunkContent.length,
|
||||
tokenCount: Math.ceil(chunkContent.split(' ').length * 1.3)
|
||||
};
|
||||
|
||||
return { chunk, document: doc, similarity };
|
||||
}
|
||||
|
||||
describe('formatResultsAsContext', () => {
|
||||
it('formats single result correctly', () => {
|
||||
const results = [createSearchResult('README.md', 'This is the content.', 0.9)];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('Relevant context from knowledge base:');
|
||||
expect(context).toContain('[Source 1: README.md]');
|
||||
expect(context).toContain('This is the content.');
|
||||
});
|
||||
|
||||
it('formats multiple results with separators', () => {
|
||||
const results = [
|
||||
createSearchResult('doc1.txt', 'First document content', 0.95),
|
||||
createSearchResult('doc2.txt', 'Second document content', 0.85),
|
||||
createSearchResult('doc3.txt', 'Third document content', 0.75)
|
||||
];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('[Source 1: doc1.txt]');
|
||||
expect(context).toContain('[Source 2: doc2.txt]');
|
||||
expect(context).toContain('[Source 3: doc3.txt]');
|
||||
expect(context).toContain('First document content');
|
||||
expect(context).toContain('Second document content');
|
||||
expect(context).toContain('Third document content');
|
||||
// Check for separators between results
|
||||
expect(context.split('---').length).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty string for empty results', () => {
|
||||
const context = formatResultsAsContext([]);
|
||||
|
||||
expect(context).toBe('');
|
||||
});
|
||||
|
||||
it('preserves special characters in content', () => {
|
||||
const results = [
|
||||
createSearchResult('code.js', 'function test() { return "hello"; }', 0.9)
|
||||
];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('function test() { return "hello"; }');
|
||||
});
|
||||
|
||||
it('includes document names in source references', () => {
|
||||
const results = [
|
||||
createSearchResult('path/to/file.md', 'Some content', 0.9)
|
||||
];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('[Source 1: path/to/file.md]');
|
||||
});
|
||||
|
||||
it('numbers sources sequentially', () => {
|
||||
const results = [
|
||||
createSearchResult('a.txt', 'Content A', 0.9),
|
||||
createSearchResult('b.txt', 'Content B', 0.8),
|
||||
createSearchResult('c.txt', 'Content C', 0.7),
|
||||
createSearchResult('d.txt', 'Content D', 0.6),
|
||||
createSearchResult('e.txt', 'Content E', 0.5)
|
||||
];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('[Source 1: a.txt]');
|
||||
expect(context).toContain('[Source 2: b.txt]');
|
||||
expect(context).toContain('[Source 3: c.txt]');
|
||||
expect(context).toContain('[Source 4: d.txt]');
|
||||
expect(context).toContain('[Source 5: e.txt]');
|
||||
});
|
||||
|
||||
it('handles multiline content', () => {
|
||||
const results = [
|
||||
createSearchResult('notes.txt', 'Line 1\nLine 2\nLine 3', 0.9)
|
||||
];
|
||||
|
||||
const context = formatResultsAsContext(results);
|
||||
|
||||
expect(context).toContain('Line 1\nLine 2\nLine 3');
|
||||
});
|
||||
});
|
||||
376
frontend/src/lib/ollama/client.test.ts
Normal file
376
frontend/src/lib/ollama/client.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* OllamaClient tests
|
||||
*
|
||||
* Tests the Ollama API client with mocked fetch
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OllamaClient } from './client';
|
||||
|
||||
// Helper to create mock fetch response
|
||||
function mockResponse(data: unknown, status = 200, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Error',
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
clone: () => mockResponse(data, status, ok)
|
||||
} as Response;
|
||||
}
|
||||
|
||||
// Helper to create streaming response
|
||||
function mockStreamResponse(chunks: unknown[]): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'));
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: stream,
|
||||
headers: new Headers()
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('OllamaClient', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
let client: OllamaClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
client = new OllamaClient({
|
||||
baseUrl: 'http://localhost:11434',
|
||||
fetchFn: mockFetch,
|
||||
enableRetry: false
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('uses default config when not provided', () => {
|
||||
const defaultClient = new OllamaClient({ fetchFn: mockFetch });
|
||||
expect(defaultClient.baseUrl).toBe('');
|
||||
});
|
||||
|
||||
it('uses custom base URL', () => {
|
||||
expect(client.baseUrl).toBe('http://localhost:11434');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels', () => {
|
||||
it('fetches models list', async () => {
|
||||
const models = {
|
||||
models: [
|
||||
{ name: 'llama3:8b', size: 4000000000 },
|
||||
{ name: 'mistral:7b', size: 3500000000 }
|
||||
]
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(models));
|
||||
|
||||
const result = await client.listModels();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/tags',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(result.models).toHaveLength(2);
|
||||
expect(result.models[0].name).toBe('llama3:8b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRunningModels', () => {
|
||||
it('fetches running models', async () => {
|
||||
const running = {
|
||||
models: [{ name: 'llama3:8b', size: 4000000000 }]
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(running));
|
||||
|
||||
const result = await client.listRunningModels();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/ps',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(result.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showModel', () => {
|
||||
it('fetches model details with string arg', async () => {
|
||||
const details = {
|
||||
modelfile: 'FROM llama3',
|
||||
parameters: 'temperature 0.8'
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(details));
|
||||
|
||||
const result = await client.showModel('llama3:8b');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/show',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'llama3:8b' })
|
||||
})
|
||||
);
|
||||
expect(result.modelfile).toBe('FROM llama3');
|
||||
});
|
||||
|
||||
it('fetches model details with request object', async () => {
|
||||
const details = { modelfile: 'FROM llama3' };
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(details));
|
||||
|
||||
await client.showModel({ model: 'llama3:8b', verbose: true });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/show',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ model: 'llama3:8b', verbose: true })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteModel', () => {
|
||||
it('sends delete request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse({}));
|
||||
|
||||
await client.deleteModel('old-model');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/delete',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ name: 'old-model' })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullModel', () => {
|
||||
it('streams pull progress', async () => {
|
||||
const chunks = [
|
||||
{ status: 'pulling manifest' },
|
||||
{ status: 'downloading', completed: 50, total: 100 },
|
||||
{ status: 'success' }
|
||||
];
|
||||
mockFetch.mockResolvedValueOnce(mockStreamResponse(chunks));
|
||||
|
||||
const progress: unknown[] = [];
|
||||
await client.pullModel('llama3:8b', (p) => progress.push(p));
|
||||
|
||||
expect(progress).toHaveLength(3);
|
||||
expect(progress[0]).toEqual({ status: 'pulling manifest' });
|
||||
expect(progress[2]).toEqual({ status: 'success' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createModel', () => {
|
||||
it('streams create progress', async () => {
|
||||
const chunks = [
|
||||
{ status: 'creating new layer sha256:abc...' },
|
||||
{ status: 'writing manifest' },
|
||||
{ status: 'success' }
|
||||
];
|
||||
mockFetch.mockResolvedValueOnce(mockStreamResponse(chunks));
|
||||
|
||||
const progress: unknown[] = [];
|
||||
await client.createModel(
|
||||
{ model: 'my-custom', from: 'llama3:8b', system: 'You are helpful' },
|
||||
(p) => progress.push(p)
|
||||
);
|
||||
|
||||
expect(progress).toHaveLength(3);
|
||||
expect(progress[2]).toEqual({ status: 'success' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
it('sends chat request', async () => {
|
||||
const response = {
|
||||
message: { role: 'assistant', content: 'Hello!' },
|
||||
done: true
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(response));
|
||||
|
||||
const result = await client.chat({
|
||||
model: 'llama3:8b',
|
||||
messages: [{ role: 'user', content: 'Hi' }]
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.objectContaining({
|
||||
method: 'POST'
|
||||
})
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.model).toBe('llama3:8b');
|
||||
expect(body.stream).toBe(false);
|
||||
expect(result.message.content).toBe('Hello!');
|
||||
});
|
||||
|
||||
it('includes tools in request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
|
||||
);
|
||||
|
||||
await client.chat({
|
||||
model: 'llama3:8b',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'get_time', description: 'Get current time' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.tools).toHaveLength(1);
|
||||
expect(body.tools[0].function.name).toBe('get_time');
|
||||
});
|
||||
|
||||
it('includes options in request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
|
||||
);
|
||||
|
||||
await client.chat({
|
||||
model: 'llama3:8b',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
options: { temperature: 0.5, num_ctx: 4096 }
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.options.temperature).toBe(0.5);
|
||||
expect(body.options.num_ctx).toBe(4096);
|
||||
});
|
||||
|
||||
it('includes think option for reasoning models', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockResponse({ message: { role: 'assistant', content: 'ok' }, done: true })
|
||||
);
|
||||
|
||||
await client.chat({
|
||||
model: 'qwen3:8b',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
think: true
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.think).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('sends generate request', async () => {
|
||||
const response = { response: 'Generated text', done: true };
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(response));
|
||||
|
||||
const result = await client.generate({
|
||||
model: 'llama3:8b',
|
||||
prompt: 'Complete this: Hello'
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.stream).toBe(false);
|
||||
expect(result.response).toBe('Generated text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embed', () => {
|
||||
it('generates embeddings', async () => {
|
||||
const response = { embeddings: [[0.1, 0.2, 0.3]] };
|
||||
mockFetch.mockResolvedValueOnce(mockResponse(response));
|
||||
|
||||
const result = await client.embed({
|
||||
model: 'nomic-embed-text',
|
||||
input: 'test text'
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/embed',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(result.embeddings[0]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthCheck', () => {
|
||||
it('returns true when server responds', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
|
||||
|
||||
const healthy = await client.healthCheck();
|
||||
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server fails', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const healthy = await client.healthCheck();
|
||||
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('returns version info', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
|
||||
|
||||
const result = await client.getVersion();
|
||||
|
||||
expect(result.version).toBe('0.3.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('returns success status when connected', async () => {
|
||||
mockFetch.mockResolvedValueOnce(mockResponse({ version: '0.3.0' }));
|
||||
|
||||
const status = await client.testConnection();
|
||||
|
||||
expect(status.connected).toBe(true);
|
||||
expect(status.version).toBe('0.3.0');
|
||||
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(status.baseUrl).toBe('http://localhost:11434');
|
||||
});
|
||||
|
||||
it('returns error status when disconnected', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const status = await client.testConnection();
|
||||
|
||||
expect(status.connected).toBe(false);
|
||||
expect(status.error).toBeDefined();
|
||||
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withConfig', () => {
|
||||
it('creates new client with updated config', () => {
|
||||
const newClient = client.withConfig({ baseUrl: 'http://other:11434' });
|
||||
|
||||
expect(newClient.baseUrl).toBe('http://other:11434');
|
||||
expect(client.baseUrl).toBe('http://localhost:11434'); // Original unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws on non-ok response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockResponse({ error: 'Model not found' }, 404, false)
|
||||
);
|
||||
|
||||
await expect(client.listModels()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
frontend/src/lib/ollama/errors.test.ts
Normal file
264
frontend/src/lib/ollama/errors.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Ollama error handling tests
|
||||
*
|
||||
* Tests error classification, error types, and retry logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
OllamaError,
|
||||
OllamaConnectionError,
|
||||
OllamaTimeoutError,
|
||||
OllamaModelNotFoundError,
|
||||
OllamaInvalidRequestError,
|
||||
OllamaStreamError,
|
||||
OllamaParseError,
|
||||
OllamaAbortError,
|
||||
classifyError,
|
||||
withRetry
|
||||
} from './errors';
|
||||
|
||||
describe('OllamaError', () => {
|
||||
it('creates error with code and message', () => {
|
||||
const error = new OllamaError('Something went wrong', 'UNKNOWN_ERROR');
|
||||
|
||||
expect(error.message).toBe('Something went wrong');
|
||||
expect(error.code).toBe('UNKNOWN_ERROR');
|
||||
expect(error.name).toBe('OllamaError');
|
||||
});
|
||||
|
||||
it('stores status code when provided', () => {
|
||||
const error = new OllamaError('Server error', 'SERVER_ERROR', { statusCode: 500 });
|
||||
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it('stores original error as cause', () => {
|
||||
const originalError = new Error('Original');
|
||||
const error = new OllamaError('Wrapped', 'UNKNOWN_ERROR', { cause: originalError });
|
||||
|
||||
expect(error.originalError).toBe(originalError);
|
||||
expect(error.cause).toBe(originalError);
|
||||
});
|
||||
|
||||
describe('isRetryable', () => {
|
||||
it('returns true for CONNECTION_ERROR', () => {
|
||||
const error = new OllamaError('Connection failed', 'CONNECTION_ERROR');
|
||||
expect(error.isRetryable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for TIMEOUT_ERROR', () => {
|
||||
const error = new OllamaError('Timed out', 'TIMEOUT_ERROR');
|
||||
expect(error.isRetryable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for SERVER_ERROR', () => {
|
||||
const error = new OllamaError('Server down', 'SERVER_ERROR');
|
||||
expect(error.isRetryable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for INVALID_REQUEST', () => {
|
||||
const error = new OllamaError('Bad request', 'INVALID_REQUEST');
|
||||
expect(error.isRetryable).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for MODEL_NOT_FOUND', () => {
|
||||
const error = new OllamaError('Model missing', 'MODEL_NOT_FOUND');
|
||||
expect(error.isRetryable).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Specialized Error Classes', () => {
|
||||
it('OllamaConnectionError has correct code', () => {
|
||||
const error = new OllamaConnectionError('Cannot connect');
|
||||
expect(error.code).toBe('CONNECTION_ERROR');
|
||||
expect(error.name).toBe('OllamaConnectionError');
|
||||
});
|
||||
|
||||
it('OllamaTimeoutError stores timeout value', () => {
|
||||
const error = new OllamaTimeoutError('Request timed out', 30000);
|
||||
expect(error.code).toBe('TIMEOUT_ERROR');
|
||||
expect(error.timeoutMs).toBe(30000);
|
||||
});
|
||||
|
||||
it('OllamaModelNotFoundError stores model name', () => {
|
||||
const error = new OllamaModelNotFoundError('llama3:8b');
|
||||
expect(error.code).toBe('MODEL_NOT_FOUND');
|
||||
expect(error.modelName).toBe('llama3:8b');
|
||||
expect(error.message).toContain('llama3:8b');
|
||||
});
|
||||
|
||||
it('OllamaInvalidRequestError has 400 status', () => {
|
||||
const error = new OllamaInvalidRequestError('Missing required field');
|
||||
expect(error.code).toBe('INVALID_REQUEST');
|
||||
expect(error.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('OllamaStreamError preserves cause', () => {
|
||||
const cause = new Error('Stream interrupted');
|
||||
const error = new OllamaStreamError('Streaming failed', cause);
|
||||
expect(error.code).toBe('STREAM_ERROR');
|
||||
expect(error.originalError).toBe(cause);
|
||||
});
|
||||
|
||||
it('OllamaParseError stores raw data', () => {
|
||||
const error = new OllamaParseError('Invalid JSON', '{"broken');
|
||||
expect(error.code).toBe('PARSE_ERROR');
|
||||
expect(error.rawData).toBe('{"broken');
|
||||
});
|
||||
|
||||
it('OllamaAbortError has default message', () => {
|
||||
const error = new OllamaAbortError();
|
||||
expect(error.code).toBe('ABORT_ERROR');
|
||||
expect(error.message).toBe('Request was aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyError', () => {
|
||||
it('returns OllamaError unchanged', () => {
|
||||
const original = new OllamaConnectionError('Already classified');
|
||||
const result = classifyError(original);
|
||||
|
||||
expect(result).toBe(original);
|
||||
});
|
||||
|
||||
it('classifies TypeError with fetch as connection error', () => {
|
||||
const error = new TypeError('Failed to fetch');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaConnectionError);
|
||||
expect(result.code).toBe('CONNECTION_ERROR');
|
||||
});
|
||||
|
||||
it('classifies TypeError with network as connection error', () => {
|
||||
const error = new TypeError('network error');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaConnectionError);
|
||||
});
|
||||
|
||||
it('classifies AbortError DOMException', () => {
|
||||
const error = new DOMException('Aborted', 'AbortError');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaAbortError);
|
||||
});
|
||||
|
||||
it('classifies ECONNREFUSED as connection error', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:11434');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaConnectionError);
|
||||
});
|
||||
|
||||
it('classifies timeout messages as timeout error', () => {
|
||||
const error = new Error('Request timed out');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaTimeoutError);
|
||||
});
|
||||
|
||||
it('classifies abort messages as abort error', () => {
|
||||
const error = new Error('Operation aborted by user');
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaAbortError);
|
||||
});
|
||||
|
||||
it('adds context prefix when provided', () => {
|
||||
const error = new Error('Something failed');
|
||||
const result = classifyError(error, 'During chat');
|
||||
|
||||
expect(result.message).toContain('During chat:');
|
||||
});
|
||||
|
||||
it('handles non-Error values', () => {
|
||||
const result = classifyError('just a string');
|
||||
|
||||
expect(result).toBeInstanceOf(OllamaError);
|
||||
expect(result.code).toBe('UNKNOWN_ERROR');
|
||||
expect(result.message).toContain('just a string');
|
||||
});
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
expect(classifyError(null).code).toBe('UNKNOWN_ERROR');
|
||||
expect(classifyError(undefined).code).toBe('UNKNOWN_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRetry', () => {
|
||||
it('returns result on first success', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('success');
|
||||
|
||||
const result = await withRetry(fn);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries on retryable error', async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new OllamaConnectionError('Failed'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const result = await withRetry(fn, { initialDelayMs: 1 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not retry non-retryable errors', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new OllamaInvalidRequestError('Bad request'));
|
||||
|
||||
await expect(withRetry(fn)).rejects.toThrow(OllamaInvalidRequestError);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops after maxAttempts', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new OllamaConnectionError('Always fails'));
|
||||
|
||||
await expect(withRetry(fn, { maxAttempts: 3, initialDelayMs: 1 })).rejects.toThrow();
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('calls onRetry callback', async () => {
|
||||
const onRetry = vi.fn();
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new OllamaConnectionError('Failed'))
|
||||
.mockResolvedValueOnce('ok');
|
||||
|
||||
await withRetry(fn, { initialDelayMs: 1, onRetry });
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).toHaveBeenCalledWith(expect.any(OllamaConnectionError), 1, 1);
|
||||
});
|
||||
|
||||
it('respects abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const fn = vi.fn().mockResolvedValue('success');
|
||||
|
||||
await expect(withRetry(fn, { signal: controller.signal })).rejects.toThrow(OllamaAbortError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses custom isRetryable function', async () => {
|
||||
// Make MODEL_NOT_FOUND retryable (not normally)
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new OllamaModelNotFoundError('test-model'))
|
||||
.mockResolvedValueOnce('found it');
|
||||
|
||||
const result = await withRetry(fn, {
|
||||
initialDelayMs: 1,
|
||||
isRetryable: () => true // Retry everything
|
||||
});
|
||||
|
||||
expect(result).toBe('found it');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
173
frontend/src/lib/ollama/modelfile-parser.test.ts
Normal file
173
frontend/src/lib/ollama/modelfile-parser.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Modelfile parser tests
|
||||
*
|
||||
* Tests parsing of Ollama Modelfile format directives
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseSystemPromptFromModelfile,
|
||||
parseTemplateFromModelfile,
|
||||
parseParametersFromModelfile,
|
||||
hasSystemPrompt
|
||||
} from './modelfile-parser';
|
||||
|
||||
describe('parseSystemPromptFromModelfile', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseSystemPromptFromModelfile('')).toBeNull();
|
||||
expect(parseSystemPromptFromModelfile(null as unknown as string)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses triple double quoted system prompt', () => {
|
||||
const modelfile = `FROM llama3
|
||||
SYSTEM """
|
||||
You are a helpful assistant.
|
||||
Be concise and clear.
|
||||
"""
|
||||
PARAMETER temperature 0.7`;
|
||||
|
||||
const result = parseSystemPromptFromModelfile(modelfile);
|
||||
expect(result).toBe('You are a helpful assistant.\nBe concise and clear.');
|
||||
});
|
||||
|
||||
it('parses triple single quoted system prompt', () => {
|
||||
const modelfile = `FROM llama3
|
||||
SYSTEM '''
|
||||
You are a coding assistant.
|
||||
'''`;
|
||||
|
||||
const result = parseSystemPromptFromModelfile(modelfile);
|
||||
expect(result).toBe('You are a coding assistant.');
|
||||
});
|
||||
|
||||
it('parses double quoted single-line system prompt', () => {
|
||||
const modelfile = `FROM llama3
|
||||
SYSTEM "You are a helpful assistant."`;
|
||||
|
||||
const result = parseSystemPromptFromModelfile(modelfile);
|
||||
expect(result).toBe('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('parses single quoted single-line system prompt', () => {
|
||||
const modelfile = `FROM mistral
|
||||
SYSTEM 'Be brief and accurate.'`;
|
||||
|
||||
const result = parseSystemPromptFromModelfile(modelfile);
|
||||
expect(result).toBe('Be brief and accurate.');
|
||||
});
|
||||
|
||||
it('parses unquoted system prompt', () => {
|
||||
const modelfile = `FROM llama3
|
||||
SYSTEM You are a helpful AI`;
|
||||
|
||||
const result = parseSystemPromptFromModelfile(modelfile);
|
||||
expect(result).toBe('You are a helpful AI');
|
||||
});
|
||||
|
||||
it('returns null when no system directive', () => {
|
||||
const modelfile = `FROM llama3
|
||||
PARAMETER temperature 0.8`;
|
||||
|
||||
expect(parseSystemPromptFromModelfile(modelfile)).toBeNull();
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
const modelfile = `system "Lower case works too"`;
|
||||
expect(parseSystemPromptFromModelfile(modelfile)).toBe('Lower case works too');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTemplateFromModelfile', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(parseTemplateFromModelfile('')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses triple quoted template', () => {
|
||||
const modelfile = `FROM llama3
|
||||
TEMPLATE """{{ .System }}
|
||||
{{ .Prompt }}"""`;
|
||||
|
||||
const result = parseTemplateFromModelfile(modelfile);
|
||||
expect(result).toBe('{{ .System }}\n{{ .Prompt }}');
|
||||
});
|
||||
|
||||
it('parses single-line template', () => {
|
||||
const modelfile = `FROM mistral
|
||||
TEMPLATE "{{ .Prompt }}"`;
|
||||
|
||||
const result = parseTemplateFromModelfile(modelfile);
|
||||
expect(result).toBe('{{ .Prompt }}');
|
||||
});
|
||||
|
||||
it('returns null when no template', () => {
|
||||
const modelfile = `FROM llama3
|
||||
SYSTEM "Hello"`;
|
||||
|
||||
expect(parseTemplateFromModelfile(modelfile)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseParametersFromModelfile', () => {
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(parseParametersFromModelfile('')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses single parameter', () => {
|
||||
const modelfile = `FROM llama3
|
||||
PARAMETER temperature 0.7`;
|
||||
|
||||
const result = parseParametersFromModelfile(modelfile);
|
||||
expect(result).toEqual({ temperature: '0.7' });
|
||||
});
|
||||
|
||||
it('parses multiple parameters', () => {
|
||||
const modelfile = `FROM llama3
|
||||
PARAMETER temperature 0.8
|
||||
PARAMETER top_k 40
|
||||
PARAMETER top_p 0.9
|
||||
PARAMETER num_ctx 4096`;
|
||||
|
||||
const result = parseParametersFromModelfile(modelfile);
|
||||
expect(result).toEqual({
|
||||
temperature: '0.8',
|
||||
top_k: '40',
|
||||
top_p: '0.9',
|
||||
num_ctx: '4096'
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes parameter names to lowercase', () => {
|
||||
const modelfile = `PARAMETER Temperature 0.5
|
||||
PARAMETER TOP_K 50`;
|
||||
|
||||
const result = parseParametersFromModelfile(modelfile);
|
||||
expect(result.temperature).toBe('0.5');
|
||||
expect(result.top_k).toBe('50');
|
||||
});
|
||||
|
||||
it('handles mixed content', () => {
|
||||
const modelfile = `FROM mistral
|
||||
SYSTEM "Be helpful"
|
||||
PARAMETER temperature 0.7
|
||||
TEMPLATE "{{ .Prompt }}"
|
||||
PARAMETER num_ctx 8192`;
|
||||
|
||||
const result = parseParametersFromModelfile(modelfile);
|
||||
expect(result).toEqual({
|
||||
temperature: '0.7',
|
||||
num_ctx: '8192'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSystemPrompt', () => {
|
||||
it('returns true when system prompt exists', () => {
|
||||
expect(hasSystemPrompt('SYSTEM "Hello"')).toBe(true);
|
||||
expect(hasSystemPrompt('SYSTEM """Multi\nline"""')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no system prompt', () => {
|
||||
expect(hasSystemPrompt('FROM llama3')).toBe(false);
|
||||
expect(hasSystemPrompt('')).toBe(false);
|
||||
});
|
||||
});
|
||||
132
frontend/src/lib/services/conversation-summary.test.ts
Normal file
132
frontend/src/lib/services/conversation-summary.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Conversation Summary Service tests
|
||||
*
|
||||
* Tests the pure utility functions for conversation summaries
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getSummaryPrompt } from './conversation-summary';
|
||||
import type { Message } from '$lib/types/chat';
|
||||
|
||||
// Helper to create messages
|
||||
function createMessage(
|
||||
role: 'user' | 'assistant' | 'system',
|
||||
content: string
|
||||
): Message {
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
describe('getSummaryPrompt', () => {
|
||||
it('formats user and assistant messages correctly', () => {
|
||||
const messages: Message[] = [
|
||||
createMessage('user', 'Hello!'),
|
||||
createMessage('assistant', 'Hi there!')
|
||||
];
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
expect(prompt).toContain('User: Hello!');
|
||||
expect(prompt).toContain('Assistant: Hi there!');
|
||||
expect(prompt).toContain('Summarize this conversation');
|
||||
});
|
||||
|
||||
it('filters out system messages', () => {
|
||||
const messages: Message[] = [
|
||||
createMessage('system', 'You are a helpful assistant'),
|
||||
createMessage('user', 'Hello!'),
|
||||
createMessage('assistant', 'Hi!')
|
||||
];
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
expect(prompt).not.toContain('You are a helpful assistant');
|
||||
expect(prompt).toContain('User: Hello!');
|
||||
});
|
||||
|
||||
it('respects maxMessages limit', () => {
|
||||
const messages: Message[] = [
|
||||
createMessage('user', 'Message 1'),
|
||||
createMessage('assistant', 'Response 1'),
|
||||
createMessage('user', 'Message 2'),
|
||||
createMessage('assistant', 'Response 2'),
|
||||
createMessage('user', 'Message 3'),
|
||||
createMessage('assistant', 'Response 3')
|
||||
];
|
||||
|
||||
const prompt = getSummaryPrompt(messages, 4);
|
||||
|
||||
// Should only include last 4 messages
|
||||
expect(prompt).not.toContain('Message 1');
|
||||
expect(prompt).not.toContain('Response 1');
|
||||
expect(prompt).toContain('Message 2');
|
||||
expect(prompt).toContain('Response 2');
|
||||
expect(prompt).toContain('Message 3');
|
||||
expect(prompt).toContain('Response 3');
|
||||
});
|
||||
|
||||
it('truncates long message content to 500 chars', () => {
|
||||
const longContent = 'A'.repeat(600);
|
||||
const messages: Message[] = [createMessage('user', longContent)];
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
// Content should be truncated
|
||||
expect(prompt).not.toContain('A'.repeat(600));
|
||||
expect(prompt).toContain('A'.repeat(500));
|
||||
});
|
||||
|
||||
it('handles empty messages array', () => {
|
||||
const prompt = getSummaryPrompt([]);
|
||||
|
||||
expect(prompt).toContain('Summarize this conversation');
|
||||
expect(prompt).toContain('Conversation:');
|
||||
});
|
||||
|
||||
it('includes standard prompt instructions', () => {
|
||||
const messages: Message[] = [
|
||||
createMessage('user', 'Test'),
|
||||
createMessage('assistant', 'Test response')
|
||||
];
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
expect(prompt).toContain('Summarize this conversation in 2-3 sentences');
|
||||
expect(prompt).toContain('Focus on the main topics');
|
||||
expect(prompt).toContain('Be concise');
|
||||
expect(prompt).toContain('Summary:');
|
||||
});
|
||||
|
||||
it('uses default maxMessages of 20', () => {
|
||||
// Create 25 messages with distinct identifiers to avoid substring matches
|
||||
const messages: Message[] = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
// Use letters to avoid number substring issues (Message 1 in Message 10)
|
||||
const letter = String.fromCharCode(65 + i); // A, B, C, ...
|
||||
messages.push(createMessage(i % 2 === 0 ? 'user' : 'assistant', `Msg-${letter}`));
|
||||
}
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
// First 5 messages should not be included (25 - 20 = 5)
|
||||
expect(prompt).not.toContain('Msg-A');
|
||||
expect(prompt).not.toContain('Msg-E');
|
||||
// Message 6 onwards should be included
|
||||
expect(prompt).toContain('Msg-F');
|
||||
expect(prompt).toContain('Msg-Y'); // 25th message
|
||||
});
|
||||
|
||||
it('separates messages with double newlines', () => {
|
||||
const messages: Message[] = [
|
||||
createMessage('user', 'First'),
|
||||
createMessage('assistant', 'Second')
|
||||
];
|
||||
|
||||
const prompt = getSummaryPrompt(messages);
|
||||
|
||||
expect(prompt).toContain('User: First\n\nAssistant: Second');
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/services/prompt-resolution.test.ts
Normal file
45
frontend/src/lib/services/prompt-resolution.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Prompt resolution service tests
|
||||
*
|
||||
* Tests the pure utility functions from prompt resolution
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getPromptSourceLabel, type PromptSource } from './prompt-resolution';
|
||||
|
||||
describe('getPromptSourceLabel', () => {
|
||||
const testCases: Array<{ source: PromptSource; expected: string }> = [
|
||||
{ source: 'per-conversation', expected: 'Custom (this chat)' },
|
||||
{ source: 'new-chat-selection', expected: 'Selected prompt' },
|
||||
{ source: 'model-mapping', expected: 'Model default' },
|
||||
{ source: 'model-embedded', expected: 'Model built-in' },
|
||||
{ source: 'capability-match', expected: 'Auto-matched' },
|
||||
{ source: 'global-active', expected: 'Global default' },
|
||||
{ source: 'none', expected: 'None' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ source, expected }) => {
|
||||
it(`returns "${expected}" for source "${source}"`, () => {
|
||||
expect(getPromptSourceLabel(source)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('covers all prompt source types', () => {
|
||||
// This ensures we test all PromptSource values
|
||||
const allSources: PromptSource[] = [
|
||||
'per-conversation',
|
||||
'new-chat-selection',
|
||||
'model-mapping',
|
||||
'model-embedded',
|
||||
'capability-match',
|
||||
'global-active',
|
||||
'none'
|
||||
];
|
||||
|
||||
allSources.forEach((source) => {
|
||||
const label = getPromptSourceLabel(source);
|
||||
expect(typeof label).toBe('string');
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
283
frontend/src/lib/tools/builtin.test.ts
Normal file
283
frontend/src/lib/tools/builtin.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Built-in tools tests
|
||||
*
|
||||
* Tests the MathParser and tool definitions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { builtinTools, getBuiltinToolDefinitions } from './builtin';
|
||||
|
||||
// We need to test the MathParser through the calculate handler
|
||||
// since MathParser is not exported directly
|
||||
function calculate(expression: string, precision?: number): unknown {
|
||||
const entry = builtinTools.get('calculate');
|
||||
if (!entry) throw new Error('Calculate tool not found');
|
||||
return entry.handler({ expression, precision });
|
||||
}
|
||||
|
||||
describe('MathParser (via calculate tool)', () => {
|
||||
describe('basic arithmetic', () => {
|
||||
it('handles addition', () => {
|
||||
expect(calculate('2+3')).toBe(5);
|
||||
expect(calculate('100+200')).toBe(300);
|
||||
expect(calculate('1+2+3+4')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles subtraction', () => {
|
||||
expect(calculate('10-3')).toBe(7);
|
||||
expect(calculate('100-50-25')).toBe(25);
|
||||
});
|
||||
|
||||
it('handles multiplication', () => {
|
||||
expect(calculate('3*4')).toBe(12);
|
||||
expect(calculate('2*3*4')).toBe(24);
|
||||
});
|
||||
|
||||
it('handles division', () => {
|
||||
expect(calculate('10/2')).toBe(5);
|
||||
expect(calculate('100/4/5')).toBe(5);
|
||||
});
|
||||
|
||||
it('handles modulo', () => {
|
||||
expect(calculate('10%3')).toBe(1);
|
||||
expect(calculate('17%5')).toBe(2);
|
||||
});
|
||||
|
||||
it('handles mixed operations with precedence', () => {
|
||||
expect(calculate('2+3*4')).toBe(14);
|
||||
expect(calculate('10-2*3')).toBe(4);
|
||||
expect(calculate('10/2+3')).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parentheses', () => {
|
||||
it('handles simple parentheses', () => {
|
||||
expect(calculate('(2+3)*4')).toBe(20);
|
||||
expect(calculate('(10-2)*3')).toBe(24);
|
||||
});
|
||||
|
||||
it('handles nested parentheses', () => {
|
||||
expect(calculate('((2+3)*4)+1')).toBe(21);
|
||||
expect(calculate('2*((3+4)*2)')).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('power/exponentiation', () => {
|
||||
it('handles caret operator', () => {
|
||||
expect(calculate('2^3')).toBe(8);
|
||||
expect(calculate('3^2')).toBe(9);
|
||||
expect(calculate('10^0')).toBe(1);
|
||||
});
|
||||
|
||||
it('handles double star operator', () => {
|
||||
expect(calculate('2**3')).toBe(8);
|
||||
expect(calculate('5**2')).toBe(25);
|
||||
});
|
||||
|
||||
it('handles right associativity', () => {
|
||||
// 2^3^2 should be 2^(3^2) = 2^9 = 512
|
||||
expect(calculate('2^3^2')).toBe(512);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unary operators', () => {
|
||||
it('handles negative numbers', () => {
|
||||
expect(calculate('-5')).toBe(-5);
|
||||
expect(calculate('-5+3')).toBe(-2);
|
||||
expect(calculate('3+-5')).toBe(-2);
|
||||
});
|
||||
|
||||
it('handles positive prefix', () => {
|
||||
expect(calculate('+5')).toBe(5);
|
||||
expect(calculate('3++5')).toBe(8);
|
||||
});
|
||||
|
||||
it('handles double negation', () => {
|
||||
expect(calculate('--5')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical functions', () => {
|
||||
it('handles sqrt', () => {
|
||||
expect(calculate('sqrt(16)')).toBe(4);
|
||||
expect(calculate('sqrt(2)')).toBeCloseTo(1.41421356, 5);
|
||||
});
|
||||
|
||||
it('handles abs', () => {
|
||||
expect(calculate('abs(-5)')).toBe(5);
|
||||
expect(calculate('abs(5)')).toBe(5);
|
||||
});
|
||||
|
||||
it('handles sign', () => {
|
||||
expect(calculate('sign(-10)')).toBe(-1);
|
||||
expect(calculate('sign(10)')).toBe(1);
|
||||
expect(calculate('sign(0)')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles trigonometric functions', () => {
|
||||
expect(calculate('sin(0)')).toBe(0);
|
||||
expect(calculate('cos(0)')).toBe(1);
|
||||
expect(calculate('tan(0)')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles inverse trig functions', () => {
|
||||
expect(calculate('asin(0)')).toBe(0);
|
||||
expect(calculate('acos(1)')).toBe(0);
|
||||
expect(calculate('atan(0)')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles hyperbolic functions', () => {
|
||||
expect(calculate('sinh(0)')).toBe(0);
|
||||
expect(calculate('cosh(0)')).toBe(1);
|
||||
expect(calculate('tanh(0)')).toBe(0);
|
||||
});
|
||||
|
||||
it('handles logarithms', () => {
|
||||
expect(calculate('log(1)')).toBe(0);
|
||||
expect(calculate('log10(100)')).toBe(2);
|
||||
expect(calculate('log2(8)')).toBe(3);
|
||||
});
|
||||
|
||||
it('handles exp', () => {
|
||||
expect(calculate('exp(0)')).toBe(1);
|
||||
expect(calculate('exp(1)')).toBeCloseTo(Math.E, 5);
|
||||
});
|
||||
|
||||
it('handles rounding functions', () => {
|
||||
expect(calculate('round(1.5)')).toBe(2);
|
||||
expect(calculate('floor(1.9)')).toBe(1);
|
||||
expect(calculate('ceil(1.1)')).toBe(2);
|
||||
expect(calculate('trunc(-1.9)')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('handles PI', () => {
|
||||
expect(calculate('PI')).toBeCloseTo(Math.PI, 5);
|
||||
expect(calculate('pi')).toBeCloseTo(Math.PI, 5);
|
||||
});
|
||||
|
||||
it('handles E', () => {
|
||||
expect(calculate('E')).toBeCloseTo(Math.E, 5);
|
||||
expect(calculate('e')).toBeCloseTo(Math.E, 5);
|
||||
});
|
||||
|
||||
it('handles TAU', () => {
|
||||
expect(calculate('TAU')).toBeCloseTo(Math.PI * 2, 5);
|
||||
expect(calculate('tau')).toBeCloseTo(Math.PI * 2, 5);
|
||||
});
|
||||
|
||||
it('handles PHI (golden ratio)', () => {
|
||||
expect(calculate('PHI')).toBeCloseTo(1.618033988, 5);
|
||||
});
|
||||
|
||||
it('handles LN2 and LN10', () => {
|
||||
expect(calculate('LN2')).toBeCloseTo(Math.LN2, 5);
|
||||
expect(calculate('LN10')).toBeCloseTo(Math.LN10, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex expressions', () => {
|
||||
it('handles PI-based calculations', () => {
|
||||
expect(calculate('sin(PI/2)')).toBeCloseTo(1, 5);
|
||||
expect(calculate('cos(PI)')).toBeCloseTo(-1, 5);
|
||||
});
|
||||
|
||||
it('handles nested functions', () => {
|
||||
expect(calculate('sqrt(abs(-16))')).toBe(4);
|
||||
expect(calculate('log2(2^10)')).toBe(10);
|
||||
});
|
||||
|
||||
it('handles function with complex argument', () => {
|
||||
expect(calculate('sqrt(3^2+4^2)')).toBe(5); // Pythagorean: 3-4-5 triangle
|
||||
});
|
||||
});
|
||||
|
||||
describe('precision handling', () => {
|
||||
it('defaults to 10 decimal places', () => {
|
||||
const result = calculate('1/3');
|
||||
expect(result).toBeCloseTo(0.3333333333, 9);
|
||||
});
|
||||
|
||||
it('respects custom precision', () => {
|
||||
const result = calculate('1/3', 2);
|
||||
expect(result).toBe(0.33);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles division by zero', () => {
|
||||
const result = calculate('1/0') as { error: string };
|
||||
expect(result.error).toContain('Division by zero');
|
||||
});
|
||||
|
||||
it('handles unknown functions', () => {
|
||||
const result = calculate('unknown(5)') as { error: string };
|
||||
expect(result.error).toContain('Unknown function');
|
||||
});
|
||||
|
||||
it('handles missing closing parenthesis', () => {
|
||||
const result = calculate('(2+3') as { error: string };
|
||||
expect(result.error).toContain('parenthesis');
|
||||
});
|
||||
|
||||
it('handles unexpected characters', () => {
|
||||
const result = calculate('2+@3') as { error: string };
|
||||
expect(result.error).toContain('Unexpected character');
|
||||
});
|
||||
|
||||
it('handles infinity result', () => {
|
||||
const result = calculate('exp(1000)') as { error: string };
|
||||
expect(result.error).toContain('invalid number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('ignores whitespace', () => {
|
||||
expect(calculate('2 + 3')).toBe(5);
|
||||
expect(calculate(' 2 * 3 ')).toBe(6);
|
||||
expect(calculate('sqrt( 16 )')).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('builtinTools registry', () => {
|
||||
it('contains all expected tools', () => {
|
||||
expect(builtinTools.has('get_current_time')).toBe(true);
|
||||
expect(builtinTools.has('calculate')).toBe(true);
|
||||
expect(builtinTools.has('fetch_url')).toBe(true);
|
||||
expect(builtinTools.has('get_location')).toBe(true);
|
||||
expect(builtinTools.has('web_search')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks all tools as builtin', () => {
|
||||
for (const [, entry] of builtinTools) {
|
||||
expect(entry.isBuiltin).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('has valid definitions for all tools', () => {
|
||||
for (const [name, entry] of builtinTools) {
|
||||
expect(entry.definition.type).toBe('function');
|
||||
expect(entry.definition.function.name).toBe(name);
|
||||
expect(typeof entry.definition.function.description).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuiltinToolDefinitions', () => {
|
||||
it('returns array of tool definitions', () => {
|
||||
const definitions = getBuiltinToolDefinitions();
|
||||
expect(Array.isArray(definitions)).toBe(true);
|
||||
expect(definitions.length).toBe(5);
|
||||
});
|
||||
|
||||
it('returns valid definitions', () => {
|
||||
const definitions = getBuiltinToolDefinitions();
|
||||
for (const def of definitions) {
|
||||
expect(def.type).toBe('function');
|
||||
expect(def.function).toBeDefined();
|
||||
expect(typeof def.function.name).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
176
frontend/src/lib/types/attachment.test.ts
Normal file
176
frontend/src/lib/types/attachment.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Attachment type guards tests
|
||||
*
|
||||
* Tests file type detection utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isImageMimeType,
|
||||
isTextMimeType,
|
||||
isPdfMimeType,
|
||||
isTextExtension,
|
||||
IMAGE_MIME_TYPES,
|
||||
TEXT_MIME_TYPES,
|
||||
TEXT_FILE_EXTENSIONS
|
||||
} from './attachment';
|
||||
|
||||
describe('isImageMimeType', () => {
|
||||
it('returns true for supported image types', () => {
|
||||
expect(isImageMimeType('image/jpeg')).toBe(true);
|
||||
expect(isImageMimeType('image/png')).toBe(true);
|
||||
expect(isImageMimeType('image/gif')).toBe(true);
|
||||
expect(isImageMimeType('image/webp')).toBe(true);
|
||||
expect(isImageMimeType('image/bmp')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image types', () => {
|
||||
expect(isImageMimeType('text/plain')).toBe(false);
|
||||
expect(isImageMimeType('application/pdf')).toBe(false);
|
||||
expect(isImageMimeType('image/svg+xml')).toBe(false); // Not in supported list
|
||||
expect(isImageMimeType('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for partial matches', () => {
|
||||
expect(isImageMimeType('image/')).toBe(false);
|
||||
expect(isImageMimeType('image/jpeg/extra')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTextMimeType', () => {
|
||||
it('returns true for supported text types', () => {
|
||||
expect(isTextMimeType('text/plain')).toBe(true);
|
||||
expect(isTextMimeType('text/markdown')).toBe(true);
|
||||
expect(isTextMimeType('text/html')).toBe(true);
|
||||
expect(isTextMimeType('text/css')).toBe(true);
|
||||
expect(isTextMimeType('text/javascript')).toBe(true);
|
||||
expect(isTextMimeType('application/json')).toBe(true);
|
||||
expect(isTextMimeType('application/javascript')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-text types', () => {
|
||||
expect(isTextMimeType('image/png')).toBe(false);
|
||||
expect(isTextMimeType('application/pdf')).toBe(false);
|
||||
expect(isTextMimeType('application/octet-stream')).toBe(false);
|
||||
expect(isTextMimeType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPdfMimeType', () => {
|
||||
it('returns true for PDF mime type', () => {
|
||||
expect(isPdfMimeType('application/pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-PDF types', () => {
|
||||
expect(isPdfMimeType('text/plain')).toBe(false);
|
||||
expect(isPdfMimeType('image/png')).toBe(false);
|
||||
expect(isPdfMimeType('application/json')).toBe(false);
|
||||
expect(isPdfMimeType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTextExtension', () => {
|
||||
describe('code files', () => {
|
||||
it('recognizes JavaScript/TypeScript files', () => {
|
||||
expect(isTextExtension('app.js')).toBe(true);
|
||||
expect(isTextExtension('component.jsx')).toBe(true);
|
||||
expect(isTextExtension('index.ts')).toBe(true);
|
||||
expect(isTextExtension('App.tsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes Python files', () => {
|
||||
expect(isTextExtension('script.py')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes Go files', () => {
|
||||
expect(isTextExtension('main.go')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes Rust files', () => {
|
||||
expect(isTextExtension('lib.rs')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes C/C++ files', () => {
|
||||
expect(isTextExtension('main.c')).toBe(true);
|
||||
expect(isTextExtension('util.cpp')).toBe(true);
|
||||
expect(isTextExtension('header.h')).toBe(true);
|
||||
expect(isTextExtension('class.hpp')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config files', () => {
|
||||
it('recognizes JSON/YAML/TOML', () => {
|
||||
expect(isTextExtension('config.json')).toBe(true);
|
||||
expect(isTextExtension('docker-compose.yaml')).toBe(true);
|
||||
expect(isTextExtension('config.yml')).toBe(true);
|
||||
expect(isTextExtension('Cargo.toml')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes dotfiles', () => {
|
||||
expect(isTextExtension('.gitignore')).toBe(true);
|
||||
expect(isTextExtension('.dockerignore')).toBe(true);
|
||||
expect(isTextExtension('.env')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web files', () => {
|
||||
it('recognizes HTML/CSS', () => {
|
||||
expect(isTextExtension('index.html')).toBe(true);
|
||||
expect(isTextExtension('page.htm')).toBe(true);
|
||||
expect(isTextExtension('styles.css')).toBe(true);
|
||||
expect(isTextExtension('app.scss')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes framework files', () => {
|
||||
expect(isTextExtension('App.svelte')).toBe(true);
|
||||
expect(isTextExtension('Component.vue')).toBe(true);
|
||||
expect(isTextExtension('Page.astro')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text files', () => {
|
||||
it('recognizes markdown', () => {
|
||||
expect(isTextExtension('README.md')).toBe(true);
|
||||
expect(isTextExtension('docs.markdown')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes plain text', () => {
|
||||
expect(isTextExtension('notes.txt')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(isTextExtension('FILE.TXT')).toBe(true);
|
||||
expect(isTextExtension('Script.PY')).toBe(true);
|
||||
expect(isTextExtension('README.MD')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unknown extensions', () => {
|
||||
expect(isTextExtension('image.png')).toBe(false);
|
||||
expect(isTextExtension('document.pdf')).toBe(false);
|
||||
expect(isTextExtension('archive.zip')).toBe(false);
|
||||
expect(isTextExtension('binary.exe')).toBe(false);
|
||||
expect(isTextExtension('noextension')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Constants are defined', () => {
|
||||
it('IMAGE_MIME_TYPES has expected values', () => {
|
||||
expect(IMAGE_MIME_TYPES).toContain('image/jpeg');
|
||||
expect(IMAGE_MIME_TYPES).toContain('image/png');
|
||||
expect(IMAGE_MIME_TYPES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('TEXT_MIME_TYPES has expected values', () => {
|
||||
expect(TEXT_MIME_TYPES).toContain('text/plain');
|
||||
expect(TEXT_MIME_TYPES).toContain('application/json');
|
||||
expect(TEXT_MIME_TYPES.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('TEXT_FILE_EXTENSIONS has expected values', () => {
|
||||
expect(TEXT_FILE_EXTENSIONS).toContain('.ts');
|
||||
expect(TEXT_FILE_EXTENSIONS).toContain('.py');
|
||||
expect(TEXT_FILE_EXTENSIONS).toContain('.md');
|
||||
expect(TEXT_FILE_EXTENSIONS.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
211
frontend/src/lib/utils/export.test.ts
Normal file
211
frontend/src/lib/utils/export.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Export utility tests
|
||||
*
|
||||
* Tests export formatting, filename generation, and share encoding
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateFilename,
|
||||
generatePreview,
|
||||
encodeShareableData,
|
||||
decodeShareableData,
|
||||
type ShareableData
|
||||
} from './export';
|
||||
|
||||
describe('generateFilename', () => {
|
||||
it('creates filename with title and timestamp', () => {
|
||||
const filename = generateFilename('My Chat', 'md');
|
||||
expect(filename).toMatch(/^My_Chat_\d{4}-\d{2}-\d{2}\.md$/);
|
||||
});
|
||||
|
||||
it('replaces spaces with underscores', () => {
|
||||
const filename = generateFilename('Hello World Test', 'json');
|
||||
expect(filename).toContain('Hello_World_Test');
|
||||
});
|
||||
|
||||
it('removes Windows-unsafe characters', () => {
|
||||
const filename = generateFilename('File<with>special:chars/and|more?*"quotes', 'md');
|
||||
expect(filename).not.toMatch(/[<>:"/\\|?*]/);
|
||||
});
|
||||
|
||||
it('collapses multiple underscores', () => {
|
||||
const filename = generateFilename('Multiple spaces here', 'md');
|
||||
expect(filename).not.toContain('__');
|
||||
});
|
||||
|
||||
it('trims leading and trailing underscores from title', () => {
|
||||
const filename = generateFilename(' spaced ', 'md');
|
||||
// Title part should not have leading underscore, but underscore before date is expected
|
||||
expect(filename).toMatch(/^spaced_\d{4}/);
|
||||
});
|
||||
|
||||
it('limits title length to 50 characters', () => {
|
||||
const longTitle = 'a'.repeat(100);
|
||||
const filename = generateFilename(longTitle, 'md');
|
||||
// Should have: max 50 chars + underscore + date (10 chars) + .md (3 chars)
|
||||
expect(filename.length).toBeLessThanOrEqual(50 + 1 + 10 + 3);
|
||||
});
|
||||
|
||||
it('handles empty title', () => {
|
||||
const filename = generateFilename('', 'md');
|
||||
// Empty title produces underscore prefix before timestamp
|
||||
expect(filename).toMatch(/_\d{4}-\d{2}-\d{2}\.md$/);
|
||||
expect(filename).toContain('.md');
|
||||
});
|
||||
|
||||
it('supports different extensions', () => {
|
||||
expect(generateFilename('test', 'json')).toMatch(/\.json$/);
|
||||
expect(generateFilename('test', 'txt')).toMatch(/\.txt$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePreview', () => {
|
||||
it('returns full content if under maxLines', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
expect(generatePreview(content, 10)).toBe(content);
|
||||
});
|
||||
|
||||
it('returns full content if exactly at maxLines', () => {
|
||||
const content = 'line1\nline2\nline3';
|
||||
expect(generatePreview(content, 3)).toBe(content);
|
||||
});
|
||||
|
||||
it('truncates content over maxLines', () => {
|
||||
const content = 'line1\nline2\nline3\nline4\nline5';
|
||||
const preview = generatePreview(content, 3);
|
||||
expect(preview).toBe('line1\nline2\nline3\n...');
|
||||
});
|
||||
|
||||
it('uses default maxLines of 10', () => {
|
||||
const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`);
|
||||
const content = lines.join('\n');
|
||||
const preview = generatePreview(content);
|
||||
expect(preview.split('\n').length).toBe(11); // 10 lines + "..."
|
||||
expect(preview).toContain('...');
|
||||
});
|
||||
|
||||
it('handles single line content', () => {
|
||||
const content = 'single line';
|
||||
expect(generatePreview(content, 5)).toBe(content);
|
||||
});
|
||||
|
||||
it('handles empty content', () => {
|
||||
expect(generatePreview('', 5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeShareableData / decodeShareableData', () => {
|
||||
const testData: ShareableData = {
|
||||
version: 1,
|
||||
title: 'Test Chat',
|
||||
model: 'llama3:8b',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello', timestamp: '2024-01-01T00:00:00Z' },
|
||||
{ role: 'assistant', content: 'Hi there!', timestamp: '2024-01-01T00:00:01Z' }
|
||||
]
|
||||
};
|
||||
|
||||
it('encodes data to base64 string', () => {
|
||||
const encoded = encodeShareableData(testData);
|
||||
expect(typeof encoded).toBe('string');
|
||||
expect(encoded.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('decodes back to original data', () => {
|
||||
const encoded = encodeShareableData(testData);
|
||||
const decoded = decodeShareableData(encoded);
|
||||
expect(decoded).toEqual(testData);
|
||||
});
|
||||
|
||||
it('handles UTF-8 characters', () => {
|
||||
const dataWithUnicode: ShareableData = {
|
||||
version: 1,
|
||||
title: '你好世界 🌍',
|
||||
model: 'test',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Привет мир', timestamp: '2024-01-01T00:00:00Z' }
|
||||
]
|
||||
};
|
||||
|
||||
const encoded = encodeShareableData(dataWithUnicode);
|
||||
const decoded = decodeShareableData(encoded);
|
||||
expect(decoded).toEqual(dataWithUnicode);
|
||||
});
|
||||
|
||||
it('handles special characters in content', () => {
|
||||
const dataWithSpecialChars: ShareableData = {
|
||||
version: 1,
|
||||
title: 'Test',
|
||||
model: 'test',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Code: `const x = 1;` and "quotes" and \'apostrophes\'',
|
||||
timestamp: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const encoded = encodeShareableData(dataWithSpecialChars);
|
||||
const decoded = decodeShareableData(encoded);
|
||||
expect(decoded).toEqual(dataWithSpecialChars);
|
||||
});
|
||||
|
||||
it('handles empty messages array', () => {
|
||||
const dataEmpty: ShareableData = {
|
||||
version: 1,
|
||||
title: 'Empty',
|
||||
model: 'test',
|
||||
messages: []
|
||||
};
|
||||
|
||||
const encoded = encodeShareableData(dataEmpty);
|
||||
const decoded = decodeShareableData(encoded);
|
||||
expect(decoded).toEqual(dataEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeShareableData validation', () => {
|
||||
it('returns null for invalid base64', () => {
|
||||
expect(decodeShareableData('not-valid-base64!@#$')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
const invalidJson = btoa(encodeURIComponent('not json'));
|
||||
expect(decodeShareableData(invalidJson)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing version', () => {
|
||||
const noVersion = { title: 'test', model: 'test', messages: [] };
|
||||
const encoded = btoa(encodeURIComponent(JSON.stringify(noVersion)));
|
||||
expect(decodeShareableData(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-numeric version', () => {
|
||||
const badVersion = { version: 'one', title: 'test', model: 'test', messages: [] };
|
||||
const encoded = btoa(encodeURIComponent(JSON.stringify(badVersion)));
|
||||
expect(decodeShareableData(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing messages array', () => {
|
||||
const noMessages = { version: 1, title: 'test', model: 'test' };
|
||||
const encoded = btoa(encodeURIComponent(JSON.stringify(noMessages)));
|
||||
expect(decodeShareableData(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-array messages', () => {
|
||||
const badMessages = { version: 1, title: 'test', model: 'test', messages: 'not an array' };
|
||||
const encoded = btoa(encodeURIComponent(JSON.stringify(badMessages)));
|
||||
expect(decodeShareableData(encoded)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns valid data for minimal valid structure', () => {
|
||||
const minimal = { version: 1, messages: [] };
|
||||
const encoded = btoa(encodeURIComponent(JSON.stringify(minimal)));
|
||||
const decoded = decodeShareableData(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.version).toBe(1);
|
||||
expect(decoded?.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
246
frontend/src/lib/utils/file-processor.test.ts
Normal file
246
frontend/src/lib/utils/file-processor.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* File processor utility tests
|
||||
*
|
||||
* Tests file type detection, formatting, and utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectFileType,
|
||||
formatFileSize,
|
||||
getFileIcon,
|
||||
formatAttachmentsForMessage
|
||||
} from './file-processor';
|
||||
import type { FileAttachment } from '$lib/types/attachment';
|
||||
|
||||
// Helper to create mock File objects
|
||||
function createMockFile(name: string, type: string, size: number = 1000): File {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
size,
|
||||
lastModified: Date.now(),
|
||||
webkitRelativePath: '',
|
||||
slice: () => new Blob(),
|
||||
stream: () => new ReadableStream(),
|
||||
text: () => Promise.resolve(''),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
||||
} as File;
|
||||
}
|
||||
|
||||
describe('detectFileType', () => {
|
||||
describe('image types', () => {
|
||||
it('detects JPEG images', () => {
|
||||
expect(detectFileType(createMockFile('photo.jpg', 'image/jpeg'))).toBe('image');
|
||||
});
|
||||
|
||||
it('detects PNG images', () => {
|
||||
expect(detectFileType(createMockFile('icon.png', 'image/png'))).toBe('image');
|
||||
});
|
||||
|
||||
it('detects GIF images', () => {
|
||||
expect(detectFileType(createMockFile('anim.gif', 'image/gif'))).toBe('image');
|
||||
});
|
||||
|
||||
it('detects WebP images', () => {
|
||||
expect(detectFileType(createMockFile('photo.webp', 'image/webp'))).toBe('image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF type', () => {
|
||||
it('detects PDF files', () => {
|
||||
expect(detectFileType(createMockFile('doc.pdf', 'application/pdf'))).toBe('pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('text types by mime', () => {
|
||||
it('detects plain text', () => {
|
||||
expect(detectFileType(createMockFile('readme.txt', 'text/plain'))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects markdown', () => {
|
||||
expect(detectFileType(createMockFile('doc.md', 'text/markdown'))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects HTML', () => {
|
||||
expect(detectFileType(createMockFile('page.html', 'text/html'))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects JSON', () => {
|
||||
expect(detectFileType(createMockFile('data.json', 'application/json'))).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('text types by extension fallback', () => {
|
||||
it('detects TypeScript by extension', () => {
|
||||
expect(detectFileType(createMockFile('app.ts', ''))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects Python by extension', () => {
|
||||
expect(detectFileType(createMockFile('script.py', ''))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects Go by extension', () => {
|
||||
expect(detectFileType(createMockFile('main.go', ''))).toBe('text');
|
||||
});
|
||||
|
||||
it('detects YAML by extension', () => {
|
||||
expect(detectFileType(createMockFile('config.yaml', ''))).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported types', () => {
|
||||
it('returns null for binary files', () => {
|
||||
expect(detectFileType(createMockFile('app.exe', 'application/octet-stream'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for archives', () => {
|
||||
expect(detectFileType(createMockFile('archive.zip', 'application/zip'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
expect(detectFileType(createMockFile('data.xyz', ''))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('is case insensitive for mime types', () => {
|
||||
expect(detectFileType(createMockFile('img.jpg', 'IMAGE/JPEG'))).toBe('image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('formats bytes', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(100)).toBe('100 B');
|
||||
expect(formatFileSize(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatFileSize(10240)).toBe('10.0 KB');
|
||||
expect(formatFileSize(1024 * 1024 - 1)).toBe('1024.0 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1024 * 1024 * 5)).toBe('5.0 MB');
|
||||
expect(formatFileSize(1024 * 1024 * 10.5)).toBe('10.5 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileIcon', () => {
|
||||
it('returns image icon for images', () => {
|
||||
expect(getFileIcon('image')).toBe('🖼️');
|
||||
});
|
||||
|
||||
it('returns document icon for PDFs', () => {
|
||||
expect(getFileIcon('pdf')).toBe('📄');
|
||||
});
|
||||
|
||||
it('returns note icon for text', () => {
|
||||
expect(getFileIcon('text')).toBe('📝');
|
||||
});
|
||||
|
||||
it('returns paperclip for unknown types', () => {
|
||||
expect(getFileIcon('unknown' as 'text')).toBe('📎');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAttachmentsForMessage', () => {
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(formatAttachmentsForMessage([])).toBe('');
|
||||
});
|
||||
|
||||
it('filters out attachments without text content', () => {
|
||||
const attachments: FileAttachment[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'image',
|
||||
filename: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1000
|
||||
}
|
||||
];
|
||||
expect(formatAttachmentsForMessage(attachments)).toBe('');
|
||||
});
|
||||
|
||||
it('formats text attachment with XML tags', () => {
|
||||
const attachments: FileAttachment[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'text',
|
||||
filename: 'readme.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 100,
|
||||
textContent: 'Hello, World!'
|
||||
}
|
||||
];
|
||||
const result = formatAttachmentsForMessage(attachments);
|
||||
expect(result).toContain('<file name="readme.txt"');
|
||||
expect(result).toContain('size="100 B"');
|
||||
expect(result).toContain('Hello, World!');
|
||||
expect(result).toContain('</file>');
|
||||
});
|
||||
|
||||
it('includes truncated attribute when content is truncated', () => {
|
||||
const attachments: FileAttachment[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'text',
|
||||
filename: 'large.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 1000000,
|
||||
textContent: 'Content...',
|
||||
truncated: true,
|
||||
originalLength: 1000000
|
||||
}
|
||||
];
|
||||
const result = formatAttachmentsForMessage(attachments);
|
||||
expect(result).toContain('truncated="true"');
|
||||
});
|
||||
|
||||
it('escapes XML special characters in filename', () => {
|
||||
const attachments: FileAttachment[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'text',
|
||||
filename: 'file<with>&"special\'chars.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 100,
|
||||
textContent: 'content'
|
||||
}
|
||||
];
|
||||
const result = formatAttachmentsForMessage(attachments);
|
||||
expect(result).toContain('<');
|
||||
expect(result).toContain('>');
|
||||
expect(result).toContain('&');
|
||||
expect(result).toContain('"');
|
||||
expect(result).toContain(''');
|
||||
});
|
||||
|
||||
it('formats multiple attachments separated by newlines', () => {
|
||||
const attachments: FileAttachment[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'text',
|
||||
filename: 'file1.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 100,
|
||||
textContent: 'Content 1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'text',
|
||||
filename: 'file2.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 200,
|
||||
textContent: 'Content 2'
|
||||
}
|
||||
];
|
||||
const result = formatAttachmentsForMessage(attachments);
|
||||
expect(result).toContain('file1.txt');
|
||||
expect(result).toContain('file2.txt');
|
||||
expect(result.split('</file>').length - 1).toBe(2);
|
||||
});
|
||||
});
|
||||
238
frontend/src/lib/utils/import.test.ts
Normal file
238
frontend/src/lib/utils/import.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Import utility tests
|
||||
*
|
||||
* Tests import validation and file size formatting
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateImport, formatFileSize } from './import';
|
||||
|
||||
describe('validateImport', () => {
|
||||
describe('invalid inputs', () => {
|
||||
it('rejects null', () => {
|
||||
const result = validateImport(null);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid file: not a valid JSON object');
|
||||
});
|
||||
|
||||
it('rejects undefined', () => {
|
||||
const result = validateImport(undefined);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
expect(validateImport('string').valid).toBe(false);
|
||||
expect(validateImport(123).valid).toBe(false);
|
||||
expect(validateImport([]).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required fields', () => {
|
||||
it('requires id field', () => {
|
||||
const data = { title: 'Test', model: 'test', messages: [] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing or invalid conversation ID');
|
||||
});
|
||||
|
||||
it('requires title field', () => {
|
||||
const data = { id: '123', model: 'test', messages: [] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing or invalid conversation title');
|
||||
});
|
||||
|
||||
it('requires model field', () => {
|
||||
const data = { id: '123', title: 'Test', messages: [] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing or invalid model name');
|
||||
});
|
||||
|
||||
it('requires messages array', () => {
|
||||
const data = { id: '123', title: 'Test', model: 'test' };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing or invalid messages array');
|
||||
});
|
||||
|
||||
it('requires messages to be an array', () => {
|
||||
const data = { id: '123', title: 'Test', model: 'test', messages: 'not array' };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing or invalid messages array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message validation', () => {
|
||||
const baseData = { id: '123', title: 'Test', model: 'test' };
|
||||
|
||||
it('requires role in messages', () => {
|
||||
const data = { ...baseData, messages: [{ content: 'hello' }] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Message 1: missing or invalid role');
|
||||
});
|
||||
|
||||
it('requires content in messages', () => {
|
||||
const data = { ...baseData, messages: [{ role: 'user' }] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Message 1: missing or invalid content');
|
||||
});
|
||||
|
||||
it('warns on unknown role', () => {
|
||||
const data = { ...baseData, messages: [{ role: 'unknown', content: 'test' }] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toContain('Message 1: unknown role "unknown"');
|
||||
});
|
||||
|
||||
it('accepts valid roles', () => {
|
||||
const roles = ['user', 'assistant', 'system', 'tool'];
|
||||
for (const role of roles) {
|
||||
const data = { ...baseData, messages: [{ role, content: 'test' }] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.filter(w => w.includes('unknown role'))).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('warns on invalid images format', () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
messages: [{ role: 'user', content: 'test', images: 'not-array' }]
|
||||
};
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toContain('Message 1: invalid images format, will be ignored');
|
||||
});
|
||||
|
||||
it('accepts valid images array', () => {
|
||||
const data = {
|
||||
...baseData,
|
||||
messages: [{ role: 'user', content: 'test', images: ['base64data'] }]
|
||||
};
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.filter(w => w.includes('images'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns on empty messages', () => {
|
||||
const data = { ...baseData, messages: [] };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toContain('Conversation has no messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date validation', () => {
|
||||
const baseData = { id: '123', title: 'Test', model: 'test', messages: [] };
|
||||
|
||||
it('warns on invalid creation date', () => {
|
||||
const data = { ...baseData, createdAt: 'not-a-date' };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings).toContain('Invalid creation date, will use current time');
|
||||
});
|
||||
|
||||
it('accepts valid ISO date', () => {
|
||||
const data = { ...baseData, createdAt: '2024-01-01T00:00:00Z' };
|
||||
const result = validateImport(data);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.filter(w => w.includes('date'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('valid data conversion', () => {
|
||||
it('returns converted data on success', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
title: 'Test Chat',
|
||||
model: 'llama3:8b',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
exportedAt: '2024-01-02T00:00:00Z',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello', timestamp: '2024-01-01T00:00:00Z' },
|
||||
{ role: 'assistant', content: 'Hi!', timestamp: '2024-01-01T00:00:01Z' }
|
||||
]
|
||||
};
|
||||
const result = validateImport(data);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data?.id).toBe('123');
|
||||
expect(result.data?.title).toBe('Test Chat');
|
||||
expect(result.data?.model).toBe('llama3:8b');
|
||||
expect(result.data?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves images in converted data', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
title: 'Test',
|
||||
model: 'test',
|
||||
messages: [{ role: 'user', content: 'test', images: ['img1', 'img2'] }]
|
||||
};
|
||||
const result = validateImport(data);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data?.messages[0].images).toEqual(['img1', 'img2']);
|
||||
});
|
||||
|
||||
it('provides defaults for missing optional fields', () => {
|
||||
const data = {
|
||||
id: '123',
|
||||
title: 'Test',
|
||||
model: 'test',
|
||||
messages: [{ role: 'user', content: 'test' }]
|
||||
};
|
||||
const result = validateImport(data);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.data?.createdAt).toBeDefined();
|
||||
expect(result.data?.exportedAt).toBeDefined();
|
||||
expect(result.data?.messages[0].timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error accumulation', () => {
|
||||
it('reports multiple errors', () => {
|
||||
const data = { messages: 'not-array' };
|
||||
const result = validateImport(data);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(1);
|
||||
expect(result.errors).toContain('Missing or invalid conversation ID');
|
||||
expect(result.errors).toContain('Missing or invalid conversation title');
|
||||
expect(result.errors).toContain('Missing or invalid model name');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('formats zero bytes', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatFileSize(100)).toBe('100 B');
|
||||
expect(formatFileSize(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatFileSize(1024)).toBe('1 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatFileSize(10240)).toBe('10 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1 MB');
|
||||
expect(formatFileSize(1024 * 1024 * 5.5)).toBe('5.5 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB');
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 2.5)).toBe('2.5 GB');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user