test: add component tests for LoginPage, modals, and CanvasContainer

This commit is contained in:
Pouzor
2026-03-28 12:21:21 +01:00
parent 565f4337c8
commit b35b51d5b2
7 changed files with 1019 additions and 0 deletions
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { EdgeModal } from '../EdgeModal'
describe('EdgeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when closed', () => {
const { container } = render(<EdgeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form when open', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Connect Nodes')).toBeDefined()
})
it('uses custom title when provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Link" />)
expect(screen.getByText('Edit Link')).toBeDefined()
})
// ── Submit button label ───────────────────────────────────────────────────
it('shows "Connect" button when onDelete is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined()
})
it('shows "Save" button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
// ── Default submit ────────────────────────────────────────────────────────
it('calls onSubmit with default ethernet type', () => {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].type).toBe('ethernet')
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
})
it('omits label from payload when empty', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
})
// ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull()
})
it('submits integer vlan_id when type is vlan', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'vlan', vlan_id: 20 }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBe(20)
})
it('omits vlan_id from payload for non-vlan types', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBeUndefined()
})
// ── Path style ────────────────────────────────────────────────────────────
it('defaults to bezier path style', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('bezier')
})
it('switches path style to smooth on click', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Smooth step'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Animated toggle ───────────────────────────────────────────────────────
it('flow animation defaults to off', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
// animated: false → omitted (falsy || undefined)
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
})
it('toggling animation sends animated: true', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// Find the toggle: it's the only button with aria-pressed attribute
const allButtons = screen.getAllByRole('button')
const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))!
expect(toggle).toBeDefined()
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe(true)
})
// ── Pre-fill ──────────────────────────────────────────────────────────────
it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
expect(input.value).toBe('trunk')
})
it('pre-fills path style from initial prop', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ path_style: 'smooth' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Cancel & Delete ───────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined()
})
it('does not show Delete button without onDelete', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
})
it('calls onDelete and onClose when Delete is clicked', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
return {
id: 'dev-1',
ip: '192.168.1.100',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pve.local',
os: 'Linux',
services: [],
suggested_type: 'server',
status: 'pending',
discovered_at: '2024-01-15T10:30:00Z',
...overrides,
}
}
describe('PendingDeviceModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when device is null', () => {
const { container } = render(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
// ── Device info display ───────────────────────────────────────────────────
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// hostname appears in both title and info row — check at least one match
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
})
it('falls back to IP as title when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// IP appears in both title and info row when hostname is absent
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
})
it('shows IP address', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
})
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
// ── Services ──────────────────────────────────────────────────────────────
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('No services detected')).toBeDefined()
})
it('shows service count and details', () => {
const device = makeDevice({
services: [
{ port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' },
{ port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' },
],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Services found (2)')).toBeDefined()
expect(screen.getByText('HTTP')).toBeDefined()
expect(screen.getByText('HTTPS')).toBeDefined()
expect(screen.getByText('80')).toBeDefined()
expect(screen.getByText('443')).toBeDefined()
})
it('shows service category when present', () => {
const device = makeDevice({
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('hypervisor')).toBeDefined()
})
// ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device and onClose when Approve is clicked', () => {
const device = makeDevice()
const onApprove = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onHide with the device and onClose when Hide is clicked', () => {
const device = makeDevice()
const onHide = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(onHide).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onIgnore with the device and onClose when Delete is clicked', () => {
const device = makeDevice()
const onIgnore = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ScanConfigModal } from '../ScanConfigModal'
vi.mock('@/api/client', () => ({
scanApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } }
describe('ScanConfigModal', () => {
beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('loads config from API on open', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => {
expect(scanApi.getConfig).toHaveBeenCalledOnce()
})
const input = await screen.findByDisplayValue('192.168.1.0/24')
expect(input).toBeDefined()
})
it('loads interval from API on open', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByText('Add range'))
const inputs = screen.getAllByPlaceholderText('192.168.1.0/24')
expect(inputs).toHaveLength(2)
})
it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Only 1 range → delete button disabled
const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0)
})
it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
})
it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
})
expect(scanApi.saveConfig).not.toHaveBeenCalled()
})
it('saves config and closes on Save click', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
})
it('shows error toast when save fails', async () => {
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
})
})
it('calls onScanNow after saving on "Scan Now" click', async () => {
const onScanNow = vi.fn()
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalled()
expect(onScanNow).toHaveBeenCalledOnce()
})
})
it('calls onClose when Cancel is clicked', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('strips whitespace from ranges before saving', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24')
// Type a range with surrounding whitespace
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
)
})
})
})
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchModal } from '../SearchModal'
import { useCanvasStore } from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const mockFitView = vi.fn()
vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }),
}))
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: id,
type: overrides.type ?? 'server',
status: 'unknown',
services: [],
...overrides,
},
}
}
describe('SearchModal', () => {
beforeEach(() => {
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
mockFitView.mockReset()
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
})
it('filters nodes by label', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
})
it('filters nodes by IP', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'Box A', ip: '192.168.1.10' }),
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
})
it('filters nodes by hostname', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'A', hostname: 'pve.local' }),
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
})
it('excludes groupRect nodes from results', () => {
useCanvasStore.setState({
nodes: [
makeNode('n1', { label: 'Server', type: 'server' }),
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
})
it('limits results to 8 nodes', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8)
})
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
expect(mockFitView).toHaveBeenCalledWith(expect.objectContaining({ nodes: [{ id: 'n1' }] }))
expect(onClose).toHaveBeenCalledOnce()
})
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
expect(onClose).toHaveBeenCalledOnce()
})
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
expect(onClose).toHaveBeenCalledOnce()
})
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})
})
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ThemeModal } from '../ThemeModal'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEME_ORDER } from '@/utils/themes'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { toast } from 'sonner'
describe('ThemeModal', () => {
beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' })
useCanvasStore.setState({ hasUnsavedChanges: false })
vi.mocked(toast.info).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ThemeModal open={false} onClose={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders all available themes', () => {
render(<ThemeModal open onClose={vi.fn()} />)
// Every theme in THEME_ORDER should have a card rendered
expect(THEME_ORDER.length).toBeGreaterThan(0)
// At minimum the dialog title should be present
expect(screen.getByText('Choose Canvas Style')).toBeDefined()
})
it('shows Apply Style button', () => {
render(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Apply Style' })).toBeDefined()
})
it('shows Cancel button', () => {
render(<ThemeModal open onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
})
it('live-previews theme when a card is clicked', () => {
render(<ThemeModal open onClose={vi.fn()} />)
const initialTheme = useThemeStore.getState().activeTheme
// Click a different theme card (find by button role, pick a non-default one)
const cards = screen.getAllByRole('button').filter((b) =>
b.className.includes('rounded-xl')
)
// Click the second card (first non-selected)
fireEvent.click(cards[1])
// Theme should have changed for live preview
expect(useThemeStore.getState().activeTheme).not.toBe(initialTheme)
})
it('Apply sets theme, marks unsaved, and closes', () => {
const onClose = vi.fn()
render(<ThemeModal open onClose={onClose} />)
// Click a non-default card first
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
const previewTheme = useThemeStore.getState().activeTheme
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
expect(useThemeStore.getState().activeTheme).toBe(previewTheme)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
expect(onClose).toHaveBeenCalledOnce()
})
it('Apply shows toast asking user to save canvas', () => {
render(<ThemeModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Apply Style' }))
expect(toast.info).toHaveBeenCalledWith(
expect.stringContaining('save'),
expect.any(Object)
)
})
it('Cancel reverts to original theme and closes', () => {
const onClose = vi.fn()
useThemeStore.setState({ activeTheme: 'default' })
render(<ThemeModal open onClose={onClose} />)
// Preview a different theme
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
expect(useThemeStore.getState().activeTheme).not.toBe('default')
// Cancel should revert
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(useThemeStore.getState().activeTheme).toBe('default')
expect(onClose).toHaveBeenCalledOnce()
})
it('Cancel does not mark canvas as unsaved', () => {
render(<ThemeModal open onClose={vi.fn()} />)
const cards = screen.getAllByRole('button').filter((b) => b.className.includes('rounded-xl'))
fireEvent.click(cards[1])
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
})
})