mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-19 19:34:25 +00:00
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:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+2192
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
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -20,7 +20,8 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["node", "jest", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user