import { describe, it, expect, vi, beforeEach } from 'vitest' import { render } from '@testing-library/react' import { CanvasContainer } from '../CanvasContainer' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData } from '@/types' // Capture props passed to ReactFlow so we can test the callbacks let rfProps: Record = {} vi.mock('@xyflow/react', () => ({ ReactFlow: (props: Record) => { rfProps = props return
}, Background: () => null, Controls: () => null, ControlButton: () => null, BackgroundVariant: { Dots: 'dots' }, ConnectionMode: { Loose: 'loose' }, SelectionMode: { Partial: 'partial' }, useReactFlow: () => ({ fitView: vi.fn() }), })) vi.mock('@xyflow/react/dist/style.css', () => ({})) function makeNode(id: string): Node { return { id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [] }, } } function makeEdge(id: string): Edge { return { id, source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } } describe('CanvasContainer', () => { beforeEach(() => { rfProps = {} useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) useThemeStore.setState({ activeTheme: 'default' }) }) // ── Rendering ───────────────────────────────────────────────────────────── it('renders without crashing', () => { const { getByTestId } = render() expect(getByTestId('react-flow')).toBeDefined() }) it('passes nodes from store to ReactFlow', () => { useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] }) render() expect((rfProps.nodes as Node[]).length).toBe(2) }) it('passes edges from store to ReactFlow', () => { useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')], edges: [makeEdge('e1')], }) render() expect((rfProps.edges as Edge[]).length).toBe(1) }) // ── Node click → selection ──────────────────────────────────────────────── it('calls setSelectedNode with node id on node click', () => { const node = makeNode('n1') useCanvasStore.setState({ nodes: [node] }) render() ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) expect(useCanvasStore.getState().selectedNodeId).toBe('n1') }) // ── Pane click → deselect ───────────────────────────────────────────────── it('calls setSelectedNode(null) on pane click', () => { useCanvasStore.setState({ selectedNodeId: 'n1' }) render() ;(rfProps.onPaneClick as (...args: unknown[]) => unknown)() expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) // ── Edge double-click ───────────────────────────────────────────────────── it('calls onEdgeDoubleClick prop when an edge is double-clicked', () => { const onEdgeDoubleClick = vi.fn() const edge = makeEdge('e1') render() ;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge) expect(onEdgeDoubleClick).toHaveBeenCalledWith(edge) }) it('does not throw when onEdgeDoubleClick is not provided', () => { const edge = makeEdge('e1') render() expect(() => { ;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge) }).not.toThrow() }) // ── Node double-click ───────────────────────────────────────────────────── it('calls onNodeDoubleClick prop when a node is double-clicked', () => { const onNodeDoubleClick = vi.fn() const node = makeNode('n1') render() ;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) expect(onNodeDoubleClick).toHaveBeenCalledWith(node) }) it('does not throw when onNodeDoubleClick is not provided', () => { const node = makeNode('n1') render() expect(() => { ;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node) }).not.toThrow() }) // ── Connection validation ───────────────────────────────────────────────── it('isValidConnection returns false for self-connections', () => { render() const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean expect(isValid({ source: 'n1', target: 'n1' })).toBe(false) }) it('isValidConnection returns true for different nodes', () => { render() const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean expect(isValid({ source: 'n1', target: 'n2' })).toBe(true) }) // ── onConnect prop passthrough ──────────────────────────────────────────── it('passes onConnect prop to ReactFlow', () => { const onConnect = vi.fn() render() ;(rfProps.onConnect as (...args: unknown[]) => unknown)({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null }) expect(onConnect).toHaveBeenCalledOnce() }) // ── onNodeDragStart prop passthrough ────────────────────────────────────── it('passes onNodeDragStart prop to ReactFlow', () => { const onNodeDragStart = vi.fn() render() expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) }) // ── Canvas settings ─────────────────────────────────────────────────────── it('enables snapToGrid', () => { render() expect(rfProps.snapToGrid).toBe(true) }) it('sets snapGrid to [8, 8]', () => { render() expect(rfProps.snapGrid).toEqual([8, 8]) }) // ── Delete key ──────────────────────────────────────────────────────────── it('sets deleteKeyCode to include both Backspace and Delete', () => { render() expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete']) }) // ── Lasso / multi-select ────────────────────────────────────────────────── it('enables selectionOnDrag for lasso selection', () => { render() expect(rfProps.selectionOnDrag).toBe(true) }) it('sets panActivationKeyCode to Space', () => { render() expect(rfProps.panActivationKeyCode).toBe('Space') }) it('sets panOnDrag to [1, 2]', () => { render() expect(rfProps.panOnDrag).toEqual([1, 2]) }) it('sets selectionMode to Partial', () => { render() expect(rfProps.selectionMode).toBe('partial') }) it('sets multiSelectionKeyCode to Meta and Control', () => { render() expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control']) }) it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => { const node = makeNode('n1') useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' }) render() ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)( { ctrlKey: true, metaKey: false } as unknown as MouseEvent, node, ) expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) it('clears selectedNode (sets null) on Cmd+click', () => { const node = makeNode('n1') useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' }) render() ;(rfProps.onNodeClick as (...args: unknown[]) => unknown)( { ctrlKey: false, metaKey: true } as unknown as MouseEvent, node, ) expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) // ── onBeforeDelete snapshot ─────────────────────────────────────────────── it('onBeforeDelete calls snapshotHistory and returns true', async () => { const snapshotHistory = vi.fn() useCanvasStore.setState({ snapshotHistory } as unknown as Parameters[0]) render() const result = await (rfProps.onBeforeDelete as () => Promise)() expect(snapshotHistory).toHaveBeenCalledOnce() expect(result).toBe(true) }) })