test(05-03): add CableTestPage unit tests with Vitest setup

- Install vitest, @testing-library/react, jsdom (Rule 3: missing test infra)
- Add vitest.config.ts with jsdom environment and @ alias
- Add src/test/setup.ts with jest-dom matchers and matchMedia stub
- Add CableTestPage.test.tsx: 4 tests (no-tester, recent list, print success, print_skipped)
- Mock AppShell to avoid TanStack Router context in tests
- All 4 tests pass; npm run build exits 0
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:21:08 +00:00
parent 499dbb9929
commit 4e5a3547f9
5 changed files with 2629 additions and 3 deletions

2430
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
@ -26,17 +27,23 @@
"zustand": "^4.5.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^4.1.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.7.0",
"jsdom": "^29.0.2",
"postcss": "^8.4.40",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.3",
"vite": "^5.3.5"
"vite": "^5.3.5",
"vitest": "^4.1.4"
}
}

View file

@ -0,0 +1,158 @@
import * as React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import { CableTestPage } from './CableTestPage'
// ---------------------------------------------------------------------------
// Mock AppShell — strips out TopBar which requires TanStack Router context
// ---------------------------------------------------------------------------
vi.mock('@/components/layout/AppShell', () => ({
AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// ---------------------------------------------------------------------------
// Mock EventSource — prevents real SSE connections in tests
// ---------------------------------------------------------------------------
class MockEventSource {
addEventListener = vi.fn()
removeEventListener = vi.fn()
close = vi.fn()
}
vi.stubGlobal('EventSource', MockEventSource)
// ---------------------------------------------------------------------------
// Mock the API module
// ---------------------------------------------------------------------------
vi.mock('@/api/test', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/test')>()
return {
...actual,
streamTestEvents: (_onReading: unknown) => new MockEventSource(),
getRecentTests: vi.fn().mockResolvedValue([]),
submitCableTest: vi.fn(),
}
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
function renderPage() {
const client = makeClient()
return render(
<QueryClientProvider client={client}>
<CableTestPage />
<Toaster />
</QueryClientProvider>,
)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CableTestPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders "No tester connected" when no SSE data', () => {
renderPage()
expect(screen.getByText('No tester connected')).toBeInTheDocument()
})
it('renders recent tests list', async () => {
const { getRecentTests } = await import('@/api/test')
vi.mocked(getRecentTests).mockResolvedValue([
{
cable_type: 0,
usb_version: 'USB 3.2 Gen 2',
dp_version: '',
hdmi_version: '',
speed_gbps: 10,
max_watts: 100,
pin_continuity: true,
has_emarker: true,
resistance_ohm: 0.12,
hw_id: 'HW-00001',
},
{
cable_type: 0,
usb_version: 'USB 2.0',
dp_version: '',
hdmi_version: '',
speed_gbps: 0.48,
max_watts: 10,
pin_continuity: false,
has_emarker: false,
resistance_ohm: 0.5,
hw_id: 'HW-00002',
},
])
renderPage()
await waitFor(() => {
expect(screen.getByText('HW-00001')).toBeInTheDocument()
expect(screen.getByText('HW-00002')).toBeInTheDocument()
})
})
it('Print & Next calls submitCableTest and shows success toast', async () => {
const user = userEvent.setup()
const { submitCableTest } = await import('@/api/test')
vi.mocked(submitCableTest).mockResolvedValue({
hw_id: 'HW-00001',
netbox_id: 1,
print_skipped: false,
})
renderPage()
// Fill in the HW ID
const hwIdInput = screen.getByLabelText('HW ID')
await user.clear(hwIdInput)
await user.type(hwIdInput, 'HW-00001')
// Click Print & Next
const button = screen.getByRole('button', { name: /print label and advance/i })
await user.click(button)
await waitFor(() => {
expect(submitCableTest).toHaveBeenCalled()
expect(screen.getByText('Label printed for HW-00001')).toBeInTheDocument()
})
})
it('Print & Next shows print_skipped toast', async () => {
const user = userEvent.setup()
const { submitCableTest } = await import('@/api/test')
vi.mocked(submitCableTest).mockResolvedValue({
hw_id: 'HW-00001',
netbox_id: 1,
print_skipped: true,
})
renderPage()
const hwIdInput = screen.getByLabelText('HW ID')
await user.clear(hwIdInput)
await user.type(hwIdInput, 'HW-00001')
const button = screen.getByRole('button', { name: /print label and advance/i })
await user.click(button)
await waitFor(() => {
expect(screen.getByText('Saved — printer not available')).toBeInTheDocument()
})
})
})

16
web/src/test/setup.ts Normal file
View file

@ -0,0 +1,16 @@
import '@testing-library/jest-dom'
// jsdom does not implement matchMedia — stub it so react-hot-toast renders
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})

17
web/vitest.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
})