diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts deleted file mode 100644 index 0dea6bb..0000000 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ /dev/null @@ -1,1430 +0,0 @@ -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, - editingTextId: null, - past: [], - future: [], - clipboard: { nodes: [], edges: [] }, - serviceStatuses: {}, - }) - }) - - it('setServiceStatuses stores live status keyed by node/port/protocol', () => { - const { setServiceStatuses } = useCanvasStore.getState() - setServiceStatuses('node-1', [ - { port: 80, protocol: 'tcp', status: 'offline' }, - { port: 443, protocol: 'tcp', status: 'online' }, - ]) - const { serviceStatuses } = useCanvasStore.getState() - expect(serviceStatuses['node-1:80/tcp']).toBe('offline') - expect(serviceStatuses['node-1:443/tcp']).toBe('online') - }) - - it('setServiceStatuses merges without dropping other nodes', () => { - const { setServiceStatuses } = useCanvasStore.getState() - setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }]) - setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }]) - const { serviceStatuses } = useCanvasStore.getState() - expect(serviceStatuses['node-1:80/tcp']).toBe('online') - expect(serviceStatuses['node-2:22/tcp']).toBe('offline') - }) - - it('does not mark canvas unsaved on a service status update', () => { - useCanvasStore.setState({ hasUnsavedChanges: false }) - useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }]) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('setEditingTextId sets and clears editing text id', () => { - const { setEditingTextId } = useCanvasStore.getState() - setEditingTextId('t1') - expect(useCanvasStore.getState().editingTextId).toBe('t1') - setEditingTextId(null) - expect(useCanvasStore.getState().editingTextId).toBeNull() - }) - - it('addNode supports text type with text_content and style', () => { - const { addNode } = useCanvasStore.getState() - const textNode: Node = { - id: 't1', - type: 'text', - position: { x: 50, y: 50 }, - data: { - label: '', - type: 'text', - status: 'unknown', - services: [], - text_content: 'Hello world', - custom_colors: { - text_color: '#ffffff', - text_size: 24, - font: 'mono', - border_style: 'dashed', - border_width: 2, - border: '#00d4ff', - background: '#00000000', - }, - }, - width: 200, - height: 60, - } - addNode(textNode) - const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1') - expect(stored?.type).toBe('text') - expect(stored?.data.text_content).toBe('Hello world') - expect(stored?.data.custom_colors?.text_size).toBe(24) - expect(stored?.data.custom_colors?.border_style).toBe('dashed') - }) - - it('updateNode updates text_content and custom_colors on a text node', () => { - const { addNode, updateNode } = useCanvasStore.getState() - addNode({ ...makeNode('t1', { type: 'text', text_content: 'old' }), type: 'text' }) - updateNode('t1', { text_content: 'new', custom_colors: { text_color: '#ff0000', text_size: 32 } }) - const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1') - expect(stored?.data.text_content).toBe('new') - expect(stored?.data.custom_colors?.text_color).toBe('#ff0000') - expect(stored?.data.custom_colors?.text_size).toBe(32) - }) - - it('deleteNode removes a text node', () => { - const { addNode, deleteNode } = useCanvasStore.getState() - addNode({ ...makeNode('t1', { type: 'text' }), type: 'text' }) - deleteNode('t1') - expect(useCanvasStore.getState().nodes).toHaveLength(0) - }) - - 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('addNode strips parentId/extent when the parent is not a container', () => { - // Regression: a stray extent:'parent' on a non-container parent traps the - // node in the parent's tiny box with no way to drag it out (issue #205). - const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } } - useCanvasStore.getState().addNode(parent) - const trapped: Node = { - ...makeNode('c1', { parent_id: 'p1' }), - position: { x: 150, y: 180 }, - parentId: 'p1', - extent: 'parent', - } - useCanvasStore.getState().addNode(trapped) - const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') - expect(child?.parentId).toBeUndefined() - expect(child?.extent).toBeUndefined() - }) - - 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 allows multiple edges between the same two nodes (no dedupe)', () => { - // Regression: React Flow addEdge() dropped a second edge between the same - // source+target when handles were null/equal. A homelab has multiple links - // between two devices, so every connect must add an edge. - useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) - useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) - const { edges } = useCanvasStore.getState() - expect(edges).toHaveLength(2) - expect(edges[0].id).not.toBe(edges[1].id) - }) - - it('onConnect preserves endpoint marker shapes from edge data', () => { - const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' }) - useCanvasStore.getState().onConnect(conn) - const { edges } = useCanvasStore.getState() - expect(edges[0].data?.marker_start).toBe('diamond') - expect(edges[0].data?.marker_end).toBe('arrow') - }) - - 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) - }) - - // ── addToGroup ────────────────────────────────────────────────────────────── - - it('addToGroup nests a top-level node with parent-relative position', () => { - const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 } - const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } - useCanvasStore.setState({ nodes: [group, child] }) - - useCanvasStore.getState().addToGroup('g1', 'n1') - - const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') - expect(moved?.parentId).toBe('g1') - expect(moved?.extent).toBe('parent') - expect(moved?.data.parent_id).toBe('g1') - // 300-76=224, 200-52=148 - expect(moved?.position).toEqual({ x: 224, y: 148 }) - }) - - it('addToGroup places the group before the child in the array', () => { - const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } - const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } - // child first to prove reordering - useCanvasStore.setState({ nodes: [child, group] }) - - useCanvasStore.getState().addToGroup('g1', 'n1') - - const { nodes } = useCanvasStore.getState() - expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) - }) - - it('addToGroup is a no-op when target is not a group', () => { - const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [notGroup, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().addToGroup('s1', 'n1') - - expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('addToGroup is a no-op when child already belongs to the group', () => { - 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().markSaved() - - useCanvasStore.getState().addToGroup('g1', 'n1') - - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('addToGroup snapshots history and marks unsaved', () => { - const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [group, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().addToGroup('g1', 'n1') - - expect(useCanvasStore.getState().past).toHaveLength(1) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - // ── addToContainer ────────────────────────────────────────────────────────── - - it('addToContainer nests a top-level node under a container_mode node', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 } - const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } - useCanvasStore.setState({ nodes: [container, child] }) - - useCanvasStore.getState().addToContainer('px1', 'n1') - - const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') - expect(moved?.parentId).toBe('px1') - expect(moved?.extent).toBe('parent') - expect(moved?.data.parent_id).toBe('px1') - // 300-76=224, 200-52=148 - expect(moved?.position).toEqual({ x: 224, y: 148 }) - }) - - it('addToContainer works for any container_mode type (docker_host)', () => { - const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [host, child] }) - - useCanvasStore.getState().addToContainer('dh1', 'n1') - - expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1') - }) - - it('addToContainer places the container before the child in the array', () => { - const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } - useCanvasStore.setState({ nodes: [child, container] }) - - useCanvasStore.getState().addToContainer('px1', 'n1') - - const { nodes } = useCanvasStore.getState() - expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) - }) - - it('addToContainer is a no-op when target is not in container_mode', () => { - const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [notContainer, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().addToContainer('px1', 'n1') - - expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('addToContainer is a no-op when child already belongs to the container', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const } - useCanvasStore.setState({ nodes: [container, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().addToContainer('px1', 'n1') - - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('addToContainer snapshots history and marks unsaved', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [container, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().addToContainer('px1', 'n1') - - expect(useCanvasStore.getState().past).toHaveLength(1) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - // ── removeFromGroup ───────────────────────────────────────────────────────── - - it('removeFromGroup releases the child to absolute coords and keeps the group', () => { - const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } } - const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const } - useCanvasStore.setState({ nodes: [group, child] }) - - useCanvasStore.getState().removeFromGroup('g1', 'n1') - - const { nodes } = useCanvasStore.getState() - const released = nodes.find((n) => n.id === 'n1') - expect(released?.parentId).toBeUndefined() - expect(released?.extent).toBeUndefined() - expect(released?.data.parent_id).toBeUndefined() - // 224+76=300, 148+52=200 - expect(released?.position).toEqual({ x: 300, y: 200 }) - // group survives - expect(nodes.find((n) => n.id === 'g1')).toBeDefined() - }) - - it('removeFromGroup is a no-op when child is not in the group', () => { - const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [group, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().removeFromGroup('g1', 'n1') - - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('removeFromGroup snapshots history and marks unsaved', () => { - 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().markSaved() - - useCanvasStore.getState().removeFromGroup('g1', 'n1') - - 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('reconnectEdge swaps source/target and normalizes handles', () => { - useCanvasStore.setState((s) => ({ - edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }], - })) - useCanvasStore.getState().markSaved() - useCanvasStore.getState().reconnectEdge('e1', { - source: 'n1', - target: 'n3', - sourceHandle: 'bottom-2-t', - targetHandle: 'top-t', - }) - const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1') - expect(edge?.target).toBe('n3') - expect(edge?.source).toBe('n1') - expect(edge?.sourceHandle).toBe('bottom-2') - expect(edge?.targetHandle).toBe('top') - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - it('reconnectEdge snapshots history for undo', () => { - useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] })) - useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null }) - expect(useCanvasStore.getState().past.length).toBe(1) - }) - - 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.nodes).toHaveLength(1) - expect(clipboard.nodes[0].id).toBe('a') - }) - - it('copySelectedNodes captures edges whose endpoints are both selected', () => { - useCanvasStore.setState({ - nodes: [ - { ...makeNode('a'), selected: true }, - { ...makeNode('b'), selected: true }, - { ...makeNode('c'), selected: false }, - ], - edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')], - }) - useCanvasStore.getState().copySelectedNodes() - const { clipboard } = useCanvasStore.getState() - expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b']) - expect(clipboard.edges).toHaveLength(1) - expect(clipboard.edges[0].id).toBe('e-ab') - }) - - it('copySelectedNodes pulls in children of a selected group', () => { - useCanvasStore.setState({ - nodes: [ - { ...makeNode('g', { type: 'group' }), type: 'group', selected: true }, - { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false }, - ], - edges: [], - }) - useCanvasStore.getState().copySelectedNodes() - expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g']) - }) - - it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => { - const node = { ...makeNode('src'), position: { x: 100, y: 100 } } - useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) - useCanvasStore.getState().pasteNodes() - const { nodes } = useCanvasStore.getState() - expect(nodes).toHaveLength(1) - const pasted = nodes[0] - expect(pasted.id).not.toBe('src') - expect(pasted.position).toEqual({ x: 150, y: 150 }) - expect(pasted.selected).toBe(true) - }) - - it('pasteNodes centers the pasted bounding box on the target point', () => { - const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 } - useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) - useCanvasStore.getState().pasteNodes({ x: 500, y: 300 }) - const pasted = useCanvasStore.getState().nodes[0] - // bbox center (50,50) shifted onto (500,300) → top-left at (450,250) - expect(pasted.position).toEqual({ x: 450, y: 250 }) - }) - - it('pasteNodes remaps edge endpoints to the new node IDs', () => { - const a = { ...makeNode('a') } - const b = { ...makeNode('b') } - useCanvasStore.setState({ - nodes: [], - edges: [], - clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] }, - }) - useCanvasStore.getState().pasteNodes() - const { nodes, edges } = useCanvasStore.getState() - expect(edges).toHaveLength(1) - const ids = nodes.map((n) => n.id) - expect(ids).toContain(edges[0].source) - expect(ids).toContain(edges[0].target) - expect(edges[0].source).not.toBe('a') - expect(edges[0].id).not.toBe('e-ab') - }) - - it('pasteNodes preserves parent-child relationship under remapped IDs', () => { - const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } } - const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } } - useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } }) - useCanvasStore.getState().pasteNodes() - const { nodes } = useCanvasStore.getState() - const newGroup = nodes.find((n) => n.data.type === 'group')! - const newChild = nodes.find((n) => n.id !== newGroup.id)! - expect(newChild.parentId).toBe(newGroup.id) - expect(newChild.data.parent_id).toBe(newGroup.id) - // Child keeps its parent-relative position (no offset applied to children) - expect(newChild.position).toEqual({ x: 20, y: 30 }) - // Group (the root) precedes its child in the array - expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan( - nodes.findIndex((n) => n.id === newChild.id), - ) - }) - - it('clipboard survives loadCanvas so nodes can be pasted into another design', () => { - useCanvasStore.setState({ - nodes: [{ ...makeNode('a'), selected: true }], - edges: [], - }) - useCanvasStore.getState().copySelectedNodes() - // Switch to another design: loadCanvas replaces nodes/edges. - useCanvasStore.getState().loadCanvas([makeNode('other')], []) - expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1) - useCanvasStore.getState().pasteNodes() - const ids = useCanvasStore.getState().nodes.map((n) => n.id) - expect(ids).toContain('other') - expect(ids).toHaveLength(2) - }) - - it('pasteNodes does nothing when clipboard is empty', () => { - useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } }) - useCanvasStore.getState().pasteNodes() - expect(useCanvasStore.getState().nodes).toHaveLength(1) - }) - - // --- Hide IP preference (persisted to localStorage) --- - - it('toggleHideIp flips the flag and persists it', () => { - localStorage.removeItem('homelable.hideIp') - useCanvasStore.setState({ hideIp: false }) - useCanvasStore.getState().toggleHideIp() - expect(useCanvasStore.getState().hideIp).toBe(true) - expect(localStorage.getItem('homelable.hideIp')).toBe('true') - useCanvasStore.getState().toggleHideIp() - expect(useCanvasStore.getState().hideIp).toBe(false) - expect(localStorage.getItem('homelable.hideIp')).toBe('false') - }) - - it('setHideIp sets the flag and persists it', () => { - localStorage.removeItem('homelable.hideIp') - useCanvasStore.getState().setHideIp(true) - expect(useCanvasStore.getState().hideIp).toBe(true) - expect(localStorage.getItem('homelable.hideIp')).toBe('true') - }) - - // --- 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() - }) - - it('updateNode preserves height for group type when properties change (children must not vanish)', () => { - const group: Node = { - id: 'g1', - type: 'group', - position: { x: 0, y: 0 }, - width: 360, - height: 240, - data: { label: 'Zone', type: 'group', status: 'unknown', services: [] }, - } - useCanvasStore.setState({ nodes: [group], edges: [] }) - useCanvasStore.getState().updateNode('g1', { properties: [] }) - const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'g1') - expect(stored?.height).toBe(240) - expect(stored?.width).toBe(360) - }) - - it('updateNode preserves height for groupRect when properties change', () => { - const rect: Node = { - id: 'r1', - type: 'groupRect', - position: { x: 0, y: 0 }, - width: 360, - height: 240, - data: { label: 'Rect', type: 'groupRect', status: 'unknown', services: [] }, - } - useCanvasStore.setState({ nodes: [rect], edges: [] }) - useCanvasStore.getState().updateNode('r1', { properties: [] }) - expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240) - }) - - // ── 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') - }) - - // ── per-side edge remapping (issue #243) ────────────────────────────────── - - it('remaps top edges to "top" slot 0 when top_handles is reduced', () => { - const node = makeNode('n1', { top_handles: 3 }) - const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'top-3' } - useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) - - useCanvasStore.getState().updateNode('n1', { top_handles: 1 }) - - expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('top') - }) - - it('remaps left/right edges to "bottom" when the side drops to 0 (slot 0 gone)', () => { - const node = makeNode('n1', { left_handles: 2, right_handles: 2 }) - const edges = [ - { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'left' }, - { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'left-2' }, - { ...makeEdge('e3', 'n1', 'n2'), targetHandle: 'right', source: 'n2', target: 'n1' }, - ] - useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) - - useCanvasStore.getState().updateNode('n1', { left_handles: 0, right_handles: 0 }) - - const after = useCanvasStore.getState().edges - expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom') - expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom') - expect(after.find((e) => e.id === 'e3')?.targetHandle).toBe('bottom') - }) - - it('remaps a side to its own slot 0 when shrinking but not to 0', () => { - const node = makeNode('n1', { right_handles: 3 }) - const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'right-3' } - useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) - - useCanvasStore.getState().updateNode('n1', { right_handles: 1 }) - - expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('right') - }) - - it('leaves edges on other sides untouched when one side shrinks', () => { - const node = makeNode('n1', { top_handles: 3, bottom_handles: 3 }) - const edges = [ - { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }, - { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'top-2' }, - ] - useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) - - useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 }) - - const after = useCanvasStore.getState().edges - expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom') - expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('top-2') - }) -}) - -describe('canvasStore — custom style apply', () => { - beforeEach(() => { - useCanvasStore.setState({ - nodes: [], - edges: [], - hasUnsavedChanges: false, - selectedNodeId: null, - selectedNodeIds: [], - editingGroupRectId: null, - editingTextId: null, - past: [], - future: [], - clipboard: { nodes: [], edges: [] }, - }) - }) - - 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', lineStyle: 'dotted', widthMult: 3, animated: 'flow', arrowStart: 'circle', arrowEnd: 'arrow' }) - - 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?.line_style).toBe('dotted') - expect(updated1.data?.width_mult).toBe(3) - expect(updated1.data?.animated).toBe('flow') - expect(updated1.data?.marker_start).toBe('circle') - expect(updated1.data?.marker_end).toBe('arrow') - expect(updated2.data?.custom_color).toBeUndefined() - expect(updated2.data?.marker_end).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', lineStyle: 'dashed', widthMult: 2, animated: 'none', arrowStart: 'none', arrowEnd: 'square' }, - }, - }) - - 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(e.data?.line_style).toBe('dashed') - expect(e.data?.width_mult).toBe(2) - expect(e.data?.marker_end).toBe('square') - expect(e.data?.marker_start).toBe('none') - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - it('setNodeSize sets explicit width/height and marks unsaved', () => { - useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false }) - useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 }) - const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! - expect(n.width).toBe(220) - expect(n.height).toBe(130) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - it('setNodeSize clamps below the minimum box', () => { - useCanvasStore.setState({ nodes: [makeNode('n1')] }) - useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 }) - const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! - expect(n.width).toBe(140) - expect(n.height).toBe(50) - }) - - it('setNodeSize updates only the provided axis', () => { - useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] }) - useCanvasStore.getState().setNodeSize('n1', { width: 300 }) - const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! - expect(n.width).toBe(300) - expect(n.height).toBe(100) - }) - - describe('floorMap', () => { - const fm = { - imageData: 'data:image/png;base64,abc', - posX: 10, posY: 20, width: 800, height: 600, - opacity: 0.8, locked: false, enabled: true, - } - - beforeEach(() => useCanvasStore.setState({ floorMap: null, hasUnsavedChanges: false })) - - it('setFloorMap sets and marks unsaved', () => { - useCanvasStore.getState().setFloorMap(fm) - expect(useCanvasStore.getState().floorMap).toEqual(fm) - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) - }) - - it('setFloorMap(null) clears the plan — prevents cross-design bleed on load', () => { - useCanvasStore.setState({ floorMap: fm }) - useCanvasStore.getState().setFloorMap(null) - expect(useCanvasStore.getState().floorMap).toBeNull() - }) - - it('updateFloorMap patches an existing plan (position preserved)', () => { - useCanvasStore.setState({ floorMap: fm }) - useCanvasStore.getState().updateFloorMap({ posX: 99, opacity: 0.5 }) - expect(useCanvasStore.getState().floorMap).toEqual({ ...fm, posX: 99, opacity: 0.5 }) - }) - - it('updateFloorMap is a no-op when no plan exists', () => { - useCanvasStore.getState().updateFloorMap({ posX: 5 }) - expect(useCanvasStore.getState().floorMap).toBeNull() - }) - - it('requestFloorMapEdit bumps the nonce each call', () => { - const start = useCanvasStore.getState().floorMapEditNonce - useCanvasStore.getState().requestFloorMapEdit() - useCanvasStore.getState().requestFloorMapEdit() - expect(useCanvasStore.getState().floorMapEditNonce).toBe(start + 2) - }) - }) -}) diff --git a/frontend/src/stores/__tests__/canvasStore/clipboard.test.ts b/frontend/src/stores/__tests__/canvasStore/clipboard.test.ts new file mode 100644 index 0000000..009c6c6 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/clipboard.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import { makeNode, makeEdge } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — clipboard', () => { + beforeEach(() => { + resetStore() + }) + + // --- 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.nodes).toHaveLength(1) + expect(clipboard.nodes[0].id).toBe('a') + }) + + it('copySelectedNodes captures edges whose endpoints are both selected', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('a'), selected: true }, + { ...makeNode('b'), selected: true }, + { ...makeNode('c'), selected: false }, + ], + edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')], + }) + useCanvasStore.getState().copySelectedNodes() + const { clipboard } = useCanvasStore.getState() + expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b']) + expect(clipboard.edges).toHaveLength(1) + expect(clipboard.edges[0].id).toBe('e-ab') + }) + + it('copySelectedNodes pulls in children of a selected group', () => { + useCanvasStore.setState({ + nodes: [ + { ...makeNode('g', { type: 'group' }), type: 'group', selected: true }, + { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false }, + ], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g']) + }) + + it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => { + const node = { ...makeNode('src'), position: { x: 100, y: 100 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) + useCanvasStore.getState().pasteNodes() + const { nodes } = useCanvasStore.getState() + expect(nodes).toHaveLength(1) + const pasted = nodes[0] + expect(pasted.id).not.toBe('src') + expect(pasted.position).toEqual({ x: 150, y: 150 }) + expect(pasted.selected).toBe(true) + }) + + it('pasteNodes centers the pasted bounding box on the target point', () => { + const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } }) + useCanvasStore.getState().pasteNodes({ x: 500, y: 300 }) + const pasted = useCanvasStore.getState().nodes[0] + // bbox center (50,50) shifted onto (500,300) → top-left at (450,250) + expect(pasted.position).toEqual({ x: 450, y: 250 }) + }) + + it('pasteNodes remaps edge endpoints to the new node IDs', () => { + const a = { ...makeNode('a') } + const b = { ...makeNode('b') } + useCanvasStore.setState({ + nodes: [], + edges: [], + clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] }, + }) + useCanvasStore.getState().pasteNodes() + const { nodes, edges } = useCanvasStore.getState() + expect(edges).toHaveLength(1) + const ids = nodes.map((n) => n.id) + expect(ids).toContain(edges[0].source) + expect(ids).toContain(edges[0].target) + expect(edges[0].source).not.toBe('a') + expect(edges[0].id).not.toBe('e-ab') + }) + + it('pasteNodes preserves parent-child relationship under remapped IDs', () => { + const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } } + const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } } + useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } }) + useCanvasStore.getState().pasteNodes() + const { nodes } = useCanvasStore.getState() + const newGroup = nodes.find((n) => n.data.type === 'group')! + const newChild = nodes.find((n) => n.id !== newGroup.id)! + expect(newChild.parentId).toBe(newGroup.id) + expect(newChild.data.parent_id).toBe(newGroup.id) + // Child keeps its parent-relative position (no offset applied to children) + expect(newChild.position).toEqual({ x: 20, y: 30 }) + // Group (the root) precedes its child in the array + expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan( + nodes.findIndex((n) => n.id === newChild.id), + ) + }) + + it('clipboard survives loadCanvas so nodes can be pasted into another design', () => { + useCanvasStore.setState({ + nodes: [{ ...makeNode('a'), selected: true }], + edges: [], + }) + useCanvasStore.getState().copySelectedNodes() + // Switch to another design: loadCanvas replaces nodes/edges. + useCanvasStore.getState().loadCanvas([makeNode('other')], []) + expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1) + useCanvasStore.getState().pasteNodes() + const ids = useCanvasStore.getState().nodes.map((n) => n.id) + expect(ids).toContain('other') + expect(ids).toHaveLength(2) + }) + + it('pasteNodes does nothing when clipboard is empty', () => { + useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } }) + useCanvasStore.getState().pasteNodes() + expect(useCanvasStore.getState().nodes).toHaveLength(1) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/containers.test.ts b/frontend/src/stores/__tests__/canvasStore/containers.test.ts new file mode 100644 index 0000000..76a8902 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/containers.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { makeNode } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — containers & nesting', () => { + beforeEach(() => { + resetStore() + }) + + 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('addNode strips parentId/extent when the parent is not a container', () => { + // Regression: a stray extent:'parent' on a non-container parent traps the + // node in the parent's tiny box with no way to drag it out (issue #205). + const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } } + useCanvasStore.getState().addNode(parent) + const trapped: Node = { + ...makeNode('c1', { parent_id: 'p1' }), + position: { x: 150, y: 180 }, + parentId: 'p1', + extent: 'parent', + } + useCanvasStore.getState().addNode(trapped) + const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1') + expect(child?.parentId).toBeUndefined() + expect(child?.extent).toBeUndefined() + }) + + 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 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 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') + }) + + 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 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('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) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/customStyle.test.ts b/frontend/src/stores/__tests__/canvasStore/customStyle.test.ts new file mode 100644 index 0000000..1016475 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/customStyle.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Edge } from '@xyflow/react' +import type { EdgeData } from '@/types' +import { makeNode } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — custom style apply', () => { + beforeEach(() => { + resetStore() + }) + + 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', lineStyle: 'dotted', widthMult: 3, animated: 'flow', arrowStart: 'circle', arrowEnd: 'arrow' }) + + 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?.line_style).toBe('dotted') + expect(updated1.data?.width_mult).toBe(3) + expect(updated1.data?.animated).toBe('flow') + expect(updated1.data?.marker_start).toBe('circle') + expect(updated1.data?.marker_end).toBe('arrow') + expect(updated2.data?.custom_color).toBeUndefined() + expect(updated2.data?.marker_end).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', lineStyle: 'dashed', widthMult: 2, animated: 'none', arrowStart: 'none', arrowEnd: 'square' }, + }, + }) + + 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(e.data?.line_style).toBe('dashed') + expect(e.data?.width_mult).toBe(2) + expect(e.data?.marker_end).toBe('square') + expect(e.data?.marker_start).toBe('none') + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('setNodeSize sets explicit width/height and marks unsaved', () => { + useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false }) + useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 }) + const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! + expect(n.width).toBe(220) + expect(n.height).toBe(130) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('setNodeSize clamps below the minimum box', () => { + useCanvasStore.setState({ nodes: [makeNode('n1')] }) + useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 }) + const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! + expect(n.width).toBe(140) + expect(n.height).toBe(50) + }) + + it('setNodeSize updates only the provided axis', () => { + useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] }) + useCanvasStore.getState().setNodeSize('n1', { width: 300 }) + const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')! + expect(n.width).toBe(300) + expect(n.height).toBe(100) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/edges.test.ts b/frontend/src/stores/__tests__/canvasStore/edges.test.ts new file mode 100644 index 0000000..58a9967 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/edges.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { makeNode, makeEdge } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — edges', () => { + beforeEach(() => { + resetStore() + }) + + 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 allows multiple edges between the same two nodes (no dedupe)', () => { + // Regression: React Flow addEdge() dropped a second edge between the same + // source+target when handles were null/equal. A homelab has multiple links + // between two devices, so every connect must add an edge. + useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) + useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }) + const { edges } = useCanvasStore.getState() + expect(edges).toHaveLength(2) + expect(edges[0].id).not.toBe(edges[1].id) + }) + + it('onConnect preserves endpoint marker shapes from edge data', () => { + const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: 'diamond', marker_end: 'arrow' }) + useCanvasStore.getState().onConnect(conn) + const { edges } = useCanvasStore.getState() + expect(edges[0].data?.marker_start).toBe('diamond') + expect(edges[0].data?.marker_end).toBe('arrow') + }) + + 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('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('reconnectEdge swaps source/target and normalizes handles', () => { + useCanvasStore.setState((s) => ({ + edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }], + })) + useCanvasStore.getState().markSaved() + useCanvasStore.getState().reconnectEdge('e1', { + source: 'n1', + target: 'n3', + sourceHandle: 'bottom-2-t', + targetHandle: 'top-t', + }) + const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1') + expect(edge?.target).toBe('n3') + expect(edge?.source).toBe('n1') + expect(edge?.sourceHandle).toBe('bottom-2') + expect(edge?.targetHandle).toBe('top') + 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 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') + }) + + // ── 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') + }) + + // ── per-side edge remapping (issue #243) ────────────────────────────────── + + it('remaps top edges to "top" slot 0 when top_handles is reduced', () => { + const node = makeNode('n1', { top_handles: 3 }) + const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'top-3' } + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) + + useCanvasStore.getState().updateNode('n1', { top_handles: 1 }) + + expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('top') + }) + + it('remaps left/right edges to "bottom" when the side drops to 0 (slot 0 gone)', () => { + const node = makeNode('n1', { left_handles: 2, right_handles: 2 }) + const edges = [ + { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'left' }, + { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'left-2' }, + { ...makeEdge('e3', 'n1', 'n2'), targetHandle: 'right', source: 'n2', target: 'n1' }, + ] + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) + + useCanvasStore.getState().updateNode('n1', { left_handles: 0, right_handles: 0 }) + + const after = useCanvasStore.getState().edges + expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom') + expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom') + expect(after.find((e) => e.id === 'e3')?.targetHandle).toBe('bottom') + }) + + it('remaps a side to its own slot 0 when shrinking but not to 0', () => { + const node = makeNode('n1', { right_handles: 3 }) + const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'right-3' } + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] }) + + useCanvasStore.getState().updateNode('n1', { right_handles: 1 }) + + expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('right') + }) + + it('leaves edges on other sides untouched when one side shrinks', () => { + const node = makeNode('n1', { top_handles: 3, bottom_handles: 3 }) + const edges = [ + { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }, + { ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'top-2' }, + ] + useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges }) + + useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 }) + + const after = useCanvasStore.getState().edges + expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom') + expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('top-2') + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/floorMap.test.ts b/frontend/src/stores/__tests__/canvasStore/floorMap.test.ts new file mode 100644 index 0000000..1260d5e --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/floorMap.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' + +describe('floorMap', () => { + const fm = { + imageData: 'data:image/png;base64,abc', + posX: 10, posY: 20, width: 800, height: 600, + opacity: 0.8, locked: false, enabled: true, + } + + beforeEach(() => useCanvasStore.setState({ floorMap: null, hasUnsavedChanges: false })) + + it('setFloorMap sets and marks unsaved', () => { + useCanvasStore.getState().setFloorMap(fm) + expect(useCanvasStore.getState().floorMap).toEqual(fm) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('setFloorMap(null) clears the plan — prevents cross-design bleed on load', () => { + useCanvasStore.setState({ floorMap: fm }) + useCanvasStore.getState().setFloorMap(null) + expect(useCanvasStore.getState().floorMap).toBeNull() + }) + + it('updateFloorMap patches an existing plan (position preserved)', () => { + useCanvasStore.setState({ floorMap: fm }) + useCanvasStore.getState().updateFloorMap({ posX: 99, opacity: 0.5 }) + expect(useCanvasStore.getState().floorMap).toEqual({ ...fm, posX: 99, opacity: 0.5 }) + }) + + it('updateFloorMap is a no-op when no plan exists', () => { + useCanvasStore.getState().updateFloorMap({ posX: 5 }) + expect(useCanvasStore.getState().floorMap).toBeNull() + }) + + it('requestFloorMapEdit bumps the nonce each call', () => { + const start = useCanvasStore.getState().floorMapEditNonce + useCanvasStore.getState().requestFloorMapEdit() + useCanvasStore.getState().requestFloorMapEdit() + expect(useCanvasStore.getState().floorMapEditNonce).toBe(start + 2) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/grouping.test.ts b/frontend/src/stores/__tests__/canvasStore/grouping.test.ts new file mode 100644 index 0000000..69be363 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/grouping.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import { makeNode } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — grouping', () => { + beforeEach(() => { + resetStore() + }) + + // ── 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) + }) + + // ── addToGroup ────────────────────────────────────────────────────────────── + + it('addToGroup nests a top-level node with parent-relative position', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 } + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + useCanvasStore.setState({ nodes: [group, child] }) + + useCanvasStore.getState().addToGroup('g1', 'n1') + + const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(moved?.parentId).toBe('g1') + expect(moved?.extent).toBe('parent') + expect(moved?.data.parent_id).toBe('g1') + // 300-76=224, 200-52=148 + expect(moved?.position).toEqual({ x: 224, y: 148 }) + }) + + it('addToGroup places the group before the child in the array', () => { + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + // child first to prove reordering + useCanvasStore.setState({ nodes: [child, group] }) + + useCanvasStore.getState().addToGroup('g1', 'n1') + + const { nodes } = useCanvasStore.getState() + expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) + }) + + it('addToGroup is a no-op when target is not a group', () => { + const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [notGroup, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToGroup('s1', 'n1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToGroup is a no-op when child already belongs to the group', () => { + 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().markSaved() + + useCanvasStore.getState().addToGroup('g1', 'n1') + + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToGroup snapshots history and marks unsaved', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [group, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToGroup('g1', 'n1') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + // ── addToContainer ────────────────────────────────────────────────────────── + + it('addToContainer nests a top-level node under a container_mode node', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 } + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + useCanvasStore.setState({ nodes: [container, child] }) + + useCanvasStore.getState().addToContainer('px1', 'n1') + + const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(moved?.parentId).toBe('px1') + expect(moved?.extent).toBe('parent') + expect(moved?.data.parent_id).toBe('px1') + // 300-76=224, 200-52=148 + expect(moved?.position).toEqual({ x: 224, y: 148 }) + }) + + it('addToContainer works for any container_mode type (docker_host)', () => { + const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [host, child] }) + + useCanvasStore.getState().addToContainer('dh1', 'n1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1') + }) + + it('addToContainer places the container before the child in the array', () => { + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + useCanvasStore.setState({ nodes: [child, container] }) + + useCanvasStore.getState().addToContainer('px1', 'n1') + + const { nodes } = useCanvasStore.getState() + expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) + }) + + it('addToContainer is a no-op when target is not in container_mode', () => { + const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [notContainer, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToContainer is a no-op when child already belongs to the container', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToContainer snapshots history and marks unsaved', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + // ── removeFromGroup ───────────────────────────────────────────────────────── + + it('removeFromGroup releases the child to absolute coords and keeps the group', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } } + const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [group, child] }) + + useCanvasStore.getState().removeFromGroup('g1', 'n1') + + const { nodes } = useCanvasStore.getState() + const released = nodes.find((n) => n.id === 'n1') + expect(released?.parentId).toBeUndefined() + expect(released?.extent).toBeUndefined() + expect(released?.data.parent_id).toBeUndefined() + // 224+76=300, 148+52=200 + expect(released?.position).toEqual({ x: 300, y: 200 }) + // group survives + expect(nodes.find((n) => n.id === 'g1')).toBeDefined() + }) + + it('removeFromGroup is a no-op when child is not in the group', () => { + const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [group, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().removeFromGroup('g1', 'n1') + + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('removeFromGroup snapshots history and marks unsaved', () => { + 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().markSaved() + + useCanvasStore.getState().removeFromGroup('g1', 'n1') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/history.test.ts b/frontend/src/stores/__tests__/canvasStore/history.test.ts new file mode 100644 index 0000000..57041d4 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/history.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import { makeNode, makeEdge } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — history', () => { + beforeEach(() => { + resetStore() + }) + + it('reconnectEdge snapshots history for undo', () => { + useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] })) + useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null }) + expect(useCanvasStore.getState().past.length).toBe(1) + }) + + // --- 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) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/nodes.test.ts b/frontend/src/stores/__tests__/canvasStore/nodes.test.ts new file mode 100644 index 0000000..50d99c7 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/nodes.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { makeNode, makeEdge } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — nodes', () => { + beforeEach(() => { + resetStore() + }) + + it('setServiceStatuses stores live status keyed by node/port/protocol', () => { + const { setServiceStatuses } = useCanvasStore.getState() + setServiceStatuses('node-1', [ + { port: 80, protocol: 'tcp', status: 'offline' }, + { port: 443, protocol: 'tcp', status: 'online' }, + ]) + const { serviceStatuses } = useCanvasStore.getState() + expect(serviceStatuses['node-1:80/tcp']).toBe('offline') + expect(serviceStatuses['node-1:443/tcp']).toBe('online') + }) + + it('setServiceStatuses merges without dropping other nodes', () => { + const { setServiceStatuses } = useCanvasStore.getState() + setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }]) + setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }]) + const { serviceStatuses } = useCanvasStore.getState() + expect(serviceStatuses['node-1:80/tcp']).toBe('online') + expect(serviceStatuses['node-2:22/tcp']).toBe('offline') + }) + + it('does not mark canvas unsaved on a service status update', () => { + useCanvasStore.setState({ hasUnsavedChanges: false }) + useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }]) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('setEditingTextId sets and clears editing text id', () => { + const { setEditingTextId } = useCanvasStore.getState() + setEditingTextId('t1') + expect(useCanvasStore.getState().editingTextId).toBe('t1') + setEditingTextId(null) + expect(useCanvasStore.getState().editingTextId).toBeNull() + }) + + it('addNode supports text type with text_content and style', () => { + const { addNode } = useCanvasStore.getState() + const textNode: Node = { + id: 't1', + type: 'text', + position: { x: 50, y: 50 }, + data: { + label: '', + type: 'text', + status: 'unknown', + services: [], + text_content: 'Hello world', + custom_colors: { + text_color: '#ffffff', + text_size: 24, + font: 'mono', + border_style: 'dashed', + border_width: 2, + border: '#00d4ff', + background: '#00000000', + }, + }, + width: 200, + height: 60, + } + addNode(textNode) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1') + expect(stored?.type).toBe('text') + expect(stored?.data.text_content).toBe('Hello world') + expect(stored?.data.custom_colors?.text_size).toBe(24) + expect(stored?.data.custom_colors?.border_style).toBe('dashed') + }) + + it('updateNode updates text_content and custom_colors on a text node', () => { + const { addNode, updateNode } = useCanvasStore.getState() + addNode({ ...makeNode('t1', { type: 'text', text_content: 'old' }), type: 'text' }) + updateNode('t1', { text_content: 'new', custom_colors: { text_color: '#ff0000', text_size: 32 } }) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 't1') + expect(stored?.data.text_content).toBe('new') + expect(stored?.data.custom_colors?.text_color).toBe('#ff0000') + expect(stored?.data.custom_colors?.text_size).toBe(32) + }) + + it('deleteNode removes a text node', () => { + const { addNode, deleteNode } = useCanvasStore.getState() + addNode({ ...makeNode('t1', { type: 'text' }), type: 'text' }) + deleteNode('t1') + expect(useCanvasStore.getState().nodes).toHaveLength(0) + }) + + 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('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('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('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('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() + }) + + // --- Hide IP preference (persisted to localStorage) --- + + it('toggleHideIp flips the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.setState({ hideIp: false }) + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + useCanvasStore.getState().toggleHideIp() + expect(useCanvasStore.getState().hideIp).toBe(false) + expect(localStorage.getItem('homelable.hideIp')).toBe('false') + }) + + it('setHideIp sets the flag and persists it', () => { + localStorage.removeItem('homelable.hideIp') + useCanvasStore.getState().setHideIp(true) + expect(useCanvasStore.getState().hideIp).toBe(true) + expect(localStorage.getItem('homelable.hideIp')).toBe('true') + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/selection.test.ts b/frontend/src/stores/__tests__/canvasStore/selection.test.ts new file mode 100644 index 0000000..e0f355e --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/selection.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import { makeNode } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — selection', () => { + beforeEach(() => { + resetStore() + }) + + 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() + }) + + // ── 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']) + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore/sizing.test.ts b/frontend/src/stores/__tests__/canvasStore/sizing.test.ts new file mode 100644 index 0000000..7c21529 --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore/sizing.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCanvasStore } from '@/stores/canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { makeNode } from '@/test/factories' + +function resetStore() { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + editingTextId: null, + past: [], + future: [], + clipboard: { nodes: [], edges: [] }, + serviceStatuses: {}, + floorMap: null, + }) +} + +describe('canvasStore — sizing & z-order', () => { + beforeEach(() => { + resetStore() + }) + + 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) + }) + + // --- 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() + }) + + it('updateNode preserves height for group type when properties change (children must not vanish)', () => { + const group: Node = { + id: 'g1', + type: 'group', + position: { x: 0, y: 0 }, + width: 360, + height: 240, + data: { label: 'Zone', type: 'group', status: 'unknown', services: [] }, + } + useCanvasStore.setState({ nodes: [group], edges: [] }) + useCanvasStore.getState().updateNode('g1', { properties: [] }) + const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'g1') + expect(stored?.height).toBe(240) + expect(stored?.width).toBe(360) + }) + + it('updateNode preserves height for groupRect when properties change', () => { + const rect: Node = { + id: 'r1', + type: 'groupRect', + position: { x: 0, y: 0 }, + width: 360, + height: 240, + data: { label: 'Rect', type: 'groupRect', status: 'unknown', services: [] }, + } + useCanvasStore.setState({ nodes: [rect], edges: [] }) + useCanvasStore.getState().updateNode('r1', { properties: [] }) + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'r1')?.height).toBe(240) + }) +}) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.ts b/frontend/src/utils/__tests__/serviceUrl.test.ts similarity index 100% rename from frontend/src/components/panels/__tests__/DetailPanel.test.ts rename to frontend/src/utils/__tests__/serviceUrl.test.ts