Add Open AI Comatible API option in model configuration

Add unit tests for various UI components and context hooks

- Created tests for Progress, RadioGroup, Select, Separator, Sheet, Sidebar, Skeleton, Sonner, Switch, Table, Tabs, Textarea, Tooltip components.
- Implemented tests for UserConfigContext and other context hooks.
- Added utility tests for filters and utils functions.
- Set up MSW for API mocking in tests.
- Configured Vitest for testing environment with necessary setup.
This commit is contained in:
Chris Briddock 2026-05-25 19:26:41 +01:00
parent bbb4f91a27
commit 20617db37a
No known key found for this signature in database
44 changed files with 8426 additions and 5661 deletions

11565
ui/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "1.31.0",
"version": "1.31.1",
"private": true,
"scripts": {
"dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack",
@ -59,18 +59,33 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@hey-api/openapi-ts": "^0.95.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/source-map-support": "^0.5.10",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.7",
"@vitest/ui": "^4.1.7",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.3.3",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"jsdom": "^29.1.1",
"msw": "^2.14.6",
"source-map-support": "^0.5.21",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.7"
},
"overrides": {
"lucide-react": "^0.505.0"
}
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from './alert-dialog';
describe('AlertDialog', () => {
it('renders trigger', () => {
render(
<AlertDialog>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Title</AlertDialogTitle>
<AlertDialogDescription>Desc</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<AlertDialog open>
<AlertDialogTrigger>Open</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Alert Title</AlertDialogTitle>
<AlertDialogDescription>Alert description</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
expect(screen.getByText('Alert Title')).toBeInTheDocument();
expect(screen.getByText('Alert description')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Confirm')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Badge } from './badge';
describe('Badge', () => {
it('renders children text', () => {
render(<Badge>New</Badge>);
expect(screen.getByText('New')).toBeInTheDocument();
});
it('applies default variant classes', () => {
const { container } = render(<Badge>Default</Badge>);
expect(container.firstChild).toHaveClass('bg-primary');
});
it('applies secondary variant classes', () => {
const { container } = render(<Badge variant="secondary">Secondary</Badge>);
expect(container.firstChild).toHaveClass('bg-secondary');
});
it('applies destructive variant classes', () => {
const { container } = render(<Badge variant="destructive">Error</Badge>);
expect(container.firstChild).toHaveClass('bg-destructive');
});
it('applies outline variant classes', () => {
const { container } = render(<Badge variant="outline">Outline</Badge>);
expect(container.firstChild).toHaveClass('text-foreground');
});
it('applies custom className', () => {
const { container } = render(<Badge className="custom-badge">Custom</Badge>);
expect(container.firstChild).toHaveClass('custom-badge');
});
});

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Button } from './button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
screen.getByRole('button').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies default variant classes', () => {
const { container } = render(<Button>Default</Button>);
expect(container.firstChild).toHaveClass('bg-primary');
});
it('applies destructive variant classes', () => {
const { container } = render(<Button variant="destructive">Delete</Button>);
expect(container.firstChild).toHaveClass('bg-destructive');
});
it('applies outline variant classes', () => {
const { container } = render(<Button variant="outline">Outline</Button>);
expect(container.firstChild).toHaveClass('border');
});
it('applies size classes', () => {
const { container } = render(<Button size="sm">Small</Button>);
expect(container.firstChild).toHaveClass('h-8');
});
it('renders as child when asChild is true', () => {
render(
<Button asChild>
<a href="/">Link Button</a>
</Button>
);
expect(screen.getByRole('link', { name: 'Link Button' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Calendar } from './calendar';
describe('Calendar', () => {
it('renders with default props', () => {
const { container } = render(<Calendar />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts custom className', () => {
const { container } = render(<Calendar className="custom-calendar" />);
expect(container.firstChild).toHaveClass('custom-calendar');
});
it('accepts showOutsideDays prop', () => {
const { container } = render(<Calendar showOutsideDays={false} />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts captionLayout prop', () => {
const { container } = render(<Calendar captionLayout="dropdown" />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts buttonVariant prop', () => {
const { container } = render(<Calendar buttonVariant="outline" />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts fromDate and toDate constraints', () => {
const fromDate = new Date(2024, 0, 1);
const toDate = new Date(2024, 11, 31);
const { container } = render(<Calendar fromDate={fromDate} toDate={toDate} />);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts classNames prop', () => {
const { container } = render(
<Calendar classNames={{ root: 'custom-root' }} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts formatters prop', () => {
const { container } = render(
<Calendar formatters={{ formatMonthDropdown: (d) => d.toLocaleString('default', { month: 'long' }) }} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('accepts components prop', () => {
const { container } = render(<Calendar components={{}} />);
expect(container.firstChild).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Card content</Card>);
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Card className="custom-card">Content</Card>);
expect(container.firstChild).toHaveClass('custom-card');
});
it('renders CardHeader with padding', () => {
const { container } = render(<CardHeader>Header</CardHeader>);
expect(container.firstChild).toHaveClass('p-6');
});
it('renders CardTitle as h3', () => {
render(<CardTitle>Title</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
});
it('renders CardDescription', () => {
render(<CardDescription>Desc</CardDescription>);
expect(screen.getByText('Desc')).toBeInTheDocument();
});
it('renders CardContent', () => {
render(<CardContent>Body</CardContent>);
expect(screen.getByText('Body')).toBeInTheDocument();
});
it('renders CardFooter', () => {
render(<CardFooter>Footer</CardFooter>);
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Checkbox } from './checkbox';
describe('Checkbox', () => {
it('renders unchecked by default', () => {
render(<Checkbox aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
it('renders checked when checked prop is true', () => {
render(<Checkbox checked aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).toBeChecked();
});
it('handles onCheckedChange events', async () => {
const handleChange = vi.fn();
render(<Checkbox onCheckedChange={handleChange} aria-label="Accept terms" />);
await userEvent.click(screen.getByRole('checkbox'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('is disabled when disabled prop is true', () => {
render(<Checkbox disabled aria-label="Accept terms" />);
expect(screen.getByRole('checkbox')).toBeDisabled();
});
});

View file

@ -0,0 +1,69 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@/test/test-utils';
import { ChoiceChips } from './choice-chips';
describe('ChoiceChips', () => {
const defaultProps = {
options: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
value: 'option1',
onChange: vi.fn(),
};
it('renders all options', () => {
render(<ChoiceChips {...defaultProps} />);
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
});
it('renders selected option with primary styles', () => {
const { container } = render(<ChoiceChips {...defaultProps} />);
const selectedButton = screen.getByText('Option 1');
expect(selectedButton).toHaveClass('bg-primary');
expect(selectedButton).toHaveClass('text-primary-foreground');
});
it('renders unselected options with secondary styles', () => {
const { container } = render(<ChoiceChips {...defaultProps} />);
const unselectedButton = screen.getByText('Option 2');
expect(unselectedButton).toHaveClass('bg-secondary');
expect(unselectedButton).toHaveClass('text-secondary-foreground');
});
it('calls onChange when clicking an option', () => {
const onChange = vi.fn();
render(<ChoiceChips {...defaultProps} onChange={onChange} />);
fireEvent.click(screen.getByText('Option 2'));
expect(onChange).toHaveBeenCalledWith('option2');
});
it('applies custom className', () => {
const { container } = render(
<ChoiceChips {...defaultProps} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('handles empty options array', () => {
const { container } = render(
<ChoiceChips options={[]} value="" onChange={vi.fn()} />
);
expect(container.firstChild).toBeInTheDocument();
});
it('handles single option', () => {
render(
<ChoiceChips
options={[{ value: 'only', label: 'Only Option' }]}
value="only"
onChange={vi.fn()}
/>
);
expect(screen.getByText('Only Option')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './collapsible';
describe('Collapsible', () => {
it('renders trigger and content', () => {
render(
<Collapsible>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Hidden content</CollapsibleContent>
</Collapsible>
);
expect(screen.getByText('Toggle')).toBeInTheDocument();
});
it('shows content when open', () => {
render(
<Collapsible open>
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
<CollapsibleContent>Visible content</CollapsibleContent>
</Collapsible>
);
expect(screen.getByText('Visible content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from './dialog';
describe('Dialog', () => {
it('renders dialog trigger', () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Desc</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders dialog content when open', () => {
render(
<Dialog open>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog description</DialogDescription>
</DialogHeader>
<DialogFooter>Footer</DialogFooter>
</DialogContent>
</Dialog>
);
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
expect(screen.getByText('Dialog description')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from './dropdown-menu';
describe('DropdownMenu', () => {
it('renders trigger', () => {
render(
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Label</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Item</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Label')).toBeInTheDocument();
expect(screen.getByText('Item')).toBeInTheDocument();
});
it('renders checkbox item', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuCheckboxItem checked>Check</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('Check')).toBeInTheDocument();
});
it('renders radio group', () => {
render(
<DropdownMenu open>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value="a">
<DropdownMenuRadioItem value="a">A</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
expect(screen.getByText('A')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Input } from './input';
describe('Input', () => {
it('renders with placeholder', () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
});
it('accepts and displays value', () => {
render(<Input value="test value" readOnly />);
expect(screen.getByDisplayValue('test value')).toBeInTheDocument();
});
it('handles onChange events', () => {
const handleChange = vi.fn();
render(<Input onChange={handleChange} />);
const input = screen.getByRole('textbox');
input.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<Input disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('renders different input types', () => {
const { rerender } = render(<Input type="text" />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
rerender(<Input type="password" aria-label="Password" />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
});
it('applies custom className', () => {
const { container } = render(<Input className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});

View file

@ -0,0 +1,143 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@/test/test-utils';
import { JsonEditor, validateJson } from './json-editor';
describe('JsonEditor', () => {
const defaultProps = {
value: '{"key": "value"}',
onChange: vi.fn(),
};
it('renders with value', () => {
render(<JsonEditor {...defaultProps} />);
expect(screen.getByDisplayValue('{"key": "value"}')).toBeInTheDocument();
});
it('renders with label', () => {
render(<JsonEditor {...defaultProps} label="JSON Config" />);
expect(screen.getByText('JSON Config')).toBeInTheDocument();
});
it('renders with description', () => {
render(<JsonEditor {...defaultProps} description="Enter JSON configuration" />);
expect(screen.getByText('Enter JSON configuration')).toBeInTheDocument();
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<JsonEditor {...defaultProps} onChange={onChange} />);
const textarea = screen.getByDisplayValue('{"key": "value"}');
fireEvent.change(textarea, { target: { value: '{"new": "value"}' } });
expect(onChange).toHaveBeenCalledWith('{"new": "value"}');
});
it('shows error message for invalid JSON', () => {
render(<JsonEditor {...defaultProps} value="invalid json" error="Invalid JSON" />);
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
});
it('renders copy button when showCopyButton is true', () => {
render(<JsonEditor {...defaultProps} showCopyButton />);
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(
<JsonEditor {...defaultProps} className="custom-class" />
);
expect(container.firstChild).toHaveClass('custom-class');
});
it('applies custom minHeight', () => {
render(<JsonEditor {...defaultProps} minHeight="200px" />);
const textarea = screen.getByDisplayValue('{"key": "value"}');
expect(textarea).toHaveStyle({ minHeight: '200px' });
});
});
describe('validateJson', () => {
it('returns valid for empty string', () => {
const result = validateJson('');
expect(result.valid).toBe(true);
});
it('returns valid for empty object', () => {
const result = validateJson('{}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({});
});
it('returns valid for empty array', () => {
const result = validateJson('[]');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual([]);
});
it('returns valid for valid JSON object', () => {
const result = validateJson('{"key": "value"}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ key: 'value' });
});
it('returns valid for valid JSON array', () => {
const result = validateJson('["a", "b", "c"]');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual(['a', 'b', 'c']);
});
it('returns invalid for invalid JSON', () => {
const result = validateJson('{invalid}');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('detects unquoted template variables', () => {
const result = validateJson('{"key": {{variable}}}');
expect(result.valid).toBe(false);
expect(result.error).toContain('Template variables must be quoted');
});
it('detects trailing comma', () => {
const result = validateJson('{"key": "value",}');
expect(result.valid).toBe(false);
expect(result.error).toContain('Trailing comma');
});
it('detects single quotes', () => {
const result = validateJson("{'key': 'value'}");
expect(result.valid).toBe(false);
expect(result.error).toContain('double quotes');
});
it('detects unquoted string values', () => {
const result = validateJson('{key: value}');
expect(result.valid).toBe(false);
expect(result.error).toContain('quoted');
});
it('handles numbers', () => {
const result = validateJson('{"count": 42}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ count: 42 });
});
it('handles booleans', () => {
const result = validateJson('{"enabled": true}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ enabled: true });
});
it('handles null', () => {
const result = validateJson('{"value": null}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ value: null });
});
it('handles nested objects', () => {
const result = validateJson('{"outer": {"inner": "value"}}');
expect(result.valid).toBe(true);
expect(result.parsed).toEqual({ outer: { inner: 'value' } });
});
});

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Label } from './label';
describe('Label', () => {
it('renders children text', () => {
render(<Label>Email</Label>);
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('associates with input via htmlFor', () => {
render(<Label htmlFor="email-input">Email</Label>);
expect(screen.getByText('Email')).toHaveAttribute('for', 'email-input');
});
it('applies custom className', () => {
const { container } = render(<Label className="custom-label">Text</Label>);
expect(container.firstChild).toHaveClass('custom-label');
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Popover, PopoverTrigger, PopoverContent } from './popover';
describe('Popover', () => {
it('renders trigger', () => {
render(
<Popover>
<PopoverTrigger>Click me</PopoverTrigger>
<PopoverContent>Popover content</PopoverContent>
</Popover>
);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<Popover open>
<PopoverTrigger>Click</PopoverTrigger>
<PopoverContent>Content</PopoverContent>
</Popover>
);
expect(screen.getByText('Content')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Progress } from './progress';
describe('Progress', () => {
it('renders with default value', () => {
const { container } = render(<Progress value={50} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Progress value={30} className="custom-progress" />);
expect(container.firstChild).toHaveClass('custom-progress');
});
it('renders with zero value', () => {
const { container } = render(<Progress value={0} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toHaveStyle('transform: translateX(-100%)');
});
it('renders with full value', () => {
const { container } = render(<Progress value={100} />);
const indicator = container.querySelector('[data-slot="progress-indicator"]');
expect(indicator).toHaveStyle('transform: translateX(-0%)');
});
});

View file

@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { RadioGroup, RadioGroupItem } from './radio-group';
describe('RadioGroup', () => {
it('renders radio items', () => {
render(
<RadioGroup>
<RadioGroupItem value="a" aria-label="Option A" />
<RadioGroupItem value="b" aria-label="Option B" />
</RadioGroup>
);
expect(screen.getAllByRole('radio')).toHaveLength(2);
});
it('checks item when clicked', async () => {
const handleChange = vi.fn();
render(
<RadioGroup onValueChange={handleChange}>
<RadioGroupItem value="a" aria-label="Option A" />
</RadioGroup>
);
await userEvent.click(screen.getByRole('radio'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('applies custom className', () => {
const { container } = render(
<RadioGroup className="custom-group">
<RadioGroupItem value="a" aria-label="A" />
</RadioGroup>
);
expect(container.firstChild).toHaveClass('custom-group');
});
});

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel } from './select';
describe('Select', () => {
it('renders select trigger with placeholder', () => {
render(
<Select>
<SelectTrigger>
<SelectValue placeholder="Choose an option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
);
expect(screen.getByText('Choose an option')).toBeInTheDocument();
});
it('renders select with value', () => {
render(
<Select value="a">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
);
expect(screen.getByText('Option A')).toBeInTheDocument();
});
it('renders select group with label when open', () => {
render(
<Select open>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Group</SelectLabel>
<SelectItem value="a">A</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
expect(screen.getByText('Group')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Separator } from './separator';
describe('Separator', () => {
it('renders horizontal separator by default', () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'horizontal');
});
it('renders vertical separator', () => {
const { container } = render(<Separator orientation="vertical" />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'vertical');
});
it('has data-orientation attribute', () => {
const { container } = render(<Separator />);
expect(container.firstChild).toHaveAttribute('data-orientation', 'horizontal');
});
it('applies custom className', () => {
const { container } = render(<Separator className="custom-sep" />);
expect(container.firstChild).toHaveClass('custom-sep');
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from './sheet';
describe('Sheet', () => {
it('renders trigger', () => {
render(
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Title</SheetTitle>
<SheetDescription>Desc</SheetDescription>
</SheetHeader>
<SheetFooter>Footer</SheetFooter>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('renders content when open', () => {
render(
<Sheet open>
<SheetTrigger>Open</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Sheet Title</SheetTitle>
<SheetDescription>Sheet description</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
);
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
expect(screen.getByText('Sheet description')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarSeparator,
SidebarTrigger,
useSidebar,
SidebarInset
} from './sidebar';
describe('Sidebar', () => {
it('exports all expected components', () => {
expect(Sidebar).toBeDefined();
expect(SidebarContent).toBeDefined();
expect(SidebarFooter).toBeDefined();
expect(SidebarGroup).toBeDefined();
expect(SidebarGroupContent).toBeDefined();
expect(SidebarGroupLabel).toBeDefined();
expect(SidebarHeader).toBeDefined();
expect(SidebarInput).toBeDefined();
expect(SidebarMenu).toBeDefined();
expect(SidebarMenuAction).toBeDefined();
expect(SidebarMenuButton).toBeDefined();
expect(SidebarMenuItem).toBeDefined();
expect(SidebarMenuSkeleton).toBeDefined();
expect(SidebarMenuSub).toBeDefined();
expect(SidebarMenuSubButton).toBeDefined();
expect(SidebarMenuSubItem).toBeDefined();
expect(SidebarProvider).toBeDefined();
expect(SidebarSeparator).toBeDefined();
expect(SidebarTrigger).toBeDefined();
expect(SidebarInset).toBeDefined();
});
it('exports useSidebar hook', () => {
expect(useSidebar).toBeDefined();
expect(typeof useSidebar).toBe('function');
});
it('SidebarProvider is a function component', () => {
expect(typeof SidebarProvider).toBe('function');
});
it('Sidebar is a function component', () => {
expect(typeof Sidebar).toBe('function');
});
it('SidebarContent is a function component', () => {
expect(typeof SidebarContent).toBe('function');
});
it('SidebarHeader is a function component', () => {
expect(typeof SidebarHeader).toBe('function');
});
it('SidebarFooter is a function component', () => {
expect(typeof SidebarFooter).toBe('function');
});
it('SidebarGroup is a function component', () => {
expect(typeof SidebarGroup).toBe('function');
});
it('SidebarGroupLabel is a function component', () => {
expect(typeof SidebarGroupLabel).toBe('function');
});
it('SidebarGroupContent is a function component', () => {
expect(typeof SidebarGroupContent).toBe('function');
});
it('SidebarMenu is a function component', () => {
expect(typeof SidebarMenu).toBe('function');
});
it('SidebarMenuItem is a function component', () => {
expect(typeof SidebarMenuItem).toBe('function');
});
it('SidebarMenuButton is a function component', () => {
expect(typeof SidebarMenuButton).toBe('function');
});
it('SidebarMenuSkeleton is a function component', () => {
expect(typeof SidebarMenuSkeleton).toBe('function');
});
it('SidebarSeparator is a function component', () => {
expect(typeof SidebarSeparator).toBe('function');
});
it('SidebarTrigger is a function component', () => {
expect(typeof SidebarTrigger).toBe('function');
});
it('SidebarInput is a function component', () => {
expect(typeof SidebarInput).toBe('function');
});
it('SidebarInset is a function component', () => {
expect(typeof SidebarInset).toBe('function');
});
it('SidebarMenuAction is a function component', () => {
expect(typeof SidebarMenuAction).toBe('function');
});
it('SidebarMenuSub is a function component', () => {
expect(typeof SidebarMenuSub).toBe('function');
});
it('SidebarMenuSubButton is a function component', () => {
expect(typeof SidebarMenuSubButton).toBe('function');
});
it('SidebarMenuSubItem is a function component', () => {
expect(typeof SidebarMenuSubItem).toBe('function');
});
});

View file

@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render } from '@/test/test-utils';
import { Skeleton } from './skeleton';
describe('Skeleton', () => {
it('renders a div with animate-pulse', () => {
const { container } = render(<Skeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('applies custom className', () => {
const { container } = render(<Skeleton className="custom-skeleton" />);
expect(container.firstChild).toHaveClass('custom-skeleton');
});
it('renders children', () => {
render(<Skeleton>Loading</Skeleton>);
expect(document.querySelector('[data-slot="skeleton"]')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { Toaster } from './sonner';
// Note: sonner uses next-themes which requires a ThemeProvider wrapper
// These tests verify the component exports correctly
describe('Toaster', () => {
it('exports Toaster component', () => {
// Verify the named export exists
expect(Toaster).toBeDefined();
});
it('Toaster is a function component', () => {
expect(typeof Toaster).toBe('function');
});
});

View file

@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Switch } from './switch';
describe('Switch', () => {
it('renders unchecked by default', () => {
render(<Switch aria-label="Toggle" />);
expect(screen.getByRole('switch')).not.toBeChecked();
});
it('renders checked when checked prop is true', () => {
render(<Switch checked aria-label="Toggle" />);
expect(screen.getByRole('switch')).toBeChecked();
});
it('handles onCheckedChange events', async () => {
const handleChange = vi.fn();
render(<Switch onCheckedChange={handleChange} aria-label="Toggle" />);
await userEvent.click(screen.getByRole('switch'));
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
});
it('is disabled when disabled prop is true', () => {
render(<Switch disabled aria-label="Toggle" />);
expect(screen.getByRole('switch')).toBeDisabled();
});
});

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from './table';
describe('Table', () => {
it('renders table with header, body, and rows', () => {
render(
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>30</TableCell>
</TableRow>
</TableBody>
</Table>
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('renders table caption', () => {
render(
<Table>
<TableCaption>User list</TableCaption>
<TableBody><TableRow><TableCell>Data</TableCell></TableRow></TableBody>
</Table>
);
expect(screen.getByText('User list')).toBeInTheDocument();
});
it('renders table footer', () => {
render(
<Table>
<TableFooter>
<TableRow><TableCell>Total</TableCell></TableRow>
</TableFooter>
</Table>
);
expect(screen.getByText('Total')).toBeInTheDocument();
});
it('applies custom className', () => {
const { container } = render(<Table className="custom-table" />);
expect(container.querySelector('table')).toHaveClass('custom-table');
});
});

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
describe('Tabs', () => {
it('renders tabs with list and content', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
</Tabs>
);
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab 1' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tab 2' })).toBeInTheDocument();
});
it('shows default tab content', () => {
render(
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
</Tabs>
);
expect(screen.getByText('Content 1')).toBeInTheDocument();
});
it('applies custom className to Tabs', () => {
const { container } = render(
<Tabs className="custom-tabs" defaultValue="tab1">
<TabsList><TabsTrigger value="tab1">Tab</TabsTrigger></TabsList>
<TabsContent value="tab1">Content</TabsContent>
</Tabs>
);
expect(container.firstChild).toHaveClass('custom-tabs');
});
});

View file

@ -0,0 +1,33 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Textarea } from './textarea';
describe('Textarea', () => {
it('renders with placeholder', () => {
render(<Textarea placeholder="Enter description" />);
expect(screen.getByPlaceholderText('Enter description')).toBeInTheDocument();
});
it('accepts and displays value', () => {
render(<Textarea value="test content" readOnly />);
expect(screen.getByDisplayValue('test content')).toBeInTheDocument();
});
it('handles onChange events', () => {
const handleChange = vi.fn();
render(<Textarea onChange={handleChange} />);
const textarea = screen.getByRole('textbox');
textarea.click();
expect(handleChange).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<Textarea disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('applies custom className', () => {
const { container } = render(<Textarea className="custom-textarea" />);
expect(container.firstChild).toHaveClass('custom-textarea');
});
});

View file

@ -0,0 +1,25 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { Tooltip, TooltipTrigger, TooltipContent } from './tooltip';
describe('Tooltip', () => {
it('renders tooltip trigger', () => {
render(
<Tooltip>
<TooltipTrigger>Hover me</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
);
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('renders tooltip content when open', () => {
render(
<Tooltip open>
<TooltipTrigger>Trigger</TooltipTrigger>
<TooltipContent>Tooltip text</TooltipContent>
</Tooltip>
);
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip text');
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { UserConfigProvider, useUserConfig } from './UserConfigContext';
describe('UserConfigContext', () => {
it('exports UserConfigProvider component', () => {
expect(UserConfigProvider).toBeDefined();
expect(typeof UserConfigProvider).toBe('function');
});
it('exports useUserConfig hook', () => {
expect(useUserConfig).toBeDefined();
expect(typeof useUserConfig).toBe('function');
});
});
describe('Other Contexts', () => {
it('AppConfigContext exports provider and hook', async () => {
const module = await import('./AppConfigContext');
expect(module.AppConfigProvider).toBeDefined();
expect(module.useAppConfig).toBeDefined();
});
it('OnboardingContext exports provider and hook', async () => {
const module = await import('./OnboardingContext');
expect(module.OnboardingProvider).toBeDefined();
expect(module.useOnboarding).toBeDefined();
});
it('UnsavedChangesContext exports provider and hook', async () => {
const module = await import('./UnsavedChangesContext');
expect(module.UnsavedChangesProvider).toBeDefined();
expect(module.useUnsavedChanges).toBeDefined();
});
it('TelephonyConfigWarningsContext exports provider and hook', async () => {
const module = await import('./TelephonyConfigWarningsContext');
expect(module.TelephonyConfigWarningsProvider).toBeDefined();
expect(module.useTelephonyConfigWarnings).toBeDefined();
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useIsMobile } from './use-mobile';
describe('useIsMobile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns false when viewport is larger than breakpoint', () => {
// Mock matchMedia to return false (desktop)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
// Mock innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 1024,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it('returns true when viewport is smaller than breakpoint', () => {
// Mock matchMedia to return true (mobile)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: true,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
// Mock innerWidth
Object.defineProperty(window, 'innerWidth', {
writable: true,
value: 375,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it('is exported as a function', () => {
expect(useIsMobile).toBeDefined();
expect(typeof useIsMobile).toBe('function');
});
});

291
ui/src/lib/filters.test.ts Normal file
View file

@ -0,0 +1,291 @@
import { describe, it, expect } from 'vitest';
import {
getDefaultValue,
validateFilter,
encodeFiltersToURL,
decodeFiltersFromURL,
formatDateRange,
formatNumberRange,
getDatePresetValue
} from './filters';
import {
ActiveFilter,
DateRangeValue,
MultiSelectValue,
NumberRangeValue,
FilterAttribute
} from '@/types/filters';
describe('filters', () => {
describe('getDefaultValue', () => {
it('returns { from: null, to: null } for dateRange', () => {
expect(getDefaultValue('dateRange')).toEqual({ from: null, to: null });
});
it('returns { codes: [] } for multiSelect', () => {
expect(getDefaultValue('multiSelect')).toEqual({ codes: [] });
});
it('returns { value: null } for number', () => {
expect(getDefaultValue('number')).toEqual({ value: null });
});
it('returns { min: null, max: null } for numberRange', () => {
expect(getDefaultValue('numberRange')).toEqual({ min: null, max: null });
});
it('returns { status: "all" } for radio', () => {
expect(getDefaultValue('radio')).toEqual({ status: 'all' });
});
it('returns { codes: [] } for tags', () => {
expect(getDefaultValue('tags')).toEqual({ codes: [] });
});
it('returns { value: "" } for text', () => {
expect(getDefaultValue('text')).toEqual({ value: '' });
});
it('throws error for unknown type', () => {
expect(() => getDefaultValue('unknown' as any)).toThrow('Unknown filter type: unknown');
});
});
describe('validateFilter', () => {
const createFilter = (type: FilterAttribute['type'], value: any, config: any = {}): ActiveFilter => ({
attribute: {
id: 'test',
type,
label: 'Test',
config,
},
value,
isValid: true,
});
describe('dateRange', () => {
it('returns error when from is missing', () => {
const filter = createFilter('dateRange', { from: null, to: new Date() });
expect(validateFilter(filter)).toBe('Both dates are required');
});
it('returns error when to is missing', () => {
const filter = createFilter('dateRange', { from: new Date(), to: null });
expect(validateFilter(filter)).toBe('Both dates are required');
});
it('returns error when to is before from', () => {
const from = new Date('2024-01-15');
const to = new Date('2024-01-10');
const filter = createFilter('dateRange', { from, to });
expect(validateFilter(filter)).toBe('End date must be after start date');
});
it('returns error when range exceeds maxRangeDays', () => {
const from = new Date('2024-01-01');
const to = new Date('2024-02-01');
const filter = createFilter('dateRange', { from, to }, { maxRangeDays: 30 });
expect(validateFilter(filter)).toBe('Date range cannot exceed 30 days');
});
it('returns null for valid date range', () => {
const from = new Date('2024-01-01');
const to = new Date('2024-01-15');
const filter = createFilter('dateRange', { from, to });
expect(validateFilter(filter)).toBeNull();
});
});
describe('multiSelect', () => {
it('returns error when no codes selected', () => {
const filter = createFilter('multiSelect', { codes: [] });
expect(validateFilter(filter)).toBe('At least one option must be selected');
});
it('returns error when exceeds maxSelections', () => {
const filter = createFilter('multiSelect', { codes: ['a', 'b', 'c'] }, { maxSelections: 2 });
expect(validateFilter(filter)).toBe('Cannot select more than 2 options');
});
it('returns null for valid multiSelect', () => {
const filter = createFilter('multiSelect', { codes: ['a', 'b'] });
expect(validateFilter(filter)).toBeNull();
});
});
describe('numberRange', () => {
it('returns error when min is greater than max', () => {
const filter = createFilter('numberRange', { min: 100, max: 50 });
expect(validateFilter(filter)).toBe('Minimum must be less than maximum');
});
it('returns null for valid numberRange', () => {
const filter = createFilter('numberRange', { min: 10, max: 100 });
expect(validateFilter(filter)).toBeNull();
});
it('returns error when min and max are null', () => {
const filter = createFilter('numberRange', { min: null, max: null });
expect(validateFilter(filter)).toBe('Both values are required');
});
});
describe('number', () => {
it('returns error when value is below min', () => {
const filter = createFilter('number', { value: 5 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBe('Value cannot be less than 10');
});
it('returns error when value is above max', () => {
const filter = createFilter('number', { value: 150 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBe('Value cannot be greater than 100');
});
it('returns null for valid number', () => {
const filter = createFilter('number', { value: 50 }, { min: 10, max: 100 });
expect(validateFilter(filter)).toBeNull();
});
it('returns error when value is null', () => {
const filter = createFilter('number', { value: null });
expect(validateFilter(filter)).toBe('A value is required');
});
});
describe('radio', () => {
it('returns null for valid radio', () => {
const filter = createFilter('radio', { status: 'completed' });
expect(validateFilter(filter)).toBeNull();
});
});
describe('text', () => {
it('returns error when text is empty', () => {
const filter = createFilter('text', { value: '' });
expect(validateFilter(filter)).toBe('Text value is required');
});
it('returns error when text is whitespace only', () => {
const filter = createFilter('text', { value: ' ' });
expect(validateFilter(filter)).toBe('Text value is required');
});
it('returns null for valid text', () => {
const filter = createFilter('text', { value: 'hello' });
expect(validateFilter(filter)).toBeNull();
});
});
});
describe('encodeFiltersToURL', () => {
it('returns empty string for empty filters', () => {
expect(encodeFiltersToURL([])).toBe('');
});
it('encodes filters to URL params', () => {
const filters: ActiveFilter[] = [
{
attribute: { id: 'date', type: 'dateRange', label: 'Date', config: {} },
value: { from: new Date('2024-01-01'), to: new Date('2024-01-31') },
isValid: true,
},
];
const result = encodeFiltersToURL(filters);
expect(result).toContain('filters=');
});
it('encodes multiple filters', () => {
const filters: ActiveFilter[] = [
{
attribute: { id: 'status', type: 'multiSelect', label: 'Status', config: { options: [] } },
value: { codes: ['completed', 'failed'] },
isValid: true,
},
{
attribute: { id: 'text', type: 'text', label: 'Text', config: {} },
value: { value: 'hello' },
isValid: true,
},
];
const result = encodeFiltersToURL(filters);
expect(result).toContain('filters=');
// Should contain encoded JSON
expect(decodeURIComponent(result)).toContain('status');
expect(decodeURIComponent(result)).toContain('hello');
});
});
describe('decodeFiltersFromURL', () => {
it('returns empty array for empty params', () => {
const params = new URLSearchParams();
const availableAttributes: FilterAttribute[] = [];
expect(decodeFiltersFromURL(params, availableAttributes)).toEqual([]);
});
it('decodes filters from URL', () => {
const params = new URLSearchParams();
params.set('filters', JSON.stringify([{ id: 'test', value: { value: 'hello' } }]));
const availableAttributes: FilterAttribute[] = [
{ id: 'test', type: 'text', label: 'Test', config: {} }
];
const result = decodeFiltersFromURL(params, availableAttributes);
expect(result.length).toBe(1);
expect(result[0].attribute.id).toBe('test');
});
});
describe('formatDateRange', () => {
it('returns "No date range selected" for null values', () => {
expect(formatDateRange({ from: null, to: null })).toBe('No date range selected');
});
it('formats date range correctly', () => {
const value: DateRangeValue = {
from: new Date('2024-01-15'),
to: new Date('2024-01-20')
};
const result = formatDateRange(value);
expect(result).toContain('2024');
});
});
describe('formatNumberRange', () => {
it('returns "No range selected" for null values', () => {
expect(formatNumberRange({ min: null, max: null })).toBe('No range selected');
});
it('formats number range with unit', () => {
const value: NumberRangeValue = { min: 10, max: 100 };
const result = formatNumberRange(value, 'seconds');
expect(result).toContain('10');
expect(result).toContain('100');
expect(result).toContain('seconds');
});
});
describe('getDatePresetValue', () => {
it('returns valid date range for "today"', () => {
const result = getDatePresetValue('today');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "yesterday"', () => {
const result = getDatePresetValue('yesterday');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "last7days"', () => {
const result = getDatePresetValue('last7days');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
it('returns valid date range for "last30days"', () => {
const result = getDatePresetValue('last30days');
expect(result.from).toBeInstanceOf(Date);
expect(result.to).toBeInstanceOf(Date);
});
});
});

81
ui/src/lib/utils.test.ts Normal file
View file

@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { cn, getRandomId, getNextNodeId, debounce } from './utils';
describe('cn', () => {
it('merges class names with tailwind', () => {
expect(cn('px-2', 'py-1')).toBe('px-2 py-1');
});
it('handles conditional classes', () => {
expect(cn('base', false && 'hidden', true && 'visible')).toBe('base visible');
});
it('merges conflicting tailwind classes', () => {
expect(cn('px-2', 'px-4')).toBe('px-4');
});
it('handles empty inputs', () => {
expect(cn()).toBe('');
});
it('handles undefined and null values', () => {
expect(cn('base', undefined, null, 'extra')).toBe('base extra');
});
});
describe('getRandomId', () => {
it('returns a number between 0 and 9999', () => {
const id = getRandomId();
expect(id).toBeGreaterThanOrEqual(0);
expect(id).toBeLessThan(10000);
});
it('returns different values on multiple calls', () => {
const ids = new Set(Array.from({ length: 20 }, getRandomId));
expect(ids.size).toBeGreaterThan(1);
});
});
describe('getNextNodeId', () => {
it('returns "1" for empty array', () => {
expect(getNextNodeId([])).toBe('1');
});
it('returns next id after max existing', () => {
expect(getNextNodeId([{ id: '1' }, { id: '3' }])).toBe('4');
});
it('ignores non-numeric ids', () => {
expect(getNextNodeId([{ id: 'abc' }, { id: '5' }])).toBe('6');
});
});
describe('debounce', () => {
it('delays function execution', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('cancels previous call on rapid invocations', () => {
vi.useFakeTimers();
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

View file

@ -0,0 +1,60 @@
import { http, HttpResponse } from 'msw';
export const handlers = [
// Auth
http.get('/api/auth/session', () => {
return HttpResponse.json({ user: { id: '1', email: 'test@example.com' } });
}),
// App config
http.get('/api/config/auth', () => {
return HttpResponse.json({ provider: 'local' });
}),
http.get('/api/config/version', () => {
return HttpResponse.json({ version: '1.31.0' });
}),
// User config
http.get('/api/v1/user/configurations/user', () => {
return HttpResponse.json({
llm: { provider: 'openai', model: 'gpt-4.1', base_url: 'https://api.openai.com/v1' },
tts: { provider: 'openai', model: 'gpt-4o-mini-tts' },
stt: { provider: 'deepgram', model: 'nova-2' },
embeddings: { provider: 'openai', model: 'text-embedding-3-small' },
});
}),
http.put('/api/v1/user/configurations/user', async () => {
return HttpResponse.json({ success: true });
}),
// Default configurations
http.get('/api/v1/user/configurations/defaults', () => {
return HttpResponse.json({
llm: {
openai: {
properties: {
provider: { const: 'openai', type: 'string' },
api_key: { type: 'string' },
model: { type: 'string', default: 'gpt-4.1' },
base_url: { type: 'string', default: 'https://api.openai.com/v1' },
},
},
},
tts: {},
stt: {},
embeddings: {},
default_providers: { llm: 'openai', tts: 'openai', stt: 'deepgram', embeddings: 'openai' },
});
}),
// Workflows
http.get('/api/v1/workflows', () => {
return HttpResponse.json({ items: [], total: 0 });
}),
http.get('/api/v1/workflow/count', () => {
return HttpResponse.json({ count: 0 });
}),
];

View file

@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);

21
ui/src/test/setup.ts Normal file
View file

@ -0,0 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
import { server } from './mocks/server';
// Mock ResizeObserver for Radix UI components in jsdom
global.ResizeObserver = vi.fn().mockImplementation(function () {
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
});
// Start MSW before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Close MSW after all tests
afterAll(() => server.close());

View file

@ -0,0 +1,14 @@
import { render as rtlRender, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
function AllProviders({ children }: { children: ReactNode }) {
// Minimal wrapper — tests that need full providers will wrap explicitly
return <>{children}</>;
}
export function render(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return rtlRender(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

28
ui/vitest.config.ts Normal file
View file

@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: [
'node_modules/',
'src/test/',
'src/client/',
'**/*.d.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});