import { describe, it, expect, beforeEach } from 'vitest' import { useCanvasStore } from '@/stores/canvasStore' import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData } from '@/types' const makeNode = (id: string, overrides: Partial = {}): Node => ({ id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [], ...overrides }, }) const makeEdge = (id: string, source: string, target: string): Edge => ({ id, source, target, type: 'ethernet', data: { type: 'ethernet' }, }) describe('canvasStore', () => { beforeEach(() => { useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false, selectedNodeId: null, selectedNodeIds: [], editingGroupRectId: null, past: [], future: [], clipboard: [], }) }) it('starts empty', () => { const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState() expect(nodes).toHaveLength(0) expect(edges).toHaveLength(0) expect(hasUnsavedChanges).toBe(false) }) it('addNode adds a node and marks unsaved', () => { const { addNode } = useCanvasStore.getState() addNode(makeNode('n1')) const { nodes, hasUnsavedChanges } = useCanvasStore.getState() expect(nodes).toHaveLength(1) expect(nodes[0].id).toBe('n1') expect(hasUnsavedChanges).toBe(true) }) it('addNode nests under parent only when parent is in container mode', () => { const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } } const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } } useCanvasStore.getState().addNode(parent) useCanvasStore.getState().addNode(child) const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') expect(childNode?.parentId).toBeUndefined() useCanvasStore.getState().updateNode('p1', { container_mode: true }) useCanvasStore.getState().setProxmoxContainerMode('p1', true) const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') expect(nested?.parentId).toBe('p1') expect(nested?.extent).toBe('parent') }) it('docker_container nests under docker_host with container_mode on', () => { const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } } const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } } useCanvasStore.getState().addNode(host) useCanvasStore.getState().addNode(container) useCanvasStore.getState().updateNode('dc1', { parent_id: 'dh1' }) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'dc1') expect(node?.parentId).toBe('dh1') expect(node?.extent).toBe('parent') }) it('updateNode updates data fields', () => { useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' })) useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' }) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(node?.data.label).toBe('new') expect(node?.data.ip).toBe('10.0.0.1') }) it('updateNode setting parent_id on container-mode proxmox sets parentId and relative position', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' }) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1') expect(node?.parentId).toBe('px1') expect(node?.extent).toBe('parent') // Position should be relative to parent (160-100=60, 180-100=80) expect(node?.position.x).toBe(60) expect(node?.position.y).toBe(80) }) it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } } const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' }) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1') expect(node?.parentId).toBeUndefined() expect(node?.extent).toBeUndefined() }) it('updateNode clearing parent_id converts position to absolute and clears parentId', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined }) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1') expect(node?.parentId).toBeUndefined() expect(node?.extent).toBeUndefined() // Position should be absolute (100+30=130, 100+40=140) expect(node?.position.x).toBe(130) expect(node?.position.y).toBe(140) }) it('updateNode with parent_id puts parents before children in array', () => { const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 10, y: 10 } } useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' }) const { nodes } = useCanvasStore.getState() const pxIdx = nodes.findIndex((n) => n.id === 'px1') const lxcIdx = nodes.findIndex((n) => n.id === 'lxc1') expect(pxIdx).toBeLessThan(lxcIdx) }) it('deleteNode removes node and its connected edges', () => { const store = useCanvasStore.getState() store.addNode(makeNode('n1')) store.addNode(makeNode('n2')) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().deleteNode('n1') const { nodes, edges } = useCanvasStore.getState() expect(nodes.find((n) => n.id === 'n1')).toBeUndefined() expect(edges.find((e) => e.id === 'e1')).toBeUndefined() }) it('deleteNode clears selectedNodeId if it was the deleted node', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().setSelectedNode('n1') expect(useCanvasStore.getState().selectedNodeId).toBe('n1') useCanvasStore.getState().deleteNode('n1') expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) it('markSaved clears hasUnsavedChanges', () => { useCanvasStore.getState().addNode(makeNode('n1')) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) useCanvasStore.getState().markSaved() expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) it('loadCanvas replaces state', () => { useCanvasStore.getState().addNode(makeNode('old')) useCanvasStore.getState().loadCanvas([makeNode('n1'), makeNode('n2')], [makeEdge('e1', 'n1', 'n2')]) const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState() expect(nodes).toHaveLength(2) expect(edges).toHaveLength(1) expect(hasUnsavedChanges).toBe(false) }) it('setSelectedNode sets and clears selection', () => { useCanvasStore.getState().setSelectedNode('n1') expect(useCanvasStore.getState().selectedNodeId).toBe('n1') useCanvasStore.getState().setSelectedNode(null) expect(useCanvasStore.getState().selectedNodeId).toBeNull() }) it('onNodesChange marks unsaved for position changes', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('onNodesChange does not mark unsaved for select-only changes', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) it('onEdgesChange marks unsaved for remove changes', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('onEdgesChange does not mark unsaved for select-only changes', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }]) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) it('onConnect adds an edge between two nodes', () => { useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) const { edges, hasUnsavedChanges } = useCanvasStore.getState() expect(edges).toHaveLength(1) expect(edges[0].source).toBe('n1') expect(edges[0].target).toBe('n2') expect(hasUnsavedChanges).toBe(true) }) it('onConnect preserves type and label from edge data', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'wifi', label: 'uplink' }) useCanvasStore.getState().onConnect(conn) const { edges } = useCanvasStore.getState() expect(edges[0].type).toBe('wifi') expect(edges[0].data?.type).toBe('wifi') expect(edges[0].data?.label).toBe('uplink') }) it('onConnect preserves animated from edge data', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' }) useCanvasStore.getState().onConnect(conn) const { edges } = useCanvasStore.getState() expect(edges[0].data?.animated).toBe('snake') }) it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => { const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' }) useCanvasStore.getState().onConnect(conn) const { edges } = useCanvasStore.getState() expect(edges).toHaveLength(1) expect(edges[0].sourceHandle).toBe('cluster-right') expect(edges[0].targetHandle).toBe('cluster-left') expect(edges[0].type).toBe('cluster') }) it('deleteNode also removes children with matching parentId', () => { useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().deleteNode('parent') const { nodes } = useCanvasStore.getState() expect(nodes.find((n) => n.id === 'parent')).toBeUndefined() expect(nodes.find((n) => n.id === 'child')).toBeUndefined() }) it('addNode with parent_id sets parentId and extent when parent is in container mode', () => { useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child') expect(child?.parentId).toBe('parent') expect(child?.extent).toBe('parent') }) // ── selectedNodeIds ─────────────────────────────────────────────────────── it('selectedNodeIds starts empty', () => { expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) }) it('onNodesChange syncs selectedNodeIds from select changes', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().addNode(makeNode('n2')) useCanvasStore.getState().onNodesChange([ { type: 'select', id: 'n1', selected: true }, { type: 'select', id: 'n2', selected: true }, ]) expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2'])) expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2) }) it('setSelectedNode(null) resets selectedNodeIds to empty', () => { useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] }) useCanvasStore.getState().setSelectedNode(null) expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) }) it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => { useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] }) useCanvasStore.getState().setSelectedNode('n1') // Single node click resets multi-selection to just the clicked node expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1']) }) // ── createGroup ─────────────────────────────────────────────────────────── it('createGroup creates a group node at the bounding box of selected nodes', () => { // n1 at (100,100), n2 at (300,200); both default to 200x80 const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 } const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 } useCanvasStore.setState({ nodes: [n1, n2] }) useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group') const { nodes } = useCanvasStore.getState() const group = nodes.find((n) => n.data.type === 'group') expect(group).toBeDefined() expect(group?.data.label).toBe('My Group') // groupX = 100-24=76, groupY = 100-48=52 expect(group?.position.x).toBe(76) expect(group?.position.y).toBe(52) // groupW = (500-100)+48=448, groupH = (280-100)+48+24=252 expect(group?.width).toBe(448) expect(group?.height).toBe(252) }) it('createGroup converts children to relative positions', () => { const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 } const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 } useCanvasStore.setState({ nodes: [n1, n2] }) useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') const { nodes } = useCanvasStore.getState() const c1 = nodes.find((n) => n.id === 'n1') const c2 = nodes.find((n) => n.id === 'n2') // groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148) expect(c1?.position).toEqual({ x: 24, y: 48 }) expect(c2?.position).toEqual({ x: 224, y: 148 }) }) it('createGroup sets parentId and extent on children', () => { const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } } useCanvasStore.setState({ nodes: [n1, n2] }) useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') const { nodes } = useCanvasStore.getState() const group = nodes.find((n) => n.data.type === 'group')! const c1 = nodes.find((n) => n.id === 'n1') const c2 = nodes.find((n) => n.id === 'n2') expect(c1?.parentId).toBe(group.id) expect(c1?.extent).toBe('parent') expect(c2?.parentId).toBe(group.id) }) it('createGroup places the group node before its children in the array', () => { const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } } useCanvasStore.setState({ nodes: [n1, n2] }) useCanvasStore.getState().createGroup(['n1', 'n2'], 'G') const { nodes } = useCanvasStore.getState() const groupIdx = nodes.findIndex((n) => n.data.type === 'group') const c1Idx = nodes.findIndex((n) => n.id === 'n1') const c2Idx = nodes.findIndex((n) => n.id === 'n2') expect(groupIdx).toBeLessThan(c1Idx) expect(groupIdx).toBeLessThan(c2Idx) }) it('createGroup snapshots history and marks unsaved', () => { const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } useCanvasStore.setState({ nodes: [n1] }) useCanvasStore.getState().markSaved() useCanvasStore.getState().createGroup(['n1'], 'G') expect(useCanvasStore.getState().past).toHaveLength(1) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('createGroup clears selection', () => { const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } } useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) useCanvasStore.getState().createGroup(['n1'], 'G') expect(useCanvasStore.getState().selectedNodeId).toBeNull() expect(useCanvasStore.getState().selectedNodeIds).toEqual([]) }) // ── ungroup ─────────────────────────────────────────────────────────────── it('ungroup restores children to absolute positions', () => { const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, } const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const } const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const } useCanvasStore.setState({ nodes: [group, c1, c2] }) useCanvasStore.getState().ungroup('g1') const { nodes } = useCanvasStore.getState() const r1 = nodes.find((n) => n.id === 'n1') const r2 = nodes.find((n) => n.id === 'n2') expect(r1?.position).toEqual({ x: 100, y: 100 }) expect(r2?.position).toEqual({ x: 300, y: 200 }) }) it('ungroup removes parentId and extent from children', () => { const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const } useCanvasStore.setState({ nodes: [group, child] }) useCanvasStore.getState().ungroup('g1') const { nodes } = useCanvasStore.getState() const released = nodes.find((n) => n.id === 'n1') expect(released?.parentId).toBeUndefined() expect(released?.extent).toBeUndefined() }) it('ungroup deletes the group node', () => { const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } useCanvasStore.setState({ nodes: [group] }) useCanvasStore.getState().ungroup('g1') expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined() }) it('ungroup snapshots history and marks unsaved', () => { const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } useCanvasStore.setState({ nodes: [group] }) useCanvasStore.getState().markSaved() useCanvasStore.getState().ungroup('g1') expect(useCanvasStore.getState().past).toHaveLength(1) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('updateEdge updates edge data and marks unsaved', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().updateEdge('e1', { type: 'wifi', label: 'uplink' }) const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(edge?.data?.type).toBe('wifi') expect(edge?.data?.label).toBe('uplink') expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('deleteEdge removes the edge and marks unsaved', () => { useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] })) useCanvasStore.getState().markSaved() useCanvasStore.getState().deleteEdge('e1') const { edges, hasUnsavedChanges } = useCanvasStore.getState() expect(edges.find((e) => e.id === 'e1')).toBeUndefined() expect(edges.find((e) => e.id === 'e2')).toBeDefined() expect(hasUnsavedChanges).toBe(true) }) it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => { const host: Node = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } } useCanvasStore.setState({ nodes: [host] }) useCanvasStore.getState().setProxmoxContainerMode('dh', true) const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh') expect(updated?.data.container_mode).toBe(true) expect(updated?.width).toBe(300) expect(updated?.height).toBe(200) }) it('setProxmoxContainerMode OFF clears width/height for docker_host', () => { const host: Node = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } } useCanvasStore.setState({ nodes: [host] }) useCanvasStore.getState().setProxmoxContainerMode('dh', false) const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh') expect(updated?.data.container_mode).toBe(false) expect(updated?.width).toBeUndefined() expect(updated?.height).toBeUndefined() }) it('setProxmoxContainerMode ON nests children inside proxmox', () => { const proxmox: Node = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } } const child = makeNode('vm1', { parent_id: 'px', type: 'vm' }) useCanvasStore.setState({ nodes: [proxmox, child] }) useCanvasStore.getState().setProxmoxContainerMode('px', true) const { nodes } = useCanvasStore.getState() const updatedProxy = nodes.find((n) => n.id === 'px') const updatedChild = nodes.find((n) => n.id === 'vm1') expect(updatedProxy?.data.container_mode).toBe(true) expect(updatedProxy?.width).toBe(300) expect(updatedChild?.parentId).toBe('px') expect(updatedChild?.extent).toBe('parent') }) it('setProxmoxContainerMode OFF detaches children', () => { const proxmox: Node = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: true }, parentId: undefined } const child: Node = { id: 'vm1', type: 'vm', position: { x: 0, y: 0 }, data: { label: 'vm1', type: 'vm', status: 'unknown', services: [], parent_id: 'px' }, parentId: 'px', extent: 'parent' } useCanvasStore.setState({ nodes: [proxmox, child] }) useCanvasStore.getState().setProxmoxContainerMode('px', false) const { nodes } = useCanvasStore.getState() const updatedChild = nodes.find((n) => n.id === 'vm1') expect(nodes.find((n) => n.id === 'px')?.data.container_mode).toBe(false) expect(updatedChild?.parentId).toBeUndefined() expect(updatedChild?.extent).toBeUndefined() }) it('setEditingGroupRectId sets and clears the editing id', () => { useCanvasStore.getState().setEditingGroupRectId('rect-1') expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1') useCanvasStore.getState().setEditingGroupRectId(null) expect(useCanvasStore.getState().editingGroupRectId).toBeNull() }) it('setNodeZIndex updates the node zIndex and marks unsaved', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() useCanvasStore.getState().setNodeZIndex('n1', -5) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(node?.zIndex).toBe(-5) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('addNode with groupRect type preserves zIndex and dimensions', () => { const rectNode: Node = { id: 'rect-1', type: 'groupRect', position: { x: 100, y: 100 }, data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] }, width: 360, height: 240, zIndex: -9, } useCanvasStore.getState().addNode(rectNode) const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1') expect(stored?.type).toBe('groupRect') expect(stored?.zIndex).toBe(-9) expect(stored?.width).toBe(360) expect(stored?.height).toBe(240) }) it('loadCanvas sorts parents before children', () => { const parent = makeNode('p1') const child: Node = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' } useCanvasStore.getState().loadCanvas([child, parent], []) const { nodes } = useCanvasStore.getState() const parentIdx = nodes.findIndex((n) => n.id === 'p1') const childIdx = nodes.findIndex((n) => n.id === 'c1') expect(parentIdx).toBeLessThan(childIdx) }) // --- History (undo/redo) --- it('snapshotHistory pushes current state to past and clears future', () => { const { addNode, snapshotHistory } = useCanvasStore.getState() addNode(makeNode('n1')) snapshotHistory() const { past, future } = useCanvasStore.getState() expect(past).toHaveLength(1) expect(past[0].nodes).toHaveLength(1) expect(future).toHaveLength(0) }) it('undo restores previous state and moves current to future', () => { const { addNode, snapshotHistory, undo } = useCanvasStore.getState() addNode(makeNode('n1')) snapshotHistory() addNode(makeNode('n2')) undo() const { nodes, past, future } = useCanvasStore.getState() expect(nodes).toHaveLength(1) expect(nodes[0].id).toBe('n1') expect(past).toHaveLength(0) expect(future).toHaveLength(1) }) it('redo re-applies undone state', () => { const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState() addNode(makeNode('n1')) snapshotHistory() addNode(makeNode('n2')) undo() redo() const { nodes, future } = useCanvasStore.getState() expect(nodes).toHaveLength(2) expect(future).toHaveLength(0) }) it('undo does nothing when past is empty', () => { const { addNode, undo } = useCanvasStore.getState() addNode(makeNode('n1')) undo() expect(useCanvasStore.getState().nodes).toHaveLength(1) }) it('snapshotHistory clears future (new branch)', () => { const { addNode, snapshotHistory, undo } = useCanvasStore.getState() addNode(makeNode('n1')) snapshotHistory() addNode(makeNode('n2')) undo() // now take a new action snapshotHistory() addNode(makeNode('n3')) expect(useCanvasStore.getState().future).toHaveLength(0) }) // --- Clipboard (copy/paste) --- it('copySelectedNodes stores only selected nodes', () => { useCanvasStore.setState({ nodes: [ { ...makeNode('a'), selected: true }, { ...makeNode('b'), selected: false }, ], edges: [], }) useCanvasStore.getState().copySelectedNodes() const { clipboard } = useCanvasStore.getState() expect(clipboard).toHaveLength(1) expect(clipboard[0].id).toBe('a') }) it('pasteNodes creates new nodes with new IDs and offset position', () => { const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true } useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] }) useCanvasStore.getState().pasteNodes() const { nodes } = useCanvasStore.getState() expect(nodes).toHaveLength(2) const pasted = nodes.find((n) => n.id !== 'src')! expect(pasted).toBeDefined() expect(pasted.position.x).toBe(150) expect(pasted.position.y).toBe(150) expect(pasted.selected).toBe(false) }) it('pasteNodes does nothing when clipboard is empty', () => { useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] }) useCanvasStore.getState().pasteNodes() expect(useCanvasStore.getState().nodes).toHaveLength(1) }) // --- Node resizing (width / height) --- it('addNode preserves explicit width and height', () => { const node: Node = { ...makeNode('n1'), width: 280, height: 120 } useCanvasStore.getState().addNode(node) const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(stored?.width).toBe(280) expect(stored?.height).toBe(120) }) it('onNodesChange dimensions change updates width and height', () => { useCanvasStore.getState().addNode(makeNode('n1')) useCanvasStore.getState().markSaved() useCanvasStore.getState().onNodesChange([ { type: 'dimensions', id: 'n1', dimensions: { width: 320, height: 180 }, resizing: true }, ]) const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(node?.measured?.width ?? node?.width).toBeDefined() expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('loadCanvas preserves width and height on resized nodes', () => { const resized: Node = { ...makeNode('n1'), width: 300, height: 160 } useCanvasStore.getState().loadCanvas([resized], []) const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(stored?.width).toBe(300) expect(stored?.height).toBe(160) }) it('loadCanvas preserves undefined width/height for default-sized nodes', () => { useCanvasStore.getState().loadCanvas([makeNode('n1')], []) const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') expect(stored?.width).toBeUndefined() expect(stored?.height).toBeUndefined() }) // ── bottom_handles edge remapping ────────────────────────────────────────── it('remaps source edges to "bottom" when bottom_handles is reduced', () => { const node = makeNode('n1', { bottom_handles: 4 }) const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' } useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 }) const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom') }) it('remaps target edges to "bottom" when bottom_handles is reduced', () => { const node = makeNode('n2', { bottom_handles: 3 }) const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' } useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] }) useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 }) const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.targetHandle).toBe('bottom') }) it('does not remap edges that are on handles still present after reduction', () => { const node = makeNode('n1', { bottom_handles: 4 }) const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' } useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 }) const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom-2') }) it('does not remap edges when bottom_handles increases', () => { const node = makeNode('n1', { bottom_handles: 2 }) const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' } useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 }) const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom') }) it('never remaps the "bottom" handle itself', () => { const node = makeNode('n1', { bottom_handles: 4 }) const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' } useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 }) const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1') expect(updated?.sourceHandle).toBe('bottom') }) // Regression: handle cap raised from 4 to 48 — remap must scale. it('remaps high-count handles when shrinking from 12 to 2', () => { const node = makeNode('n1', { bottom_handles: 12 }) const edges = [ { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' }, { ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' }, { ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' }, ] useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 }) const after = useCanvasStore.getState().edges expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2') expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom') expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom') }) }) describe('canvasStore — custom style apply', () => { beforeEach(() => { useCanvasStore.setState({ nodes: [], edges: [], hasUnsavedChanges: false, selectedNodeId: null, selectedNodeIds: [], editingGroupRectId: null, past: [], future: [], clipboard: [], }) }) const serverStyle = { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#111111', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 220, height: 90, } it('applyTypeNodeStyle updates matching nodes custom_colors', () => { useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })], edges: [], }) useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')! expect(n1.data.custom_colors?.border).toBe('#ff0000') expect(n1.width).toBe(220) expect(n1.height).toBe(90) expect(n2.data.custom_colors?.border).toBeUndefined() }) it('applyTypeNodeStyle with opacity < 1 produces rgba', () => { useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] }) useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 }) const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/) }) it('applyTypeNodeStyle marks canvas unsaved', () => { useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] }) useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) it('applyTypeEdgeStyle updates matching edges', () => { const e1: Edge = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } const e2: Edge = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } } useCanvasStore.setState({ nodes: [], edges: [e1, e2] }) useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' }) const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')! expect(updated1.data?.custom_color).toBe('#00ff00') expect(updated1.data?.path_style).toBe('smooth') expect(updated1.data?.animated).toBe('flow') expect(updated2.data?.custom_color).toBeUndefined() }) it('applyAllCustomStyles applies all defined types', () => { const proxmoxNode = makeNode('np', { type: 'proxmox' }) const serverNode = makeNode('ns', { type: 'server' }) const e1: Edge = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } } useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] }) useCanvasStore.getState().applyAllCustomStyles({ nodes: { proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 }, }, edges: { ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' }, }, }) const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')! const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')! const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! expect(np.data.custom_colors?.border).toBe('#ff6e00') expect(ns.data.custom_colors?.border).toBeUndefined() expect(e.data?.custom_color).toBe('#aabbcc') expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) })