feat: Add Jest testing infrastructure with comprehensive mocks and test scripts

- Add jest.config.js with Next.js integration, jsdom environment, module path mapping, and coverage collection for src/**/*.{js,jsx,ts,tsx}
- Add jest.setup.js with @testing-library/jest-dom, Next.js router/navigation mocks, IntersectionObserver/ResizeObserver/WebSocket/fetch mocks, localStorage/sessionStorage mocks
- Add package.json test scripts: test, test:watch, test:coverage
- Add testing dependencies: @testing-library/jest
This commit is contained in:
nutcas3
2026-04-20 18:08:04 +03:00
parent 961259cd39
commit 02af897c5f
16 changed files with 3226 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
const nextJest = require('next/jest.js')
const createJestConfig = nextJest({
dir: './',
})
/** @type {import('jest').Config} */
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jest-environment-jsdom',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
],
}
module.exports = createJestConfig(customJestConfig)
+92
View File
@@ -0,0 +1,92 @@
import '@testing-library/jest-dom'
// Mock Next.js router
jest.mock('next/router', () => ({
useRouter() {
return {
route: '/',
pathname: '/',
query: '',
asPath: '',
push: jest.fn(),
pop: jest.fn(),
reload: jest.fn(),
back: jest.fn(),
prefetch: jest.fn().mockResolvedValue(undefined),
beforePopState: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
}
},
}))
// Mock Next.js navigation
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
}
},
useSearchParams() {
return new URLSearchParams()
},
usePathname() {
return '/'
},
}))
// Mock IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))
// Mock ResizeObserver
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))
// Mock WebSocket
global.WebSocket = jest.fn().mockImplementation(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
send: jest.fn(),
close: jest.fn(),
readyState: 1,
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,
}))
// Mock fetch
global.fetch = jest.fn()
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.localStorage = localStorageMock
// Mock sessionStorage
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.sessionStorage = sessionStorageMock
+10 -1
View File
@@ -6,7 +6,10 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
@@ -25,8 +28,14 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+2192
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { Sidebar } from '../sidebar'
// Mock next/link
jest.mock('next/link', () => {
const Link = ({ children, href, className }: any) => (
<a href={href} className={className}>{children}</a>
)
Link.displayName = 'Link'
return { __esModule: true, default: Link }
})
// Mock next/navigation - usePathname is mocked per test
const mockUsePathname = jest.fn()
jest.mock('next/navigation', () => ({
usePathname: () => mockUsePathname(),
}))
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('Sidebar', () => {
beforeEach(() => {
mockUsePathname.mockReturnValue('/')
})
it('renders the brand name', () => {
render(<Sidebar />)
expect(screen.getByText('Telecom Admin')).toBeInTheDocument()
})
it('renders all navigation items', () => {
render(<Sidebar />)
const labels = [
'Dashboard',
'Subscribers',
'Usage & Billing',
'Payments',
'eSIM Profiles',
'System Health',
'Chaos Engineering',
'Settings',
]
labels.forEach((label) => {
expect(screen.getByText(label)).toBeInTheDocument()
})
})
it('renders links with correct hrefs', () => {
render(<Sidebar />)
expect(screen.getByRole('link', { name: /Dashboard/ })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: /Subscribers/ })).toHaveAttribute('href', '/subscribers')
expect(screen.getByRole('link', { name: /Settings/ })).toHaveAttribute('href', '/settings')
})
it('marks Dashboard active when pathname is /', () => {
mockUsePathname.mockReturnValue('/')
render(<Sidebar />)
const dashboardLink = screen.getByRole('link', { name: /Dashboard/ })
expect(dashboardLink.className).toContain('bg-sidebar-accent')
})
it('marks a nested route active when pathname starts with that href', () => {
mockUsePathname.mockReturnValue('/subscribers/123')
render(<Sidebar />)
const link = screen.getByRole('link', { name: /Subscribers/ })
expect(link.className).toContain('bg-sidebar-accent')
})
it('does not mark Dashboard active on non-root paths', () => {
mockUsePathname.mockReturnValue('/subscribers')
render(<Sidebar />)
const dashboardLink = screen.getByRole('link', { name: /Dashboard/ })
// Dashboard uses exact match - should NOT contain active bg class
expect(dashboardLink.className).toContain('text-sidebar-foreground/70')
})
})
@@ -0,0 +1,155 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { StatCard } from '../stat-card'
import { Activity } from 'lucide-react'
// Mock the UI components to avoid dependency issues
jest.mock('@/components/ui/card', () => ({
Card: ({ children, className, ...props }: any) => (
<div data-testid="card" className={className} {...props}>
{children}
</div>
),
CardHeader: ({ children, className, ...props }: any) => (
<div data-testid="card-header" className={className} {...props}>
{children}
</div>
),
CardContent: ({ children, className, ...props }: any) => (
<div data-testid="card-content" className={className} {...props}>
{children}
</div>
),
CardTitle: ({ children, className, ...props }: any) => (
<div data-testid="card-title" className={className} {...props}>
{children}
</div>
),
}))
// Mock the utils function
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('StatCard', () => {
it('renders basic stat card with title, value, and icon', () => {
render(
<StatCard
title="Total Users"
value="1,234"
icon={Activity}
/>
)
expect(screen.getByText('Total Users')).toBeInTheDocument()
expect(screen.getByText('1,234')).toBeInTheDocument()
expect(screen.getByTestId('card')).toBeInTheDocument()
expect(screen.getByTestId('card-header')).toBeInTheDocument()
expect(screen.getByTestId('card-content')).toBeInTheDocument()
})
it('renders with description', () => {
render(
<StatCard
title="Total Users"
value="1,234"
description="Active users this month"
icon={Activity}
/>
)
expect(screen.getByText('Active users this month')).toBeInTheDocument()
})
it('renders with positive trend', () => {
render(
<StatCard
title="Total Users"
value="1,234"
trend={{ value: 12, positive: true }}
icon={Activity}
/>
)
const trendSpan = document.querySelector('.text-emerald-600')
expect(trendSpan).toBeInTheDocument()
expect(trendSpan).toHaveClass('text-emerald-600')
expect(trendSpan?.textContent).toContain('12')
expect(trendSpan?.textContent).toContain('+')
})
it('renders with negative trend', () => {
render(
<StatCard
title="Total Users"
value="1,234"
trend={{ value: 5, positive: false }}
icon={Activity}
/>
)
// Component only adds '+' for positive, nothing for negative - text is split across nodes
const trendSpan = document.querySelector('.text-red-600')
expect(trendSpan).toBeInTheDocument()
expect(trendSpan).toHaveClass('text-red-600')
expect(trendSpan?.textContent).toContain('5')
})
it('renders with both trend and description', () => {
render(
<StatCard
title="Total Users"
value="1,234"
trend={{ value: 12, positive: true }}
description="Active users this month"
icon={Activity}
/>
)
const trendSpan = document.querySelector('.text-emerald-600')
expect(trendSpan?.textContent).toContain('12')
expect(screen.getByText(/Active users this month/)).toBeInTheDocument()
})
it('applies custom className', () => {
render(
<StatCard
title="Total Users"
value="1,234"
icon={Activity}
className="custom-class"
/>
)
const card = screen.getByTestId('card')
expect(card).toHaveClass('custom-class')
})
it('renders numeric value correctly', () => {
render(
<StatCard
title="Total Users"
value={1234}
icon={Activity}
/>
)
expect(screen.getByText('1234')).toBeInTheDocument()
})
it('does not render trend or description when not provided', () => {
render(
<StatCard
title="Total Users"
value="1,234"
icon={Activity}
/>
)
// Should not have any trend indicators or description text
expect(screen.queryByText(/%/)).not.toBeInTheDocument()
expect(screen.getByTestId('card-content')).toBeInTheDocument()
expect(screen.getByTestId('card-content').children).toHaveLength(1) // Only the value
})
})
@@ -0,0 +1,63 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { Alert, AlertTitle, AlertDescription, AlertAction } from '../alert'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('Alert', () => {
it('renders with role=alert', () => {
render(<Alert>Alert content</Alert>)
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('has data-slot=alert', () => {
render(<Alert>Content</Alert>)
expect(screen.getByRole('alert')).toHaveAttribute('data-slot', 'alert')
})
it('applies default variant', () => {
render(<Alert>Default</Alert>)
expect(screen.getByRole('alert').className).toContain('bg-card')
})
it('applies destructive variant', () => {
render(<Alert variant="destructive">Error</Alert>)
expect(screen.getByRole('alert').className).toContain('text-destructive')
})
it('renders AlertTitle with correct data-slot', () => {
render(<AlertTitle>Title</AlertTitle>)
const title = screen.getByText('Title')
expect(title).toHaveAttribute('data-slot', 'alert-title')
})
it('renders AlertDescription with correct data-slot', () => {
render(<AlertDescription>Description text</AlertDescription>)
const desc = screen.getByText('Description text')
expect(desc).toHaveAttribute('data-slot', 'alert-description')
})
it('renders AlertAction with correct data-slot', () => {
render(<AlertAction><button>Dismiss</button></AlertAction>)
const button = screen.getByRole('button', { name: 'Dismiss' })
expect(button.parentElement).toHaveAttribute('data-slot', 'alert-action')
})
it('renders composed alert with title and description', () => {
render(
<Alert>
<AlertTitle>Heads up!</AlertTitle>
<AlertDescription>This is an alert.</AlertDescription>
</Alert>
)
expect(screen.getByText('Heads up!')).toBeInTheDocument()
expect(screen.getByText('This is an alert.')).toBeInTheDocument()
})
it('merges custom className on Alert', () => {
render(<Alert className="custom-alert">X</Alert>)
expect(screen.getByRole('alert').className).toContain('custom-alert')
})
})
@@ -0,0 +1,56 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { Badge } from '../badge'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('Badge', () => {
it('renders with default variant', () => {
render(<Badge>New</Badge>)
expect(screen.getByText('New')).toBeInTheDocument()
})
it('applies default variant classes', () => {
render(<Badge>Default</Badge>)
const badge = screen.getByText('Default')
expect(badge.className).toContain('bg-primary')
})
it('applies secondary variant', () => {
render(<Badge variant="secondary">Secondary</Badge>)
expect(screen.getByText('Secondary').className).toContain('bg-secondary')
})
it('applies destructive variant', () => {
render(<Badge variant="destructive">Error</Badge>)
expect(screen.getByText('Error').className).toContain('text-destructive')
})
it('applies success variant', () => {
render(<Badge variant="success">OK</Badge>)
expect(screen.getByText('OK').className).toContain('text-emerald-600')
})
it('applies warning variant', () => {
render(<Badge variant="warning">Warn</Badge>)
expect(screen.getByText('Warn').className).toContain('text-amber-600')
})
it('applies outline variant', () => {
render(<Badge variant="outline">Outline</Badge>)
expect(screen.getByText('Outline').className).toContain('text-foreground')
})
it('merges custom className', () => {
render(<Badge className="my-custom">Tag</Badge>)
expect(screen.getByText('Tag').className).toContain('my-custom')
})
it('forwards HTML attributes', () => {
render(<Badge data-testid="badge-id" title="tooltip">X</Badge>)
const badge = screen.getByTestId('badge-id')
expect(badge).toHaveAttribute('title', 'tooltip')
})
})
@@ -0,0 +1,161 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '../button'
// Mock Radix UI Slot - clone child and pass props through
jest.mock('radix-ui', () => ({
Slot: {
Root: ({ children, ...props }: any) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, { ...props, ...(children.props as object) })
}
return children
},
},
}))
// Mock the utils function
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('Button', () => {
it('renders with default props', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: 'Click me' })
expect(button).toBeInTheDocument()
expect(button).toHaveAttribute('data-variant', 'default')
expect(button).toHaveAttribute('data-size', 'default')
expect(button).toHaveAttribute('data-slot', 'button')
})
it('renders with different variants', () => {
const { rerender } = render(<Button variant="outline">Outline</Button>)
let button = screen.getByRole('button', { name: 'Outline' })
expect(button).toHaveAttribute('data-variant', 'outline')
rerender(<Button variant="destructive">Destructive</Button>)
button = screen.getByRole('button', { name: 'Destructive' })
expect(button).toHaveAttribute('data-variant', 'destructive')
rerender(<Button variant="ghost">Ghost</Button>)
button = screen.getByRole('button', { name: 'Ghost' })
expect(button).toHaveAttribute('data-variant', 'ghost')
rerender(<Button variant="link">Link</Button>)
button = screen.getByRole('button', { name: 'Link' })
expect(button).toHaveAttribute('data-variant', 'link')
})
it('renders with different sizes', () => {
const { rerender } = render(<Button size="sm">Small</Button>)
let button = screen.getByRole('button', { name: 'Small' })
expect(button).toHaveAttribute('data-size', 'sm')
rerender(<Button size="lg">Large</Button>)
button = screen.getByRole('button', { name: 'Large' })
expect(button).toHaveAttribute('data-size', 'lg')
rerender(<Button size="xs">Extra Small</Button>)
button = screen.getByRole('button', { name: 'Extra Small' })
expect(button).toHaveAttribute('data-size', 'xs')
rerender(<Button size="icon">Icon</Button>)
button = screen.getByRole('button', { name: 'Icon' })
expect(button).toHaveAttribute('data-size', 'icon')
})
it('applies custom className', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button', { name: 'Custom' })
expect(button).toHaveClass('custom-class')
})
it('handles click events', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button', { name: 'Click me' })
await user.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('can be disabled', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button', { name: 'Disabled' })
expect(button).toBeDisabled()
})
it('handles disabled state', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
render(<Button disabled onClick={handleClick}>Disabled</Button>)
const button = screen.getByRole('button', { name: 'Disabled' })
expect(button).toBeDisabled()
await user.click(button)
expect(handleClick).not.toHaveBeenCalled()
})
it('renders as child component when asChild is true', () => {
render(
<Button asChild>
<a href="/test">Link Button</a>
</Button>
)
const link = screen.getByRole('link', { name: 'Link Button' })
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', '/test')
expect(link).toHaveAttribute('data-slot', 'button')
})
it('passes through additional props', () => {
render(<Button data-testid="test-button" type="submit">Submit</Button>)
const button = screen.getByTestId('test-button')
expect(button).toHaveAttribute('type', 'submit')
})
it('renders with icons', () => {
render(
<Button>
<span data-testid="icon">Icon</span>
With Icon
</Button>
)
const button = screen.getByRole('button', { name: /With Icon/ })
const icon = screen.getByTestId('icon')
expect(button).toContainElement(icon)
expect(icon).toBeInTheDocument()
})
it('handles form submission', async () => {
const user = userEvent.setup()
const handleSubmit = jest.fn((e) => e.preventDefault())
render(
<form onSubmit={handleSubmit}>
<Button type="submit">Submit</Button>
</form>
)
const button = screen.getByRole('button', { name: 'Submit' })
await user.click(button)
expect(handleSubmit).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,76 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ErrorAlert } from '../error-alert'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
// Radix Slot used by Button
jest.mock('radix-ui', () => ({
Slot: {
Root: ({ children, ...props }: any) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, { ...props, ...(children.props as object) })
}
return children
},
},
}))
describe('ErrorAlert', () => {
it('renders nothing when error is null', () => {
const { container } = render(<ErrorAlert error={null} />)
expect(container.firstChild).toBeNull()
})
it('renders error message when provided', () => {
render(<ErrorAlert error="Something went wrong" />)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})
it('renders with destructive alert role', () => {
render(<ErrorAlert error="Oops" />)
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('does not render retry button when onRetry is not provided', () => {
render(<ErrorAlert error="Oops" />)
expect(screen.queryByRole('button', { name: /Retry/ })).not.toBeInTheDocument()
})
it('renders retry button when onRetry is provided', () => {
render(<ErrorAlert error="Oops" onRetry={jest.fn()} />)
expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument()
})
it('invokes onRetry when retry clicked', async () => {
const user = userEvent.setup()
const onRetry = jest.fn()
render(<ErrorAlert error="Oops" onRetry={onRetry} />)
await user.click(screen.getByRole('button', { name: /Retry/ }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('renders dismiss button when onDismiss is provided', () => {
render(<ErrorAlert error="Oops" onDismiss={jest.fn()} />)
// dismiss button has no accessible text other than icon
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('invokes onDismiss when dismiss is clicked', async () => {
const user = userEvent.setup()
const onDismiss = jest.fn()
render(<ErrorAlert error="Oops" onDismiss={onDismiss} />)
const buttons = screen.getAllByRole('button')
await user.click(buttons[buttons.length - 1])
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('uses custom retry text', () => {
render(<ErrorAlert error="Oops" onRetry={jest.fn()} retryText="Try again" />)
expect(screen.getByRole('button', { name: /Try again/ })).toBeInTheDocument()
})
})
@@ -0,0 +1,68 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Input } from '../input'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
describe('Input', () => {
it('renders an input element', () => {
render(<Input placeholder="Name" />)
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
})
it('has data-slot attribute', () => {
render(<Input data-testid="my-input" />)
expect(screen.getByTestId('my-input')).toHaveAttribute('data-slot', 'input')
})
it('passes through type attribute', () => {
render(<Input type="email" data-testid="email-input" />)
expect(screen.getByTestId('email-input')).toHaveAttribute('type', 'email')
})
it('supports password type', () => {
render(<Input type="password" data-testid="pwd" />)
expect(screen.getByTestId('pwd')).toHaveAttribute('type', 'password')
})
it('handles user typing', async () => {
const user = userEvent.setup()
render(<Input data-testid="txt" />)
const input = screen.getByTestId('txt') as HTMLInputElement
await user.type(input, 'hello')
expect(input.value).toBe('hello')
})
it('fires onChange handler', async () => {
const user = userEvent.setup()
const onChange = jest.fn()
render(<Input onChange={onChange} data-testid="txt" />)
await user.type(screen.getByTestId('txt'), 'a')
expect(onChange).toHaveBeenCalled()
})
it('can be disabled', () => {
render(<Input disabled data-testid="txt" />)
expect(screen.getByTestId('txt')).toBeDisabled()
})
it('respects aria-invalid for error states', () => {
render(<Input aria-invalid="true" data-testid="txt" />)
expect(screen.getByTestId('txt')).toHaveAttribute('aria-invalid', 'true')
})
it('merges custom className', () => {
render(<Input className="custom" data-testid="txt" />)
expect(screen.getByTestId('txt').className).toContain('custom')
})
it('forwards ref and name for forms', () => {
render(<Input name="username" data-testid="txt" defaultValue="bob" />)
const input = screen.getByTestId('txt') as HTMLInputElement
expect(input).toHaveAttribute('name', 'username')
expect(input.value).toBe('bob')
})
})
@@ -0,0 +1,132 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoadingState, InlineLoading, ActionLoading } from '../loading-state'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
jest.mock('radix-ui', () => ({
Slot: {
Root: ({ children, ...props }: any) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, { ...props, ...(children.props as object) })
}
return children
},
},
}))
describe('LoadingState', () => {
it('renders children when not loading and no error', () => {
render(
<LoadingState loading={false}>
<div>Content here</div>
</LoadingState>
)
expect(screen.getByText('Content here')).toBeInTheDocument()
})
it('shows loading state when loading is true', () => {
render(
<LoadingState loading={true}>
<div>Should not be visible</div>
</LoadingState>
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.queryByText('Should not be visible')).not.toBeInTheDocument()
})
it('shows custom loading text', () => {
render(
<LoadingState loading={true} loadingText="Fetching data...">
<div>hidden</div>
</LoadingState>
)
expect(screen.getByText('Fetching data...')).toBeInTheDocument()
})
it('shows error state when error is present', () => {
render(
<LoadingState loading={false} error="Network error">
<div>hidden</div>
</LoadingState>
)
expect(screen.getByText('Network error')).toBeInTheDocument()
expect(screen.queryByText('hidden')).not.toBeInTheDocument()
})
it('renders retry button when onRetry provided in error state', () => {
render(
<LoadingState loading={false} error="Error" onRetry={jest.fn()}>
<div>hidden</div>
</LoadingState>
)
expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument()
})
it('calls onRetry when retry button is clicked', async () => {
const user = userEvent.setup()
const onRetry = jest.fn()
render(
<LoadingState loading={false} error="Error" onRetry={onRetry}>
<div>hidden</div>
</LoadingState>
)
await user.click(screen.getByRole('button', { name: /Retry/ }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
})
describe('InlineLoading', () => {
it('shows loading text when loading', () => {
render(
<InlineLoading loading={true}>
<span>Content</span>
</InlineLoading>
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
it('shows children when not loading', () => {
render(
<InlineLoading loading={false}>
<span>Content</span>
</InlineLoading>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('supports custom text', () => {
render(
<InlineLoading loading={true} text="Please wait">
<span>hidden</span>
</InlineLoading>
)
expect(screen.getByText('Please wait')).toBeInTheDocument()
})
})
describe('ActionLoading', () => {
it('renders children button when not loading', () => {
render(
<ActionLoading loading={false}>
<button>Save</button>
</ActionLoading>
)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('renders disabled loading button when loading', () => {
render(
<ActionLoading loading={true} action="saving">
<button>Save</button>
</ActionLoading>
)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
expect(button.textContent).toContain('saving')
})
})
@@ -0,0 +1,85 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WebSocketStatus } from '../websocket-status'
jest.mock('@/lib/utils', () => ({
cn: (...args: any[]) => args.filter(Boolean).join(' '),
}))
jest.mock('radix-ui', () => ({
Slot: {
Root: ({ children, ...props }: any) => {
if (React.isValidElement(children)) {
return React.cloneElement(children, { ...props, ...(children.props as object) })
}
return children
},
},
}))
const mockReconnect = jest.fn()
const mockHookValue = {
isConnected: true,
reconnect: mockReconnect,
reconnectAttempts: 0,
}
jest.mock('@/hooks/use-websocket', () => ({
useWebSocketConnection: () => mockHookValue,
}))
describe('WebSocketStatus', () => {
beforeEach(() => {
mockReconnect.mockClear()
mockHookValue.isConnected = true
mockHookValue.reconnectAttempts = 0
})
it('shows Live badge when connected', () => {
mockHookValue.isConnected = true
render(<WebSocketStatus />)
expect(screen.getByText('Live')).toBeInTheDocument()
})
it('does not show Reconnect button when connected', () => {
mockHookValue.isConnected = true
render(<WebSocketStatus />)
expect(screen.queryByRole('button', { name: /Reconnect/ })).not.toBeInTheDocument()
})
it('shows Offline badge when disconnected', () => {
mockHookValue.isConnected = false
render(<WebSocketStatus />)
expect(screen.getByText('Offline')).toBeInTheDocument()
})
it('shows Reconnect button when disconnected', () => {
mockHookValue.isConnected = false
render(<WebSocketStatus />)
expect(screen.getByRole('button', { name: /Reconnect/ })).toBeInTheDocument()
})
it('calls reconnect when button clicked', async () => {
const user = userEvent.setup()
mockHookValue.isConnected = false
render(<WebSocketStatus />)
await user.click(screen.getByRole('button', { name: /Reconnect/ }))
expect(mockReconnect).toHaveBeenCalledTimes(1)
})
it('shows reconnect attempts count when > 0', () => {
mockHookValue.isConnected = false
mockHookValue.reconnectAttempts = 3
render(<WebSocketStatus />)
expect(screen.getByText(/Attempts:/)).toBeInTheDocument()
expect(screen.getByText(/3/)).toBeInTheDocument()
})
it('hides attempts count when 0', () => {
mockHookValue.isConnected = true
mockHookValue.reconnectAttempts = 0
render(<WebSocketStatus />)
expect(screen.queryByText(/Attempts:/)).not.toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
import { cn } from '../utils'
describe('cn utility', () => {
it('merges simple class names', () => {
expect(cn('a', 'b')).toBe('a b')
})
it('filters out falsy values', () => {
expect(cn('a', false, null, undefined, 'b')).toBe('a b')
})
it('deduplicates conflicting tailwind classes (twMerge)', () => {
// tailwind-merge should keep the latter of conflicting utilities
expect(cn('px-2', 'px-4')).toBe('px-4')
})
it('handles conditional classnames via objects', () => {
expect(cn('base', { active: true, disabled: false })).toContain('base')
expect(cn('base', { active: true, disabled: false })).toContain('active')
expect(cn('base', { active: true, disabled: false })).not.toContain('disabled')
})
it('handles arrays of classnames', () => {
expect(cn(['a', 'b'], 'c')).toBe('a b c')
})
it('returns empty string with no args', () => {
expect(cn()).toBe('')
})
it('handles nested arrays', () => {
expect(cn(['a', ['b', 'c']])).toBe('a b c')
})
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+2 -1
View File
@@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"include": [
"next-env.d.ts",