test: fix broken unit tests, add missing fixtures, and stabilize CI pipeline
- Fix os module mock pattern for Vitest v4 in proxy-ide-config tests - Fix enterprise oauth_client_key expectations in cloudHandler-sync and token-manager-parity tests - Fix proxy-controller integration test argument expectations - Add missing proxy-parity JSON fixtures (OpenAI tools, Anthropic tool-use, Gemini SSE) - Add reasoning_content mapping to convertClaudeToOpenAIResponse - Fix google-api service test env var cache leak with resetModules + dynamic imports - Fix proxy-metrics timer tests by calling onModuleInit in beforeEach - Fix locale-sensitive number formatting in StatCard tests - Add missing test files to version control (components, hooks, ipc, mocks, server, utils) - Fix CI workflows: testing.yaml (use vitest run, add type-check job, npm cache), lint.yaml and format.yaml (add npm cache, concurrency, fix job names)
This commit is contained in:
1 parent
bf4bcf4f96
commit
afe8859f16
47 files changed
+4341
-73
No files matched your search
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { AccountCard } from '@/components/AccountCard';
|
||||
import type { Account } from '@/types/account';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options) {
|
||||
const entries = Object.entries(options);
|
||||
if (entries.length > 0) {
|
||||
return `${key}:${JSON.stringify(options)}`;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
},
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean }) => {
|
||||
if (asChild) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <div data-testid="dropdown-trigger">{children}</div>;
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid="dropdown-item"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AccountCard', () => {
|
||||
const createMockAccount = (overrides?: Partial<Account>): Account => ({
|
||||
id: 'acc-1',
|
||||
name: 'Test User',
|
||||
email: '[email protected]',
|
||||
backup_file: '/path/to/backup',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
deviceProfile: {
|
||||
machineId: 'machine-1',
|
||||
macMachineId: 'mac-1',
|
||||
devDeviceId: 'dev-1',
|
||||
sqmId: 'sqm-1',
|
||||
},
|
||||
created_at: '2026-04-20T00:00:00Z',
|
||||
last_used: '2026-04-23T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
account: createMockAccount(),
|
||||
isCurrent: false,
|
||||
onSwitch: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
isSwitching: false,
|
||||
isDeleting: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders account name and email', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
expect(screen.getByText('Test User')).toBeInTheDocument();
|
||||
expect(screen.getByText('[email protected]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows avatar image element when avatar_url is provided', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
const avatarImage = document.querySelector('img');
|
||||
if (avatarImage) {
|
||||
expect(avatarImage).toHaveAttribute('src', 'https://example.com/avatar.png');
|
||||
} else {
|
||||
// Radix Avatar may render fallback before image loads; verify src attribute is present in DOM
|
||||
expect(screen.getByText('TU')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows avatar fallback with initials when avatar_url is missing', () => {
|
||||
renderWithProviders(
|
||||
<AccountCard
|
||||
{...defaultProps}
|
||||
account={createMockAccount({ avatar_url: undefined })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('TU')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email initial as fallback when name is empty', () => {
|
||||
renderWithProviders(
|
||||
<AccountCard
|
||||
{...defaultProps}
|
||||
account={createMockAccount({ name: '', avatar_url: undefined })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('T')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays current badge when isCurrent is true', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isCurrent={true} />);
|
||||
expect(screen.getByText('account.current')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display current badge when isCurrent is false', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isCurrent={false} />);
|
||||
expect(screen.queryByText('account.current')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles delete action from dropdown menu', () => {
|
||||
const onDelete = vi.fn();
|
||||
renderWithProviders(<AccountCard {...defaultProps} onDelete={onDelete} />);
|
||||
|
||||
const deleteButton = screen.getByTestId('dropdown-item');
|
||||
fireEvent.click(deleteButton);
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
expect(onDelete).toHaveBeenCalledWith('acc-1');
|
||||
});
|
||||
|
||||
it('handles switch action when card is clicked', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(<AccountCard {...defaultProps} onSwitch={onSwitch} />);
|
||||
|
||||
const card = document.querySelector('[class*="cursor-pointer"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
if (card) {
|
||||
fireEvent.click(card);
|
||||
}
|
||||
expect(onSwitch).toHaveBeenCalledTimes(1);
|
||||
expect(onSwitch).toHaveBeenCalledWith('acc-1');
|
||||
});
|
||||
|
||||
it('does not trigger switch when isCurrent is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isCurrent={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[class*="cursor-pointer"]');
|
||||
expect(card).not.toBeInTheDocument();
|
||||
expect(onSwitch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not trigger switch when isSwitching is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isSwitching={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[class*="pointer-events-none"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not trigger switch when isDeleting is true', () => {
|
||||
const onSwitch = vi.fn();
|
||||
renderWithProviders(
|
||||
<AccountCard {...defaultProps} isDeleting={true} onSwitch={onSwitch} />,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[class*="pointer-events-none"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when switching', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isSwitching={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies reduced opacity and pointer-events-none when deleting', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isDeleting={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="opacity-60"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies primary border styling when isCurrent is true', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<AccountCard {...defaultProps} isCurrent={true} />,
|
||||
);
|
||||
const card = container.querySelector('[class*="border-primary/50"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows spinning refresh icon when isSwitching is true', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} isSwitching={true} />);
|
||||
const spinIcon = document.querySelector('.animate-spin');
|
||||
expect(spinIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles mouse move to update card transform', () => {
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 });
|
||||
expect(wrapper.style.transform).toContain('perspective(1000px)');
|
||||
expect(wrapper.style.transform).toContain('rotateX');
|
||||
expect(wrapper.style.transform).toContain('rotateY');
|
||||
});
|
||||
|
||||
it('handles mouse leave to reset card transform', () => {
|
||||
const { container } = renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.mouseMove(wrapper, { clientX: 100, clientY: 50 });
|
||||
expect(wrapper.style.transform).not.toContain('rotateX(0deg)');
|
||||
|
||||
fireEvent.mouseLeave(wrapper);
|
||||
expect(wrapper.style.transform).toContain('rotateX(0deg)');
|
||||
expect(wrapper.style.transform).toContain('rotateY(0deg)');
|
||||
});
|
||||
|
||||
it('renders last used text', () => {
|
||||
renderWithProviders(<AccountCard {...defaultProps} />);
|
||||
expect(screen.getByText(/account\.lastUsed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||
import type { ProviderStats } from '@/utils/provider-grouping';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options && 'count' in options) {
|
||||
return `${key} (${options.count})`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProviderGroup', () => {
|
||||
const createMockStats = (overrides?: Partial<ProviderStats>): ProviderStats => ({
|
||||
providerKey: 'gemini-',
|
||||
providerInfo: {
|
||||
name: 'Gemini',
|
||||
company: 'Google',
|
||||
color: '#4285F4',
|
||||
},
|
||||
models: [
|
||||
{ id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' },
|
||||
{ id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' },
|
||||
],
|
||||
visibleModels: [
|
||||
{ id: 'models/gemini-pro', percentage: 75, resetTime: '2026-04-24T00:00:00Z' },
|
||||
{ id: 'models/gemini-ultra', percentage: 50, resetTime: '2026-04-24T00:00:00Z' },
|
||||
],
|
||||
avgPercentage: 62.5,
|
||||
earliestReset: '2026-04-24T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
stats: createMockStats(),
|
||||
isCollapsed: false,
|
||||
onToggleCollapse: vi.fn(),
|
||||
getQuotaTextColorClass: (percentage: number) =>
|
||||
percentage > 50 ? 'text-green-500' : 'text-yellow-500',
|
||||
getQuotaBarColorClass: (percentage: number) =>
|
||||
percentage > 50 ? 'bg-green-500' : 'bg-yellow-500',
|
||||
formatQuotaLabel: (percentage: number) => `${percentage}%`,
|
||||
formatResetTimeLabel: (resetTime?: string) => (resetTime ? '24h' : '—'),
|
||||
formatResetTimeTitle: (resetTime?: string) =>
|
||||
resetTime ? new Date(resetTime).toLocaleString() : undefined,
|
||||
leftLabel: 'left',
|
||||
};
|
||||
|
||||
it('renders provider name', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays model count', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(
|
||||
screen.getByText(/settings\.providerGroupings\.models/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quota bars and average percentage', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getByText('62.5%')).toBeInTheDocument();
|
||||
expect(screen.getByText('settings.providerGroupings.avgLabel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reset time label', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} />);
|
||||
expect(screen.getAllByText('24h').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('shows individual model rows when expanded', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(screen.getByText('gemini-pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('gemini-ultra')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides individual model rows when collapsed', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
|
||||
expect(screen.queryByText('gemini-pro')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('gemini-ultra')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggleCollapse when header button is clicked', () => {
|
||||
const onToggleCollapse = vi.fn();
|
||||
renderWithProviders(
|
||||
<ProviderGroup {...defaultProps} onToggleCollapse={onToggleCollapse} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders color dot with provider color', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup {...defaultProps} />,
|
||||
);
|
||||
const dot = container.querySelector('span[style*="background-color"]');
|
||||
expect(dot).toHaveAttribute('style', expect.stringContaining('#4285F4'));
|
||||
});
|
||||
|
||||
it('shows chevron right when collapsed', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={true} />);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows chevron down when expanded', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when visibleModels is empty', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<ProviderGroup
|
||||
{...defaultProps}
|
||||
stats={createMockStats({ visibleModels: [] })}
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('displays model-specific percentages in expanded rows', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows leftLabel next to model percentages when percentage > 0', () => {
|
||||
renderWithProviders(<ProviderGroup {...defaultProps} isCollapsed={false} />);
|
||||
const leftLabels = screen.getAllByText('left');
|
||||
expect(leftLabels.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with default variant and size', () => {
|
||||
renderWithProviders(<Button>Click me</Button>);
|
||||
const button = screen.getByRole('button', { name: /click me/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('data-slot', 'button');
|
||||
});
|
||||
|
||||
it('renders all variant styles', () => {
|
||||
const variants = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const;
|
||||
variants.forEach((variant) => {
|
||||
const { container } = renderWithProviders(
|
||||
<Button variant={variant}>{variant}</Button>,
|
||||
);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all size styles', () => {
|
||||
const sizes = ['default', 'sm', 'lg', 'icon'] as const;
|
||||
sizes.forEach((size) => {
|
||||
const { container } = renderWithProviders(
|
||||
<Button size={size}>{size}</Button>,
|
||||
);
|
||||
expect(container.querySelector('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
renderWithProviders(<Button onClick={handleClick}>Click me</Button>);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports disabled state', () => {
|
||||
renderWithProviders(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders as child element when asChild is true', () => {
|
||||
renderWithProviders(
|
||||
<Button asChild>
|
||||
<a href="/test">Link Button</a>
|
||||
</Button>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name: /link button/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/test');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(<Button className="custom-class">Styled</Button>);
|
||||
expect(screen.getByRole('button')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('buttonVariants function returns expected classes for default variant', () => {
|
||||
const classes = buttonVariants({ variant: 'default', size: 'default' });
|
||||
expect(classes).toContain('bg-primary');
|
||||
expect(classes).toContain('text-primary-foreground');
|
||||
});
|
||||
|
||||
it('buttonVariants function returns expected classes for destructive variant', () => {
|
||||
const classes = buttonVariants({ variant: 'destructive' });
|
||||
expect(classes).toContain('bg-destructive');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders Card with content', () => {
|
||||
renderWithProviders(
|
||||
<Card data-testid="card">
|
||||
<span>Card content</span>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByTestId('card')).toBeInTheDocument();
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className to Card', () => {
|
||||
renderWithProviders(<Card className="my-card">Content</Card>);
|
||||
expect(screen.getByText('Content')).toHaveClass('my-card');
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
renderWithProviders(<Card ref={ref}>Ref test</Card>);
|
||||
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardHeader', () => {
|
||||
it('renders header with title and description', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CardTitle renders as h3', () => {
|
||||
renderWithProviders(<CardTitle>Heading</CardTitle>);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Heading');
|
||||
});
|
||||
|
||||
it('CardDescription has correct text styling classes', () => {
|
||||
const { container } = renderWithProviders(<CardDescription>Desc</CardDescription>);
|
||||
expect(container.querySelector('p')).toHaveClass('text-muted-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardContent', () => {
|
||||
it('renders content area', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardContent>Main content</CardContent>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText('Main content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardFooter', () => {
|
||||
it('renders footer with actions', () => {
|
||||
renderWithProviders(
|
||||
<Card>
|
||||
<CardFooter>
|
||||
<button>Action</button>
|
||||
</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full Card composition', () => {
|
||||
it('renders complete card with all sections', () => {
|
||||
renderWithProviders(
|
||||
<Card className="w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Account Stats</CardTitle>
|
||||
<CardDescription>Overview of usage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Tokens used: 1,234</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<span>Last updated: now</span>
|
||||
</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /account stats/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/overview of usage/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/tokens used/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/last updated/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders an input element', () => {
|
||||
renderWithProviders(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts and displays text value', () => {
|
||||
renderWithProviders(<Input />);
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'Hello world' } });
|
||||
expect(input).toHaveValue('Hello world');
|
||||
});
|
||||
|
||||
it('supports different input types', () => {
|
||||
const { rerender } = renderWithProviders(<Input type="text" />);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text');
|
||||
|
||||
rerender(<Input type="password" data-testid="password-input" />);
|
||||
expect(screen.getByTestId('password-input')).toHaveAttribute('type', 'password');
|
||||
|
||||
rerender(<Input type="email" data-testid="email-input" />);
|
||||
expect(screen.getByTestId('email-input')).toHaveAttribute('type', 'email');
|
||||
});
|
||||
|
||||
it('supports disabled state', () => {
|
||||
renderWithProviders(<Input disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = React.createRef<HTMLInputElement>();
|
||||
renderWithProviders(<Input ref={ref} />);
|
||||
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(<Input className="custom-input" />);
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-input');
|
||||
});
|
||||
|
||||
it('handles focus and blur events', () => {
|
||||
const onFocus = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
renderWithProviders(<Input onFocus={onFocus} onBlur={onBlur} />);
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
expect(onFocus).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.blur(input);
|
||||
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has expected base styling classes', () => {
|
||||
const { container } = renderWithProviders(<Input />);
|
||||
const input = container.querySelector('input');
|
||||
expect(input).toHaveClass('rounded-md');
|
||||
expect(input).toHaveClass('border');
|
||||
expect(input).toHaveClass('bg-transparent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { ChartCard } from '@/components/usage/ChartCard';
|
||||
|
||||
describe('ChartCard', () => {
|
||||
const defaultProps = {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isEmpty: false,
|
||||
onRetry: vi.fn(),
|
||||
title: 'Test Chart',
|
||||
emptyTitle: 'No Data',
|
||||
emptyDescription: 'There is no data to display.',
|
||||
errorMessage: 'Failed to load chart.',
|
||||
retryLabel: 'Retry',
|
||||
children: <div data-testid="chart-content">Chart Content</div>,
|
||||
};
|
||||
|
||||
it('renders loading state with skeleton', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Chart')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state with retry button and calls onRetry when clicked', () => {
|
||||
const onRetry = vi.fn();
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Network error')}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders empty state with title and description', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard {...defaultProps} isEmpty={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('There is no data to display.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chart-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when not loading, error, or empty', () => {
|
||||
renderWithProviders(<ChartCard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('chart-content')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chart Content')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes loading over error state', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
isLoading={true}
|
||||
error={new Error('Some error')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Failed to load chart.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes error over empty state', () => {
|
||||
renderWithProviders(
|
||||
<ChartCard
|
||||
{...defaultProps}
|
||||
error={new Error('Some error')}
|
||||
isEmpty={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Failed to load chart.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No Data')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/tests/unit/test-utils';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { Activity, TrendingUp, Users, Zap } from 'lucide-react';
|
||||
import { vi, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
describe('StatCard', () => {
|
||||
beforeAll(() => {
|
||||
vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (this: number) {
|
||||
return this.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('renders label and value', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Total Tokens" value={12345} icon={Activity} />,
|
||||
);
|
||||
expect(screen.getByText('Total Tokens')).toBeInTheDocument();
|
||||
expect(screen.getByText('12,345')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string values correctly', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Status" value="Active" icon={Zap} />,
|
||||
);
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats number values with locale string', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Requests" value={1000000} icon={TrendingUp} />,
|
||||
);
|
||||
expect(screen.getByText('1,000,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows skeleton when isLoading is true', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loading" value={0} icon={Users} isLoading />,
|
||||
);
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show skeleton when isLoading is false', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Loaded" value={42} icon={Activity} isLoading={false} />,
|
||||
);
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-pulse')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon element', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="With Icon" value={100} icon={Zap} />,
|
||||
);
|
||||
expect(document.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Styled" value={1} icon={Activity} className="my-stat" />,
|
||||
);
|
||||
expect(screen.getByText('Styled').closest('[class*="border-border"]')).toHaveClass('my-stat');
|
||||
});
|
||||
|
||||
it('applies default slate accent when accent prop is not provided', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Default Accent" value={1} icon={Activity} />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-slate-500/5');
|
||||
});
|
||||
|
||||
it('applies blue accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Blue" value={1} icon={Activity} accent="blue" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-blue-500/5');
|
||||
});
|
||||
|
||||
it('applies green accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Green" value={1} icon={Activity} accent="green" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-emerald-500/5');
|
||||
});
|
||||
|
||||
it('applies amber accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Amber" value={1} icon={Activity} accent="amber" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-amber-500/5');
|
||||
});
|
||||
|
||||
it('applies purple accent', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<StatCard label="Purple" value={1} icon={Activity} accent="purple" />,
|
||||
);
|
||||
const iconContainer = container.querySelector('.rounded-md');
|
||||
expect(iconContainer).toHaveClass('bg-purple-500/5');
|
||||
});
|
||||
|
||||
it('has uppercase label styling', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Uppercase Test" value={1} icon={Activity} />,
|
||||
);
|
||||
const label = screen.getByText('Uppercase Test');
|
||||
expect(label).toHaveClass('uppercase');
|
||||
});
|
||||
|
||||
it('has mono font for value', () => {
|
||||
renderWithProviders(
|
||||
<StatCard label="Mono" value={999} icon={Activity} />,
|
||||
);
|
||||
const value = screen.getByText('999');
|
||||
expect(value).toHaveClass('font-mono');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user