test: split canvasStore monolith and relocate misnamed util test

- Split the 1430-line canvasStore.test.ts into a canvasStore/ subpackage by
  concern (nodes, containers, sizing, edges, selection, grouping, history,
  clipboard, customStyle, floorMap); each file <350 lines. Test bodies are
  unchanged; local makeNode/makeEdge replaced by the shared factories.
- Move panels/DetailPanel.test.ts -> utils/serviceUrl.test.ts: it tested
  getServiceUrl, not DetailPanel.

Same 1396 tests, all green.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-10 02:48:29 +02:00
parent 64c48de1d1
commit 4124d6c5a4
12 changed files with 1622 additions and 1430 deletions
File diff suppressed because it is too large Load Diff
@@ -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)
})
})
@@ -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<NodeData> = {
...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<NodeData> = { 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<NodeData> = { 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<NodeData> = { 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<NodeData> = { 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<NodeData> = { ...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)
})
})
@@ -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<EdgeData> = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
const e2: Edge<EdgeData> = { 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<EdgeData> = { 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)
})
})
@@ -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<NodeData> = { 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')
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
})
})
@@ -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<NodeData> = {
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')
})
})
@@ -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'])
})
})
@@ -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<NodeData> = {
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<NodeData> = { ...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<NodeData> = { ...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<NodeData> = {
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<NodeData> = {
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)
})
})