import { describe, it, expect, beforeEach } from 'vitest' import { initialCanvasStore, canvasStoreReducer, getCanvasStore, dispatchCanvasCommand, } from './canvasStore' import { selectPathNodeIds, selectPathActiveSegmentNodeIds, selectConnectionStatusForEdge, selectPathRoleForNode, selectNodes, selectEdges, selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom, } from './canvasStore.selectors' import type { CanvasStore, CanvasCommand } from './canvasStore.types' import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function makeNode(id: string, type = 'config'): AppNode { return { id, type, position: { x: 0, y: 0 }, data: {}, } } function makeEdge(id: string, source: string, target: string): AppEdge { return { id, source, target } } // --------------------------------------------------------------------------- // Reducer: graph commands // --------------------------------------------------------------------------- describe('canvasStoreReducer', () => { describe('graph commands', () => { it('graph/setNodes replaces nodes', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [makeNode('a')], edges: [], }, } const next = canvasStoreReducer(state, { type: 'graph/setNodes', payload: [makeNode('b'), makeNode('c')], }) expect(next.graph.nodes).toHaveLength(2) expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c']) expect(next.graph.edges).toEqual(state.graph.edges) }) it('graph/setNodes with updater function', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [makeNode('a'), makeNode('b')], edges: [], }, } const next = canvasStoreReducer(state, { type: 'graph/setNodes', payload: (prev) => prev.filter((n) => n.id !== 'a'), }) expect(next.graph.nodes).toHaveLength(1) expect(next.graph.nodes[0].id).toBe('b') }) it('graph/setEdges replaces edges', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')], }, } const next = canvasStoreReducer(state, { type: 'graph/setEdges', payload: [makeEdge('e2', 'b', 'c')], }) expect(next.graph.edges).toHaveLength(1) expect(next.graph.edges[0].id).toBe('e2') }) it('graph/apply updates nodes and edges', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [makeNode('a')], edges: [makeEdge('e1', 'a', 'b')], }, } const next = canvasStoreReducer(state, { type: 'graph/apply', payload: { nodes: [makeNode('x')] }, }) expect(next.graph.nodes).toHaveLength(1) expect(next.graph.nodes[0].id).toBe('x') expect(next.graph.edges).toEqual(state.graph.edges) }) }) describe('path commands', () => { it('path/addTrigger adds node id', () => { const state: CanvasStore = { ...initialCanvasStore } const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'n1', }) expect(next.path.triggerNodeIds).toEqual(['n1']) }) it('path/addTrigger is idempotent', () => { const state: CanvasStore = { ...initialCanvasStore, path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] }, } const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'n1', }) expect(next).toBe(state) }) it('path/clearTriggers empties triggerNodeIds', () => { const state: CanvasStore = { ...initialCanvasStore, path: { ...initialCanvasStore.path, triggerNodeIds: ['a', 'b'] }, } const next = canvasStoreReducer(state, { type: 'path/clearTriggers' }) expect(next.path.triggerNodeIds).toEqual([]) }) it('path/addTrigger sets pulseEndsAt', () => { const state: CanvasStore = { ...initialCanvasStore } const before = Date.now() const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'n1', }) expect(next.path.triggerNodeIds).toEqual(['n1']) expect(next.path.pulseEndsAt).toBeGreaterThanOrEqual(before + 1000) }) it('path/setError adds and removes error node', () => { const state: CanvasStore = { ...initialCanvasStore } let next = canvasStoreReducer(state, { type: 'path/setError', payload: { nodeId: 'n1', error: true }, }) expect(next.path.errorNodeIds).toEqual(['n1']) next = canvasStoreReducer(next, { type: 'path/setError', payload: { nodeId: 'n1', error: false }, }) expect(next.path.errorNodeIds).toEqual([]) }) it('path/clearPathSession resets trigger and pulse; keeps error', () => { const state: CanvasStore = { ...initialCanvasStore, path: { triggerNodeIds: ['t1'], pulseEndsAt: Date.now() + 1000, errorNodeIds: ['e1'], }, } const next = canvasStoreReducer(state, { type: 'path/clearPathSession' }) expect(next.path.triggerNodeIds).toEqual([]) expect(next.path.pulseEndsAt).toBeNull() expect(next.path.errorNodeIds).toEqual(['e1']) }) }) describe('ui commands', () => { it('ui/setRenaming updates renamingNodeId', () => { const state: CanvasStore = { ...initialCanvasStore } const next = canvasStoreReducer(state, { type: 'ui/setRenaming', payload: 'node-1', }) expect(next.ui.renamingNodeId).toBe('node-1') }) it('ui/setFullscreen updates fullscreenNodeId', () => { const state: CanvasStore = { ...initialCanvasStore } const next = canvasStoreReducer(state, { type: 'ui/setFullscreen', payload: 'node-2', }) expect(next.ui.fullscreenNodeId).toBe('node-2') }) it('ui/setConnectionFrom updates connectionFrom', () => { const state: CanvasStore = { ...initialCanvasStore } const next = canvasStoreReducer(state, { type: 'ui/setConnectionFrom', payload: { nodeId: 'n1', sourceHandle: 'out' }, }) expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' }) }) }) describe('no-op returns same reference', () => { it('path/addTrigger with existing id returns state', () => { const state: CanvasStore = { ...initialCanvasStore, path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] }, } const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' }) expect(next).toBe(state) }) }) }) // --------------------------------------------------------------------------- // Selectors // --------------------------------------------------------------------------- describe('canvasStore selectors', () => { it('selectNodes and selectEdges return graph slice', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [makeNode('a')], edges: [makeEdge('e1', 'a', 'b')], }, } expect(selectNodes(state)).toHaveLength(1) expect(selectEdges(state)).toHaveLength(1) }) it('selectPathNodeIds derives path from edges and path arrays', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [makeNode('a'), makeNode('b'), makeNode('c')], edges: [ makeEdge('e1', 'a', 'b'), makeEdge('e2', 'b', 'c'), ], }, path: { triggerNodeIds: ['a'], pulseEndsAt: null, errorNodeIds: [], }, } const pathIds = selectPathNodeIds(state) expect(pathIds.has('a')).toBe(true) expect(pathIds.has('b')).toBe(true) expect(pathIds.has('c')).toBe(true) }) it('selectConnectionStatusForEdge returns default when edge not on path', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] }, path: initialCanvasStore.path, } expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default') }) it('selectConnectionStatusForEdge returns error when target has error', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')], }, path: { ...initialCanvasStore.path, triggerNodeIds: ['a'], pulseEndsAt: null, errorNodeIds: ['b'], }, } expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error') }) it('selectPathRoleForNode returns trigger or on-path', () => { const state: CanvasStore = { ...initialCanvasStore, graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')], }, path: { ...initialCanvasStore.path, triggerNodeIds: ['a'], pulseEndsAt: null, errorNodeIds: [], }, } expect(selectPathRoleForNode(state, 'a')).toBe('trigger') expect(selectPathRoleForNode(state, 'b')).toBe('on-path') expect(selectPathRoleForNode(state, 'x')).toBe(null) }) it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => { const state: CanvasStore = { ...initialCanvasStore, ui: { renamingNodeId: 'r1', fullscreenNodeId: 'f1', connectionFrom: { nodeId: 'c1' }, }, } expect(selectRenamingNodeId(state)).toBe('r1') expect(selectFullscreenNodeId(state)).toBe('f1') expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' }) }) }) // --------------------------------------------------------------------------- // Store integration (dispatch + getState) // --------------------------------------------------------------------------- describe('canvas store integration', () => { beforeEach(() => { dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] }) dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] }) dispatchCanvasCommand({ type: 'path/clearPathSession' }) dispatchCanvasCommand({ type: 'path/clearTriggers' }) dispatchCanvasCommand({ type: 'path/clearErrors' }) dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null }) dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null }) dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null }) }) it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => { const node = makeNode('test-1') dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] }) const state = getCanvasStore() expect(state.graph.nodes).toHaveLength(1) expect(state.graph.nodes[0].id).toBe('test-1') }) it('dispatch path/addTrigger updates path and starts pulse', () => { dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] }) dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] }) dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' }) const state = getCanvasStore() expect(state.path.triggerNodeIds).toContain('a') expect(state.path.pulseEndsAt).not.toBeNull() const pathIds = selectPathNodeIds(state) expect(pathIds.has('a')).toBe(true) expect(pathIds.has('b')).toBe(true) }) it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => { dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' }) const state = getCanvasStore() expect(state.ui.fullscreenNodeId).toBe('full-node') }) // Pulse: addTrigger starts a short "updating" pulse along downstream path. it('path-per-sink: addTrigger starts pulse, all downstream edges show updating', () => { const nodes: AppNode[] = [ { id: 'var_001', type: 'variable', position: { x: -270, y: 210 }, data: { value: 'sss', valueType: 'string' }, }, { id: 'cfg_001', type: 'config', position: { x: 180, y: 330 }, data: { configType: 'plantuml', content: '@startuml\nactor Mulis\n@enduml\n', title: 'cfg_001' }, }, { id: 'rnd_001', type: 'render', position: { x: 780, y: 540 }, data: { updateMode: 'manual', runTrigger: 1, lastRunSourceSignature: 'sig1' }, }, { id: 'rnd_002', type: 'render', position: { x: 780, y: 180 }, data: { updateMode: 'auto', runTrigger: 2, lastRunSourceSignature: 'sig2' }, }, ] const edges: AppEdge[] = [ { id: 'xy-edge__var_001out-cfg_001ain', source: 'var_001', target: 'cfg_001', data: { targetType: 'config' } }, { id: 'xy-edge__cfg_001out-rnd_001ain', source: 'cfg_001', target: 'rnd_001', data: { targetType: 'render' } }, { id: 'xy-edge__cfg_001out-rnd_002ain', source: 'cfg_001', target: 'rnd_002', data: { targetType: 'render' } }, ] dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } }) dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'var_001' }) const state = getCanvasStore() const pathIds = selectPathNodeIds(state) expect(pathIds.has('var_001')).toBe(true) expect(pathIds.has('cfg_001')).toBe(true) expect(pathIds.has('rnd_001')).toBe(true) expect(pathIds.has('rnd_002')).toBe(true) // During pulse, all path edges show "updating". expect(selectConnectionStatusForEdge(state, 'var_001', 'cfg_001')).toBe('updating') expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_001')).toBe('updating') expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_002')).toBe('updating') }) })