feat: introdce store
This commit is contained in:
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
initialCanvasStore,
|
||||
canvasStoreReducer,
|
||||
getCanvasStore,
|
||||
dispatchCanvasCommand,
|
||||
} from './canvasStore'
|
||||
import {
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
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/startUpdate adds to updatingNodeIds', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/startUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/endUpdate removes from updatingNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
updatingNodeIds: ['n1', 'n2'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/endUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
|
||||
it('path/setPaused adds and removes paused node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: true },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: false },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
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 updating, trigger, paused; keeps error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
updatingNodeIds: ['u1'],
|
||||
triggerNodeIds: ['t1'],
|
||||
pausedNodeIds: ['p1'],
|
||||
errorNodeIds: ['e1'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
||||
expect(next.path.updatingNodeIds).toEqual([])
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
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: {
|
||||
updatingNodeIds: ['c'],
|
||||
triggerNodeIds: ['a'],
|
||||
pausedNodeIds: [],
|
||||
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'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: ['b'],
|
||||
},
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
||||
})
|
||||
|
||||
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
||||
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
|
||||
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 selectPathNodeIds', () => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.path.triggerNodeIds).toContain('a')
|
||||
expect(state.path.updatingNodeIds).toContain('b')
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user