mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
bbb4f91a27
commit
20617db37a
44 changed files with 8426 additions and 5661 deletions
22
.devcontainer/devcontainer.json
Normal file
22
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie"
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
|
|
@ -181,30 +181,58 @@ class UserConfigurationValidator:
|
|||
api_key = service_config.api_key
|
||||
|
||||
try:
|
||||
if not self._check_api_key(provider, api_key):
|
||||
if not self._check_api_key(provider, api_key, service_config):
|
||||
return [
|
||||
{"model": service_name, "message": f"Invalid {provider} API key"}
|
||||
{
|
||||
"model": service_name,
|
||||
"message": (
|
||||
f"Invalid {provider} API key. Please verify your API key is "
|
||||
f"correct, has not expired, and has the required permissions."
|
||||
),
|
||||
}
|
||||
]
|
||||
except ValueError as e:
|
||||
return [{"model": service_name, "message": str(e)}]
|
||||
|
||||
return []
|
||||
|
||||
def _check_api_key(self, provider: str, api_key: str) -> bool:
|
||||
def _check_api_key(
|
||||
self, provider: str, api_key: str, service_config: Optional[ServiceConfig] = None
|
||||
) -> bool:
|
||||
"""Check if an API key for a provider is valid."""
|
||||
validator = self._validator_map.get(provider)
|
||||
if not validator:
|
||||
return False
|
||||
|
||||
if provider in (
|
||||
ServiceProviders.OPENAI.value,
|
||||
ServiceProviders.OPENAI_REALTIME.value,
|
||||
):
|
||||
return validator(provider, api_key, service_config)
|
||||
return validator(provider, api_key)
|
||||
|
||||
def _check_openai_api_key(self, model: str, api_key: str) -> bool:
|
||||
client = openai.OpenAI(api_key=api_key)
|
||||
def _check_openai_api_key(
|
||||
self, model: str, api_key: str, service_config: Optional[ServiceConfig] = None
|
||||
) -> bool:
|
||||
client_kwargs: dict[str, str] = {"api_key": api_key}
|
||||
base_url = getattr(service_config, "base_url", None) if service_config else None
|
||||
if base_url:
|
||||
client_kwargs["base_url"] = base_url
|
||||
client = openai.OpenAI(**client_kwargs)
|
||||
try:
|
||||
client.models.list()
|
||||
return True
|
||||
except openai.AuthenticationError:
|
||||
return False
|
||||
if base_url and "openai.com" not in base_url:
|
||||
raise ValueError(
|
||||
f"Invalid OpenAI API key. The key was rejected by the API at {base_url}. "
|
||||
"Please check that your API key is correct and has not been revoked."
|
||||
)
|
||||
raise ValueError(
|
||||
"Invalid OpenAI API key. The key was rejected by the OpenAI API. "
|
||||
"Please check that your API key is correct and has not been revoked. "
|
||||
"You can verify your keys at https://platform.openai.com/api-keys."
|
||||
)
|
||||
|
||||
def _check_deepgram_api_key(self, model: str, api_key: str) -> bool:
|
||||
try:
|
||||
|
|
@ -212,7 +240,11 @@ class UserConfigurationValidator:
|
|||
deepgram.manage.v1.projects.list()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
raise ValueError(
|
||||
"Invalid Deepgram API key. The key was rejected by the Deepgram API. "
|
||||
"Please check that your API key is correct and active. "
|
||||
"You can verify your keys at https://console.deepgram.com/."
|
||||
)
|
||||
|
||||
def _check_groq_api_key(self, model: str, api_key: str) -> bool:
|
||||
client = Groq(api_key=api_key)
|
||||
|
|
@ -220,7 +252,11 @@ class UserConfigurationValidator:
|
|||
client.models.list()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
raise ValueError(
|
||||
"Invalid Groq API key. The key was rejected by the Groq API. "
|
||||
"Please check that your API key is correct and active. "
|
||||
"You can verify your keys at https://console.groq.com/keys."
|
||||
)
|
||||
|
||||
def _validate_elevenlabs_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -290,6 +290,10 @@ class OpenAILLMService(BaseLLMConfiguration):
|
|||
description="OpenAI chat model to use.",
|
||||
json_schema_extra={"examples": OPENAI_MODELS, "allow_custom_input": True},
|
||||
)
|
||||
base_url: str = Field(
|
||||
default="https://api.openai.com/v1",
|
||||
description="Override only if using an OpenAI-compatible API (e.g. local LLM, proxy).",
|
||||
)
|
||||
|
||||
|
||||
@register_llm
|
||||
|
|
|
|||
|
|
@ -504,6 +504,9 @@ def create_llm_service_from_provider(
|
|||
"""
|
||||
logger.info(f"Creating LLM service: provider={provider}, model={model}")
|
||||
if provider == ServiceProviders.OPENAI.value:
|
||||
kwargs = {}
|
||||
if base_url:
|
||||
kwargs["base_url"] = base_url
|
||||
if "gpt-5" in model:
|
||||
return OpenAILLMService(
|
||||
api_key=api_key,
|
||||
|
|
@ -511,10 +514,12 @@ def create_llm_service_from_provider(
|
|||
model=model,
|
||||
extra={"reasoning_effort": "minimal", "verbosity": "low"},
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
return OpenAILLMService(
|
||||
api_key=api_key,
|
||||
settings=OpenAILLMSettings(model=model, temperature=0.1),
|
||||
**kwargs,
|
||||
)
|
||||
elif provider == ServiceProviders.GROQ.value:
|
||||
return GroqLLMService(
|
||||
|
|
@ -709,7 +714,9 @@ def create_llm_service(user_config):
|
|||
api_key = user_config.llm.api_key
|
||||
|
||||
kwargs = {}
|
||||
if provider == ServiceProviders.OPENROUTER.value:
|
||||
if provider == ServiceProviders.OPENAI.value:
|
||||
kwargs["base_url"] = user_config.llm.base_url
|
||||
elif provider == ServiceProviders.OPENROUTER.value:
|
||||
kwargs["base_url"] = user_config.llm.base_url
|
||||
elif provider == ServiceProviders.AZURE.value:
|
||||
kwargs["endpoint"] = user_config.llm.endpoint
|
||||
|
|
|
|||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "dograh",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
589
plans/frontend-testing-plan.md
Normal file
589
plans/frontend-testing-plan.md
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
# Frontend Testing Plan — Dograh UI
|
||||
|
||||
## Overview
|
||||
|
||||
The Dograh frontend is a Next.js 15 + React 19 + TypeScript application using:
|
||||
- **UI**: shadcn/ui + Radix UI primitives + Tailwind CSS
|
||||
- **State**: React Context (global), Zustand (workflow builder), React Hook Form (forms)
|
||||
- **API**: Auto-generated OpenAPI client (`@hey-api/openapi-ts`)
|
||||
- **Auth**: Stack Auth (`@stackframe/stack`)
|
||||
- **Charts**: Recharts
|
||||
- **Flow**: React Flow (`@xyflow/react`)
|
||||
|
||||
Current test coverage: **Zero** — no test framework, no test files, no CI test step.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Tooling Setup
|
||||
|
||||
### 1.1 Install Test Dependencies
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom \
|
||||
@testing-library/user-event @testing-library/dom jsdom msw@latest \
|
||||
@playwright/test @types/jest
|
||||
```
|
||||
|
||||
**Rationale**:
|
||||
- **Vitest**: Fast, Vite-native, excellent TypeScript support, replaces Jest
|
||||
- **@testing-library/react**: Render components, fire events, query DOM
|
||||
- **@testing-library/jest-dom**: Custom matchers (`toBeInTheDocument`, `toHaveValue`)
|
||||
- **jsdom**: Browser-like environment for unit tests
|
||||
- **MSW (Mock Service Worker)**: Intercept and mock HTTP requests at the network layer
|
||||
- **Playwright**: E2E testing with real browser automation
|
||||
|
||||
### 1.2 Configuration Files
|
||||
|
||||
**`ui/vitest.config.ts`**:
|
||||
```typescript
|
||||
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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**`ui/src/test/setup.ts`**:
|
||||
```typescript
|
||||
import '@testing-library/jest-dom';
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// 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());
|
||||
```
|
||||
|
||||
**`ui/src/test/mocks/server.ts`**:
|
||||
```typescript
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
```
|
||||
|
||||
**`ui/src/test/mocks/handlers.ts`**:
|
||||
```typescript
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlers = [
|
||||
// Auth
|
||||
http.get('/api/auth/session', () => {
|
||||
return HttpResponse.json({ user: { id: '1', email: 'test@example.com' } });
|
||||
}),
|
||||
|
||||
// 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' },
|
||||
});
|
||||
}),
|
||||
|
||||
http.put('/api/v1/user/configurations/user', async () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
// Workflows
|
||||
http.get('/api/v1/workflows', () => {
|
||||
return HttpResponse.json({ items: [], total: 0 });
|
||||
}),
|
||||
|
||||
// Add more handlers as needed
|
||||
];
|
||||
```
|
||||
|
||||
**`ui/src/test/test-utils.tsx`** — Custom render with providers:
|
||||
```typescript
|
||||
import { render as rtlRender, RenderOptions } from '@testing-library/react';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
import { UserConfigProvider } from '@/context/UserConfigContext';
|
||||
import { AppConfigProvider } from '@/context/AppConfigContext';
|
||||
|
||||
function AllProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AppConfigProvider>
|
||||
<UserConfigProvider>
|
||||
{children}
|
||||
</UserConfigProvider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function render(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
|
||||
return rtlRender(ui, { wrapper: AllProviders, ...options });
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
```
|
||||
|
||||
**`ui/playwright.config.ts`**:
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 1.3 Update package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Unit Tests
|
||||
|
||||
### 2.1 UI Primitive Components (`src/components/ui/`)
|
||||
|
||||
These are thin wrappers around Radix UI. Test focus: correct props forwarding, accessibility, styling.
|
||||
|
||||
| Component | Test File | Key Assertions |
|
||||
|-----------|-----------|----------------|
|
||||
| `Button` | `button.test.tsx` | Renders children, handles click, disabled state, variants |
|
||||
| `Input` | `input.test.tsx` | Accepts value, onChange, placeholder, disabled |
|
||||
| `Select` | `select.test.tsx` | Opens dropdown, selects option, displays value |
|
||||
| `Dialog` | `dialog.test.tsx` | Opens/closes, renders title, focus trap, ESC to close |
|
||||
| `Checkbox` | `checkbox.test.tsx` | Checked/unchecked state, onChange fires |
|
||||
| `Tabs` | `tabs.test.tsx` | Switches panels, active tab indicator |
|
||||
| `Switch` | `switch.test.tsx` | Toggles on click, respects checked prop |
|
||||
| `Badge` | `badge.test.tsx` | Renders text, applies variant classes |
|
||||
| `Card` | `card.test.tsx` | Renders header, content, footer slots |
|
||||
| `Table` | `table.test.tsx` | Renders rows/columns, sortable headers |
|
||||
| `Textarea` | `textarea.test.tsx` | Multi-line input, resize behavior |
|
||||
| `Calendar` | `calendar.test.tsx` | Date selection, month navigation |
|
||||
| `DropdownMenu` | `dropdown-menu.test.tsx` | Opens on click, item selection, keyboard nav |
|
||||
| `Popover` | `popover.test.tsx` | Opens/closes, positions content |
|
||||
| `Tooltip` | `tooltip.test.tsx` | Shows on hover, hides on leave |
|
||||
| `RadioGroup` | `radio-group.test.tsx` | Single selection, keyboard navigation |
|
||||
| `Progress` | `progress.test.tsx` | Renders correct width for value |
|
||||
| `Separator` | `separator.test.tsx` | Renders horizontal/vertical |
|
||||
| `Skeleton` | `skeleton.test.tsx` | Renders with pulse animation class |
|
||||
| `Sonner` | `sonner.test.tsx` | Toast appears, auto-dismisses |
|
||||
| `Sheet` | `sheet.test.tsx` | Slides in from side, closes on overlay click |
|
||||
| `Sidebar` | `sidebar.test.tsx` | Expands/collapses, shows/hides items |
|
||||
| `AlertDialog` | `alert-dialog.test.tsx` | Confirm/cancel actions, focus management |
|
||||
| `Collapsible` | `collapsible.test.tsx` | Expands/collapses content |
|
||||
| `ChoiceChips` | `choice-chips.test.tsx` | Single/multi selection, removable chips |
|
||||
| `JSONEditor` | `json-editor.test.tsx` | Validates JSON, shows error state |
|
||||
|
||||
**Example — `button.test.tsx`**:
|
||||
```typescript
|
||||
import { render, screen, fireEvent } 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>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies variant classes', () => {
|
||||
const { container } = render(<Button variant="destructive">Delete</Button>);
|
||||
expect(container.firstChild).toHaveClass('bg-destructive');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 Utility Functions (`src/lib/`)
|
||||
|
||||
| File | Test File | Key Test Cases |
|
||||
|------|-----------|----------------|
|
||||
| `utils.ts` | `utils.test.ts` | `cn()` class merging, date formatting, slug generation |
|
||||
| `filters.ts` | `filters.test.ts` | Filter predicate composition, attribute extraction |
|
||||
| `filterAttributes.ts` | `filterAttributes.test.ts` | Attribute parsing, type coercion |
|
||||
| `files.ts` | `files.test.ts` | File upload validation, size/format checks |
|
||||
| `logger.ts` | `logger.test.ts` | Log level filtering, structured output |
|
||||
| `apiClient.ts` | `apiClient.test.ts` | Base URL resolution (server vs client), interceptor registration |
|
||||
|
||||
### 2.3 Custom Hooks (`src/hooks/`)
|
||||
|
||||
| Hook | Test File | Key Test Cases |
|
||||
|------|-----------|----------------|
|
||||
| `use-mobile.ts` | `use-mobile.test.ts` | Detects mobile viewport, updates on resize |
|
||||
| `useAudioPlayback.ts` | `useAudioPlayback.test.ts` | Play/pause/stop, progress tracking, error handling |
|
||||
| `useLatestReleaseVersion.ts` | `useLatestReleaseVersion.test.ts` | Fetches version, caches result, handles errors |
|
||||
|
||||
**Example — `use-mobile.test.ts`**:
|
||||
```typescript
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useIsMobile } from './use-mobile';
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
it('returns false for desktop viewport', () => {
|
||||
window.innerWidth = 1024;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for mobile viewport', () => {
|
||||
window.innerWidth = 375;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.4 Constants & Types (`src/constants/`, `src/types/`)
|
||||
|
||||
- Verify constant values match expected enums
|
||||
- Type tests using `tsd` or `vitest typecheck`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Integration Tests
|
||||
|
||||
### 3.1 Context Providers (`src/context/`)
|
||||
|
||||
Test with real provider wrappers, mock API calls via MSW.
|
||||
|
||||
| Context | Test File | Key Test Cases |
|
||||
|---------|-----------|----------------|
|
||||
| `UserConfigContext` | `UserConfigContext.test.tsx` | Fetches config on mount, saves config, handles errors, loading states |
|
||||
| `AppConfigContext` | `AppConfigContext.test.tsx` | Provides app config, handles feature flags |
|
||||
| `OnboardingContext` | `OnboardingContext.test.tsx` | Tracks onboarding step, persists state |
|
||||
| `UnsavedChangesContext` | `UnsavedChangesContext.test.tsx` | Tracks dirty state, warns on navigation |
|
||||
| `TelephonyConfigWarningsContext` | `TelephonyConfigWarningsContext.test.tsx` | Displays warnings for missing telephony config |
|
||||
|
||||
**Example — `UserConfigContext.test.tsx`**:
|
||||
```typescript
|
||||
import { render, screen, waitFor } from '@/test/test-utils';
|
||||
import { useUserConfig } from './UserConfigContext';
|
||||
|
||||
function TestComponent() {
|
||||
const { userConfig, loading, saveUserConfig } = useUserConfig();
|
||||
if (loading) return <div>Loading</div>;
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="provider">{userConfig?.llm?.provider}</div>
|
||||
<button onClick={() => saveUserConfig({ llm: { provider: 'openai', model: 'gpt-4' } })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('UserConfigContext', () => {
|
||||
it('fetches user config on mount', async () => {
|
||||
render(<TestComponent />);
|
||||
expect(screen.getByText('Loading')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('provider')).toHaveTextContent('openai');
|
||||
});
|
||||
});
|
||||
|
||||
it('saves user config', async () => {
|
||||
render(<TestComponent />);
|
||||
await waitFor(() => screen.getByRole('button'));
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
// Assert API was called
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3.2 Complex Components (`src/components/`)
|
||||
|
||||
| Component | Test File | Key Test Cases |
|
||||
|-----------|-----------|----------------|
|
||||
| `ServiceConfigurationForm` | `ServiceConfigurationForm.test.tsx` | Renders all tabs, switches providers, shows/hides fields, validates, submits |
|
||||
| `LLMConfigSelector` | `LLMConfigSelector.test.tsx` | Provider dropdown, model selection, custom input toggle |
|
||||
| `VoiceSelector` | `VoiceSelector.test.tsx` | Voice list, selection, preview |
|
||||
| `WorkflowCard` | `WorkflowCard.test.tsx` | Renders workflow data, edit/delete actions |
|
||||
| `WorkflowTable` | `WorkflowTable.test.tsx` | Sorting, pagination, row actions |
|
||||
| `CreateWorkflowButton` | `CreateWorkflowButton.test.tsx` | Opens modal, creates workflow, shows error |
|
||||
| `TemplateCard` | `TemplateCard.test.tsx` | Renders template, applies on click |
|
||||
| `UploadWorkflowButton` | `UploadWorkflowButton.test.tsx` | File upload, validation, error handling |
|
||||
| `DailyUsageTable` | `DailyUsageTable.test.tsx` | Renders usage data, date formatting |
|
||||
| `MCPSection` | `MCPSection.test.tsx` | Tool list, add/remove, configuration |
|
||||
| `MediaPreviewDialog` | `MediaPreviewDialog.test.tsx` | Opens with media, closes, audio playback |
|
||||
| `ChatwootWidget` | `ChatwootWidget.test.tsx` | Loads widget, handles events |
|
||||
| `SignInClient` | `SignInClient.test.tsx` | Form validation, auth flow, error display |
|
||||
| `SpinLoader` | `SpinLoader.test.tsx` | Shows/hides based on loading prop |
|
||||
| `ThemeSwitcher` | `ThemeSwitcher.test.tsx` | Toggles theme, persists preference |
|
||||
| `Footer` | `Footer.test.tsx` | Renders links, version info |
|
||||
| `PostHogIdentify` | `PostHogIdentify.test.tsx` | Identifies user on auth |
|
||||
| `SentryErrorBoundary` | `SentryErrorBoundary.test.tsx` | Catches errors, shows fallback UI |
|
||||
| `TelemetrySection` | `TelemetrySection.test.tsx` | Toggle telemetry, persists setting |
|
||||
| `CallTypeCell` | `CallTypeCell.test.tsx` | Renders call type badge |
|
||||
|
||||
### 3.3 Workflow Builder Components (`src/components/workflow/`, `src/components/flow/`)
|
||||
|
||||
These use React Flow and Zustand — test with mocked stores.
|
||||
|
||||
| Component | Test File | Key Test Cases |
|
||||
|-----------|-----------|----------------|
|
||||
| Flow canvas | `flow/*.test.tsx` | Node rendering, edge connections, drag-and-drop |
|
||||
| Node configuration panels | `workflow/*.test.tsx` | Form validation, state updates, node types |
|
||||
| Conversation components | `workflow/conversation/*.test.tsx` | Message rendering, send/receive |
|
||||
| Folder management | `workflow/folders/*.test.tsx` | CRUD operations, drag-drop |
|
||||
|
||||
### 3.4 Page Components (`src/app/`)
|
||||
|
||||
Test page-level integration with routing and data fetching.
|
||||
|
||||
| Page | Test File | Key Test Cases |
|
||||
|------|-----------|----------------|
|
||||
| `/model-configurations` | `model-configurations/page.test.tsx` | Loads config, renders form, saves changes |
|
||||
| `/workflow/create` | `workflow/create/page.test.tsx` | Creates workflow, validates, redirects |
|
||||
| `/workflow/[id]` | `workflow/[id]/page.test.tsx` | Loads workflow, edits, saves |
|
||||
| `/settings` | `settings/page.test.tsx` | Renders settings, updates preferences |
|
||||
| `/auth/login` | `auth/login/page.test.tsx` | Form validation, login flow, error handling |
|
||||
| `/overview` | `overview/page.test.tsx` | Dashboard stats, recent activity |
|
||||
| `/recordings` | `recordings/page.test.tsx` | List recordings, play, filter |
|
||||
| `/tools` | `tools/page.test.tsx` | Tool list, create/edit/delete |
|
||||
| `/campaigns` | `campaigns/page.test.tsx` | Campaign list, status, actions |
|
||||
| `/usage` | `usage/page.test.tsx` | Usage charts, date range selection |
|
||||
| `/api-keys` | `api-keys/page.test.tsx` | Key list, generate, revoke |
|
||||
| `/telephony-configurations` | `telephony-configurations/page.test.tsx` | Provider config, validation |
|
||||
| `/files` | `files/page.test.tsx` | File list, upload, delete |
|
||||
| `/reports` | `reports/page.test.tsx` | Report generation, export |
|
||||
| `/automation` | `automation/page.test.tsx` | Automation rules, triggers |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: E2E Tests (Playwright)
|
||||
|
||||
### 4.1 Authentication Flows
|
||||
|
||||
| Test File | Scenario |
|
||||
|-----------|----------|
|
||||
| `auth/login.spec.ts` | User logs in with valid credentials, sees dashboard |
|
||||
| `auth/signup.spec.ts` | New user signs up, completes onboarding |
|
||||
| `auth/logout.spec.ts` | User logs out, redirected to login |
|
||||
| `auth/session.spec.ts` | Session persists across page reloads |
|
||||
|
||||
### 4.2 Core User Journeys
|
||||
|
||||
| Test File | Scenario |
|
||||
|-----------|----------|
|
||||
| `workflow/create.spec.ts` | Create voice agent from scratch, configure LLM, save |
|
||||
| `workflow/edit.spec.ts` | Open existing workflow, modify nodes, save changes |
|
||||
| `workflow/deploy.spec.ts` | Deploy workflow, verify webhook URL generated |
|
||||
| `model-configurations.spec.ts` | Configure OpenAI with custom base URL, save, verify persisted |
|
||||
| `campaigns/create.spec.ts` | Create campaign, upload contacts, launch |
|
||||
| `recordings/play.spec.ts` | Navigate to recordings, play audio, verify playback |
|
||||
| `tools/mcp.spec.ts` | Add MCP tool, configure server URL, test connection |
|
||||
| `telephony/configure.spec.ts` | Add telephony provider, configure inbound webhook |
|
||||
|
||||
### 4.3 Critical Path — Voice Agent Creation
|
||||
|
||||
```typescript
|
||||
// e2e/workflow/create-voice-agent.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('create and configure a voice agent end-to-end', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/auth/login');
|
||||
await page.fill('[name="email"]', 'test@example.com');
|
||||
await page.fill('[name="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL('/overview');
|
||||
|
||||
// Navigate to create workflow
|
||||
await page.click('text=Voice Agents');
|
||||
await page.click('text=Create Agent');
|
||||
await expect(page).toHaveURL(/\/workflow\/create/);
|
||||
|
||||
// Configure LLM with custom base URL
|
||||
await page.click('text=Models');
|
||||
await page.selectOption('select[name="llm_provider"]', 'openai');
|
||||
await page.fill('input[name="llm_base_url"]', 'https://custom.example.com/v1');
|
||||
await page.fill('input[name="llm_api_key"]', 'sk-test-key');
|
||||
await page.selectOption('select[name="llm_model"]', 'gpt-4.1');
|
||||
|
||||
// Save configuration
|
||||
await page.click('text=Save Configuration');
|
||||
await expect(page.locator('.sonner')).toContainText('Saved');
|
||||
|
||||
// Verify persistence
|
||||
await page.reload();
|
||||
await expect(page.locator('input[name="llm_base_url"]')).toHaveValue('https://custom.example.com/v1');
|
||||
});
|
||||
```
|
||||
|
||||
### 4.4 Cross-Browser & Responsive
|
||||
|
||||
- Run E2E suite on Chromium and Firefox
|
||||
- Test key flows at mobile viewport (375px width)
|
||||
- Verify sidebar collapses, tables scroll, modals fit screen
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Backend Test Expansion
|
||||
|
||||
### 5.1 Service Factory Tests
|
||||
|
||||
| Test File | Coverage |
|
||||
|-----------|----------|
|
||||
| `test_openai_service_factory.py` | OpenAI LLM with/without custom base_url, gpt-5 reasoning params |
|
||||
| `test_openrouter_service_factory.py` | OpenRouter with custom base_url |
|
||||
| `test_speaches_service_factory.py` | Local model endpoint configuration |
|
||||
| `test_minimax_service_factory.py` | MiniMax with custom base_url and temperature |
|
||||
| `test_azure_service_factory.py` | Azure endpoint configuration |
|
||||
| `test_google_vertex_service_factory.py` | Vertex AI project/location/credentials |
|
||||
| `test_bedrock_service_factory.py` | AWS credentials and region |
|
||||
|
||||
### 5.2 Configuration Registry Tests
|
||||
|
||||
| Test File | Coverage |
|
||||
|-----------|----------|
|
||||
| `test_configuration_registry.py` | All provider schemas validate, defaults correct, required fields |
|
||||
| `test_configuration_merge.py` | Override merging, fallback to global, partial overrides |
|
||||
| `test_configuration_validity.py` | API key validation per provider, masked key rejection |
|
||||
| `test_configuration_defaults_endpoint.py` | `/configurations/defaults` returns complete schemas |
|
||||
|
||||
### 5.3 Integration Tests for New Feature
|
||||
|
||||
| Test File | Coverage |
|
||||
|-----------|----------|
|
||||
| `test_openai_base_url_persistence.py` | Save/load user config with custom base_url |
|
||||
| `test_openai_base_url_runtime.py` | Pipeline uses custom base_url at runtime |
|
||||
| `test_openai_base_url_workflow_override.py` | Workflow-level override of base_url |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: CI/CD Integration
|
||||
|
||||
### 6.1 GitHub Actions Workflow
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '24' }
|
||||
- run: cd ui && npm ci
|
||||
- run: cd ui && npm run test:coverage
|
||||
- uses: codecov/codecov-action@v4
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '24' }
|
||||
- run: cd ui && npm ci
|
||||
- run: cd ui && npx playwright install
|
||||
- run: cd ui && npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with: { name: playwright-report, path: ui/playwright-report/ }
|
||||
```
|
||||
|
||||
### 6.2 Coverage Gates
|
||||
|
||||
- Unit tests: minimum 70% line coverage
|
||||
- Integration tests: minimum 50% line coverage
|
||||
- E2E tests: all critical user journeys must pass
|
||||
- Block PR merge on test failure
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
| Priority | Phase | Effort | Impact |
|
||||
|----------|-------|--------|--------|
|
||||
| P0 | 1. Tooling Setup | Medium | Enables all testing |
|
||||
| P0 | 2.1 UI Primitives | Medium | Foundation for component tests |
|
||||
| P0 | 2.2 Utilities + Hooks | Low | Quick wins, high confidence |
|
||||
| P1 | 3.1 Context Providers | Medium | Core app logic |
|
||||
| P1 | 3.2 Complex Components | High | User-facing features |
|
||||
| P1 | 5.1 Service Factory Tests | Medium | Backend stability |
|
||||
| P2 | 3.3 Workflow Builder | High | Complex React Flow + Zustand |
|
||||
| P2 | 3.4 Page Components | High | Full page integration |
|
||||
| P2 | 4.1-4.3 E2E Core Journeys | High | Regression prevention |
|
||||
| P3 | 4.4 Cross-browser | Medium | Quality assurance |
|
||||
| P3 | 5.2-5.3 Backend Expansion | Medium | Complete backend coverage |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Test Count
|
||||
|
||||
| Layer | Estimated Tests |
|
||||
|-------|-----------------|
|
||||
| Unit (UI primitives) | ~150 |
|
||||
| Unit (utilities/hooks) | ~30 |
|
||||
| Integration (contexts) | ~25 |
|
||||
| Integration (components) | ~80 |
|
||||
| Integration (pages) | ~45 |
|
||||
| E2E (core journeys) | ~20 |
|
||||
| E2E (edge cases) | ~15 |
|
||||
| Backend unit | ~60 |
|
||||
| Backend integration | ~25 |
|
||||
| **Total** | **~450** |
|
||||
11565
ui/package-lock.json
generated
11565
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
ui/src/components/ui/alert-dialog.test.tsx
Normal file
56
ui/src/components/ui/alert-dialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
35
ui/src/components/ui/badge.test.tsx
Normal file
35
ui/src/components/ui/badge.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
51
ui/src/components/ui/button.test.tsx
Normal file
51
ui/src/components/ui/button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
56
ui/src/components/ui/calendar.test.tsx
Normal file
56
ui/src/components/ui/calendar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
40
ui/src/components/ui/card.test.tsx
Normal file
40
ui/src/components/ui/card.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
ui/src/components/ui/checkbox.test.tsx
Normal file
28
ui/src/components/ui/checkbox.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
69
ui/src/components/ui/choice-chips.test.tsx
Normal file
69
ui/src/components/ui/choice-chips.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
ui/src/components/ui/collapsible.test.tsx
Normal file
25
ui/src/components/ui/collapsible.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
38
ui/src/components/ui/dialog.test.tsx
Normal file
38
ui/src/components/ui/dialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
71
ui/src/components/ui/dropdown-menu.test.tsx
Normal file
71
ui/src/components/ui/dropdown-menu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
41
ui/src/components/ui/input.test.tsx
Normal file
41
ui/src/components/ui/input.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
143
ui/src/components/ui/json-editor.test.tsx
Normal file
143
ui/src/components/ui/json-editor.test.tsx
Normal 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' } });
|
||||
});
|
||||
});
|
||||
20
ui/src/components/ui/label.test.tsx
Normal file
20
ui/src/components/ui/label.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
25
ui/src/components/ui/popover.test.tsx
Normal file
25
ui/src/components/ui/popover.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
ui/src/components/ui/progress.test.tsx
Normal file
28
ui/src/components/ui/progress.test.tsx
Normal 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%)');
|
||||
});
|
||||
});
|
||||
36
ui/src/components/ui/radio-group.test.tsx
Normal file
36
ui/src/components/ui/radio-group.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
48
ui/src/components/ui/select.test.tsx
Normal file
48
ui/src/components/ui/select.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
ui/src/components/ui/separator.test.tsx
Normal file
25
ui/src/components/ui/separator.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
37
ui/src/components/ui/sheet.test.tsx
Normal file
37
ui/src/components/ui/sheet.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
134
ui/src/components/ui/sidebar.test.tsx
Normal file
134
ui/src/components/ui/sidebar.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
20
ui/src/components/ui/skeleton.test.tsx
Normal file
20
ui/src/components/ui/skeleton.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
16
ui/src/components/ui/sonner.test.tsx
Normal file
16
ui/src/components/ui/sonner.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
28
ui/src/components/ui/switch.test.tsx
Normal file
28
ui/src/components/ui/switch.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
53
ui/src/components/ui/table.test.tsx
Normal file
53
ui/src/components/ui/table.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
43
ui/src/components/ui/tabs.test.tsx
Normal file
43
ui/src/components/ui/tabs.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
33
ui/src/components/ui/textarea.test.tsx
Normal file
33
ui/src/components/ui/textarea.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
25
ui/src/components/ui/tooltip.test.tsx
Normal file
25
ui/src/components/ui/tooltip.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
40
ui/src/context/UserConfigContext.test.tsx
Normal file
40
ui/src/context/UserConfigContext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
58
ui/src/hooks/use-mobile.test.ts
Normal file
58
ui/src/hooks/use-mobile.test.ts
Normal 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
291
ui/src/lib/filters.test.ts
Normal 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
81
ui/src/lib/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
60
ui/src/test/mocks/handlers.ts
Normal file
60
ui/src/test/mocks/handlers.ts
Normal 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 });
|
||||
}),
|
||||
];
|
||||
4
ui/src/test/mocks/server.ts
Normal file
4
ui/src/test/mocks/server.ts
Normal 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
21
ui/src/test/setup.ts
Normal 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());
|
||||
14
ui/src/test/test-utils.tsx
Normal file
14
ui/src/test/test-utils.tsx
Normal 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
28
ui/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue