test: add shared frontend test infra (factories, mocks, render)

Foundation for the frontend test consolidation (mirrors backend #267):
- src/test/factories.ts: canonical makeNode/makeEdge/makeNodeData/makeDesign
- src/test/mocks/: reusable mockSonner/mockReactFlow/makeUseCanvasStore builders
  consumed via async vi.mock dynamic-import to survive hoisting
- src/test/render.tsx: renderWithProviders wrapping Tooltip + ReactFlow providers

Migrate ScanConfigModal sonner mock as canary. No behavior change.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-10 02:15:49 +02:00
parent 6644a208bb
commit 64c48de1d1
4 changed files with 174 additions and 1 deletions
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
trigger: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
+73
View File
@@ -0,0 +1,73 @@
/**
* Canonical test fixture builders. Prefer these over redefining `makeNode` /
* `makeEdge` inline per test file — one source of truth keeps fixtures in sync
* with the domain types.
*
* See CLAUDE.md → Testing Protocol.
*/
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Design } from '@/types'
/** Build a `NodeData` payload with sane defaults; override any field. */
export function makeNodeData(overrides: Partial<NodeData> = {}): NodeData {
return {
label: overrides.label ?? 'Test Node',
type: 'server',
status: 'unknown',
services: [],
...overrides,
}
}
/**
* Build a React Flow node. Accepts either an id string or a partial-node
* object as the first arg, so it covers both inline patterns that existed
* across the suite:
* makeNode('n1', { type: 'router' })
* makeNode({ data: makeNodeData({ ip: '10.0.0.1' }) })
*/
export function makeNode(
idOrOverrides: string | Partial<Node<NodeData>> = 'n1',
dataOverrides: Partial<NodeData> = {},
): Node<NodeData> {
const base: Partial<Node<NodeData>> =
typeof idOrOverrides === 'string' ? { id: idOrOverrides } : idOrOverrides
const id = base.id ?? 'n1'
return {
id,
type: base.type ?? base.data?.type ?? dataOverrides.type ?? 'server',
position: base.position ?? { x: 0, y: 0 },
...base,
data: makeNodeData({ label: id, ...dataOverrides, ...base.data }),
}
}
/** Build a React Flow edge between two node ids. */
export function makeEdge(
id: string,
source: string,
target: string,
overrides: Partial<Edge<EdgeData>> = {},
): Edge<EdgeData> {
return {
id,
source,
target,
type: 'ethernet',
...overrides,
data: { type: 'ethernet', ...overrides.data },
}
}
/** Build a Design row. */
export function makeDesign(overrides: Partial<Design> = {}): Design {
return {
id: 'd1',
name: 'Test Design',
design_type: 'network',
icon: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Reusable mock builders. Consume them from an *async* `vi.mock` factory via
* dynamic import so the boilerplate lives in one place:
*
* vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
*
* `vi.mock` factories are hoisted above the file's imports, so a plain
* top-level `import { mockSonner }` referenced inside the factory throws
* "cannot access before initialization". The dynamic `import()` sidesteps
* that — it resolves lazily when the mocked module is first loaded.
*
* See CLAUDE.md → Testing Protocol.
*/
import { vi } from 'vitest'
/** `sonner` toast stub. Assert via `import { toast } from 'sonner'`. */
export function mockSonner() {
return {
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
message: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}
}
/**
* `@xyflow/react` stub covering the exports most node/edge component tests
* need. Pass `extra` to add or override exports for a specific test.
*/
export function mockReactFlow(extra: Record<string, unknown> = {}) {
return {
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
NodeResizer: () => null,
NodeToolbar: () => null,
BaseEdge: () => null,
EdgeLabelRenderer: ({ children }: { children?: unknown }) => children ?? null,
useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
useReactFlow: () => ({
getNode: vi.fn(),
getNodes: vi.fn(() => []),
getEdges: vi.fn(() => []),
screenToFlowPosition: vi.fn((p: unknown) => p),
}),
getBezierPath: () => ['M0,0', 0, 0] as const,
getSmoothStepPath: () => ['M0,0', 0, 0] as const,
...extra,
}
}
/**
* Build a `useCanvasStore` replacement for component tests that only read a
* selected slice of store state. Returns a function usable as the hook: it
* invokes the passed selector against `state` (falling back to returning the
* whole state when called with no selector).
*/
export function makeUseCanvasStore<S extends Record<string, unknown>>(state: S) {
return <R>(selector?: (s: S) => R): R | S => (selector ? selector(state) : state)
}
/** Common `serviceStatusKey` helper mirrored from the real store. */
export function serviceStatusKey(nodeId: string, port?: number, protocol?: string) {
return `${nodeId}:${port ?? ''}/${protocol ?? ''}`
}
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable react-refresh/only-export-components -- test helper module, not a component module */
/**
* Custom render that wraps the UI in the same providers the app mounts
* (`TooltipProvider`, `ReactFlowProvider`), so component tests that touch
* React Flow context or tooltips don't each re-declare the wrapper.
*
* Re-exports everything from `@testing-library/react`, so a test can:
* import { renderWithProviders, screen } from '@/test/render'
*
* Use the plain `render` from `@testing-library/react` for pure presentational
* components that need no context. See CLAUDE.md → Testing Protocol.
*/
import type { ReactElement, ReactNode } from 'react'
import { render, type RenderOptions } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { TooltipProvider } from '@/components/ui/tooltip'
function AllProviders({ children }: { children: ReactNode }) {
return (
<TooltipProvider>
<ReactFlowProvider>{children}</ReactFlowProvider>
</TooltipProvider>
)
}
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return render(ui, { wrapper: AllProviders, ...options })
}
export * from '@testing-library/react'