diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
index 513ddd6..4f7a5f3 100644
--- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
@@ -4,7 +4,7 @@ import { CustomStyleModal } from '../CustomStyleModal'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { toast } from 'sonner'
describe('CustomStyleModal', () => {
diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
index d4d527f..cb0bbe7 100644
--- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
@@ -31,7 +31,7 @@ vi.mock('@/api/client', () => ({
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx
index 8bdb4e8..67e8017 100644
--- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
trigger: vi.fn(),
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
diff --git a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx
index 72e1a67..03b01cf 100644
--- a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ScanHistoryModal } from '../ScanHistoryModal'
import { TooltipProvider } from '@/components/ui/tooltip'
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
}))
diff --git a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx
index c33d9da..c4d7082 100644
--- a/frontend/src/components/modals/__tests__/SettingsModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/SettingsModal.test.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsModal } from '../SettingsModal'
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
settingsApi: {
get: vi.fn(),
diff --git a/frontend/src/components/modals/__tests__/ThemeModal.test.tsx b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx
index 1e5b9c3..b5df59c 100644
--- a/frontend/src/components/modals/__tests__/ThemeModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/ThemeModal.test.tsx
@@ -5,7 +5,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEME_ORDER } from '@/utils/themes'
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { toast } from 'sonner'
describe('ThemeModal', () => {
diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx
index 55266ff..c74e73d 100644
--- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx
+++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx
@@ -24,7 +24,7 @@ vi.mock('@/api/client', () => ({
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/components/ui/Logo', () => ({
Logo: ({ showText }: { showText: boolean }) => (
diff --git a/frontend/src/components/panels/__tests__/Toolbar.test.tsx b/frontend/src/components/panels/__tests__/Toolbar.test.tsx
index e06cc60..8fcc1a9 100644
--- a/frontend/src/components/panels/__tests__/Toolbar.test.tsx
+++ b/frontend/src/components/panels/__tests__/Toolbar.test.tsx
@@ -61,6 +61,39 @@ describe('Toolbar', () => {
render()
expect(screen.getByText('View')).toBeInTheDocument()
})
+
+ // These cover the store→UI wiring the seam mock would otherwise hide: the
+ // undo/redo disabled state and the unsaved dot are driven purely by store
+ // state, so assert them against the mocked slices.
+ it('disables Undo when the history stack is empty and Redo when the future is empty', () => {
+ mockStore({ past: [], future: [] })
+ render()
+ expect(screen.getByTitle('Undo (Ctrl+Z)')).toBeDisabled()
+ expect(screen.getByTitle('Redo (Ctrl+Y)')).toBeDisabled()
+ })
+
+ it('enables Undo/Redo when history/future have entries and forwards the clicks', () => {
+ mockStore({ past: [{}], future: [{}] } as Partial>)
+ render()
+ const undo = screen.getByTitle('Undo (Ctrl+Z)')
+ const redo = screen.getByTitle('Redo (Ctrl+Y)')
+ expect(undo).toBeEnabled()
+ expect(redo).toBeEnabled()
+ fireEvent.click(undo)
+ fireEvent.click(redo)
+ expect(defaultProps.onUndo).toHaveBeenCalledOnce()
+ expect(defaultProps.onRedo).toHaveBeenCalledOnce()
+ })
+
+ it('renders the unsaved-changes dot only when hasUnsavedChanges is true', () => {
+ const { rerender } = render()
+ // Save button is the last button; the dot is a sibling span with bg-[#e3b341].
+ expect(document.querySelector('.bg-\\[\\#e3b341\\]')).toBeNull()
+
+ mockStore({ hasUnsavedChanges: true })
+ rerender()
+ expect(document.querySelector('.bg-\\[\\#e3b341\\]')).not.toBeNull()
+ })
})
// ── Standalone mode ────────────────────────────────────────────────────────────
diff --git a/frontend/src/components/panels/__tests__/VersionBadge.test.tsx b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx
index 7aad9de..104fc41 100644
--- a/frontend/src/components/panels/__tests__/VersionBadge.test.tsx
+++ b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx
@@ -22,7 +22,7 @@ vi.mock('@/api/client', () => ({
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
diff --git a/frontend/src/components/proxmox/__tests__/ProxmoxImportModal.test.tsx b/frontend/src/components/proxmox/__tests__/ProxmoxImportModal.test.tsx
index dd35d39..4384c2b 100644
--- a/frontend/src/components/proxmox/__tests__/ProxmoxImportModal.test.tsx
+++ b/frontend/src/components/proxmox/__tests__/ProxmoxImportModal.test.tsx
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
importToPending: vi.fn(),
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { proxmoxApi } from '@/api/client'
import { toast } from 'sonner'
diff --git a/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx
index c7f2088..f74b11c 100644
--- a/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx
+++ b/frontend/src/components/zigbee/__tests__/ZigbeeImportModal.test.tsx
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
importToPending: vi.fn(),
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { zigbeeApi } from '@/api/client'
import { toast } from 'sonner'
diff --git a/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx
index 7c8f54c..3d5fcf8 100644
--- a/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx
+++ b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
importToPending: vi.fn(),
},
}))
-vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { zwaveApi } from '@/api/client'
import { toast } from 'sonner'
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/test/README.md b/frontend/src/test/README.md
new file mode 100644
index 0000000..10c4461
--- /dev/null
+++ b/frontend/src/test/README.md
@@ -0,0 +1,59 @@
+# Frontend test infrastructure
+
+Shared helpers so tests don't re-roll the same boilerplate. Reuse these instead
+of redefining a local `makeNode`, sonner mock, or provider wrapper per file.
+
+## `factories.ts` — fixture builders
+
+```ts
+import { makeNode, makeEdge, makeNodeData, makeDesign } from '@/test/factories'
+
+makeNode('n1', { type: 'router', ip: '10.0.0.1' }) // React Flow node
+makeNode({ data: makeNodeData({ label: 'X' }) }) // partial-node form
+makeEdge('e1', 'n1', 'n2', { type: 'wifi' }) // React Flow edge
+makeDesign({ name: 'Home' }) // Design row
+```
+
+Prefer these over an inline `const makeNode = …`. They track the domain types in
+`@/types`, so a type change surfaces in one place.
+
+## `mocks/` — reusable mock builders
+
+Consume from an **async** `vi.mock` factory via dynamic import. `vi.mock`
+factories are hoisted above the file's imports, so a plain top-level
+`import { mockSonner }` referenced inside the factory throws "cannot access
+before initialization". The dynamic `import()` resolves lazily and sidesteps it:
+
+```ts
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
+vi.mock('@xyflow/react', async () => (await import('@/test/mocks')).mockReactFlow())
+
+import { toast } from 'sonner'
+// …assert vi.mocked(toast.success) etc.
+```
+
+- `mockSonner()` — full `toast` stub (success/error/info/warning/…).
+- `mockReactFlow(extra?)` — the `@xyflow/react` exports most node/edge tests
+ need; pass `extra` to add or override an export.
+- `makeUseCanvasStore(state)` — a `useCanvasStore` replacement for component
+ tests that read a selected slice.
+
+## `render.tsx` — provider-wrapped render
+
+```ts
+import { renderWithProviders, screen } from '@/test/render'
+renderWithProviders() // wraps TooltipProvider + ReactFlowProvider
+```
+
+Use the plain `render` from `@testing-library/react` for presentational
+components that need no context.
+
+## Conventions
+
+- **Store tests are split by concern** under `stores/__tests__/canvasStore/`
+ (nodes, containers, sizing, edges, selection, grouping, history, clipboard,
+ customStyle, floorMap). Add new store tests to the matching file; don't grow a
+ monolith.
+- When a component test full-mocks a store (`vi.mock('@/stores/canvasStore')`),
+ still assert the **state-driven UI branches** (disabled buttons, indicators) —
+ don't let the mock hide that wiring.
diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts
new file mode 100644
index 0000000..6f27289
--- /dev/null
+++ b/frontend/src/test/factories.ts
@@ -0,0 +1,73 @@
+/**
+ * Canonical test fixture builders. Prefer these over redefining `makeNode` /
+ * `makeEdge` inline per test file — one source of truth keeps fixtures in sync
+ * with the domain types.
+ *
+ * See CLAUDE.md → Testing Protocol.
+ */
+import type { Node, Edge } from '@xyflow/react'
+import type { NodeData, EdgeData, Design } from '@/types'
+
+/** Build a `NodeData` payload with sane defaults; override any field. */
+export function makeNodeData(overrides: Partial = {}): NodeData {
+ return {
+ label: overrides.label ?? 'Test Node',
+ type: 'server',
+ status: 'unknown',
+ services: [],
+ ...overrides,
+ }
+}
+
+/**
+ * Build a React Flow node. Accepts either an id string or a partial-node
+ * object as the first arg, so it covers both inline patterns that existed
+ * across the suite:
+ * makeNode('n1', { type: 'router' })
+ * makeNode({ data: makeNodeData({ ip: '10.0.0.1' }) })
+ */
+export function makeNode(
+ idOrOverrides: string | Partial> = 'n1',
+ dataOverrides: Partial = {},
+): Node {
+ const base: Partial> =
+ typeof idOrOverrides === 'string' ? { id: idOrOverrides } : idOrOverrides
+ const id = base.id ?? 'n1'
+ return {
+ id,
+ type: base.type ?? base.data?.type ?? dataOverrides.type ?? 'server',
+ position: base.position ?? { x: 0, y: 0 },
+ ...base,
+ data: makeNodeData({ label: id, ...dataOverrides, ...base.data }),
+ }
+}
+
+/** Build a React Flow edge between two node ids. */
+export function makeEdge(
+ id: string,
+ source: string,
+ target: string,
+ overrides: Partial> = {},
+): Edge {
+ return {
+ id,
+ source,
+ target,
+ type: 'ethernet',
+ ...overrides,
+ data: { type: 'ethernet', ...overrides.data },
+ }
+}
+
+/** Build a Design row. */
+export function makeDesign(overrides: Partial = {}): Design {
+ return {
+ id: 'd1',
+ name: 'Test Design',
+ design_type: 'network',
+ icon: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ }
+}
diff --git a/frontend/src/test/mocks/index.ts b/frontend/src/test/mocks/index.ts
new file mode 100644
index 0000000..8f675fb
--- /dev/null
+++ b/frontend/src/test/mocks/index.ts
@@ -0,0 +1,70 @@
+/**
+ * Reusable mock builders. Consume them from an *async* `vi.mock` factory via
+ * dynamic import so the boilerplate lives in one place:
+ *
+ * vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
+ *
+ * `vi.mock` factories are hoisted above the file's imports, so a plain
+ * top-level `import { mockSonner }` referenced inside the factory throws
+ * "cannot access before initialization". The dynamic `import()` sidesteps
+ * that — it resolves lazily when the mocked module is first loaded.
+ *
+ * See CLAUDE.md → Testing Protocol.
+ */
+import { vi } from 'vitest'
+
+/** `sonner` toast stub. Assert via `import { toast } from 'sonner'`. */
+export function mockSonner() {
+ return {
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ message: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ },
+ }
+}
+
+/**
+ * `@xyflow/react` stub covering the exports most node/edge component tests
+ * need. Pass `extra` to add or override exports for a specific test.
+ */
+export function mockReactFlow(extra: Record = {}) {
+ return {
+ Handle: () => null,
+ Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
+ NodeResizer: () => null,
+ NodeToolbar: () => null,
+ BaseEdge: () => null,
+ EdgeLabelRenderer: ({ children }: { children?: unknown }) => children ?? null,
+ useUpdateNodeInternals: () => vi.fn(),
+ useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
+ useReactFlow: () => ({
+ getNode: vi.fn(),
+ getNodes: vi.fn(() => []),
+ getEdges: vi.fn(() => []),
+ screenToFlowPosition: vi.fn((p: unknown) => p),
+ }),
+ getBezierPath: () => ['M0,0', 0, 0] as const,
+ getSmoothStepPath: () => ['M0,0', 0, 0] as const,
+ ...extra,
+ }
+}
+
+/**
+ * Build a `useCanvasStore` replacement for component tests that only read a
+ * selected slice of store state. Returns a function usable as the hook: it
+ * invokes the passed selector against `state` (falling back to returning the
+ * whole state when called with no selector).
+ */
+export function makeUseCanvasStore>(state: S) {
+ return (selector?: (s: S) => R): R | S => (selector ? selector(state) : state)
+}
+
+/** Common `serviceStatusKey` helper mirrored from the real store. */
+export function serviceStatusKey(nodeId: string, port?: number, protocol?: string) {
+ return `${nodeId}:${port ?? ''}/${protocol ?? ''}`
+}
diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx
new file mode 100644
index 0000000..8d4ef29
--- /dev/null
+++ b/frontend/src/test/render.tsx
@@ -0,0 +1,30 @@
+/* eslint-disable react-refresh/only-export-components -- test helper module, not a component module */
+/**
+ * Custom render that wraps the UI in the same providers the app mounts
+ * (`TooltipProvider`, `ReactFlowProvider`), so component tests that touch
+ * React Flow context or tooltips don't each re-declare the wrapper.
+ *
+ * Re-exports everything from `@testing-library/react`, so a test can:
+ * import { renderWithProviders, screen } from '@/test/render'
+ *
+ * Use the plain `render` from `@testing-library/react` for pure presentational
+ * components that need no context. See CLAUDE.md → Testing Protocol.
+ */
+import type { ReactElement, ReactNode } from 'react'
+import { render, type RenderOptions } from '@testing-library/react'
+import { ReactFlowProvider } from '@xyflow/react'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+function AllProviders({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function renderWithProviders(ui: ReactElement, options?: Omit) {
+ return render(ui, { wrapper: AllProviders, ...options })
+}
+
+export * from '@testing-library/react'
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