505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
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 and pulseEndsAt', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
path: {
|
|
triggerNodeIds: ['a', 'b'],
|
|
pulseEndsAt: Date.now() + 1000,
|
|
errorNodeIds: [],
|
|
},
|
|
}
|
|
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
|
|
expect(next.path.triggerNodeIds).toEqual([])
|
|
expect(next.path.pulseEndsAt).toBeNull()
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('initial path shape', () => {
|
|
it('path has triggerNodeIds, pulseEndsAt, errorNodeIds; no updatingNodeIds', () => {
|
|
const path = initialCanvasStore.path
|
|
expect(path).toHaveProperty('triggerNodeIds')
|
|
expect(path).toHaveProperty('pulseEndsAt')
|
|
expect(path).toHaveProperty('errorNodeIds')
|
|
expect(path.triggerNodeIds).toEqual([])
|
|
expect(path.pulseEndsAt).toBeNull()
|
|
expect(path.errorNodeIds).toEqual([])
|
|
expect(path).not.toHaveProperty('updatingNodeIds')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 default when on path but pulse inactive', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
graph: {
|
|
nodes: [],
|
|
edges: [makeEdge('e1', 'a', 'b')],
|
|
},
|
|
path: {
|
|
triggerNodeIds: ['a'],
|
|
pulseEndsAt: null,
|
|
errorNodeIds: [],
|
|
},
|
|
}
|
|
expect(selectPathNodeIds(state).has('a')).toBe(true)
|
|
expect(selectPathNodeIds(state).has('b')).toBe(true)
|
|
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
|
|
})
|
|
|
|
it('selectConnectionStatusForEdge returns updating when on path and pulse active', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
graph: {
|
|
nodes: [],
|
|
edges: [makeEdge('e1', 'a', 'b')],
|
|
},
|
|
path: {
|
|
triggerNodeIds: ['a'],
|
|
pulseEndsAt: Date.now() + 2000,
|
|
errorNodeIds: [],
|
|
},
|
|
}
|
|
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('updating')
|
|
})
|
|
|
|
it('selectConnectionStatusForEdge returns error over updating when target has error', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
graph: {
|
|
nodes: [],
|
|
edges: [makeEdge('e1', 'a', 'b')],
|
|
},
|
|
path: {
|
|
triggerNodeIds: ['a'],
|
|
pulseEndsAt: Date.now() + 2000,
|
|
errorNodeIds: ['b'],
|
|
},
|
|
}
|
|
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
|
})
|
|
|
|
it('selectPathActiveSegmentNodeIds is empty when pulse inactive', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
graph: {
|
|
nodes: [],
|
|
edges: [makeEdge('e1', 'a', 'b')],
|
|
},
|
|
path: {
|
|
triggerNodeIds: ['a'],
|
|
pulseEndsAt: null,
|
|
errorNodeIds: [],
|
|
},
|
|
}
|
|
const active = selectPathActiveSegmentNodeIds(state)
|
|
expect(active.size).toBe(0)
|
|
})
|
|
|
|
it('selectPathActiveSegmentNodeIds returns downstream of trigger when pulse active', () => {
|
|
const state: CanvasStore = {
|
|
...initialCanvasStore,
|
|
graph: {
|
|
nodes: [],
|
|
edges: [
|
|
makeEdge('e1', 'a', 'b'),
|
|
makeEdge('e2', 'b', 'c'),
|
|
],
|
|
},
|
|
path: {
|
|
triggerNodeIds: ['a'],
|
|
pulseEndsAt: Date.now() + 2000,
|
|
errorNodeIds: [],
|
|
},
|
|
}
|
|
const active = selectPathActiveSegmentNodeIds(state)
|
|
expect(active.has('a')).toBe(true)
|
|
expect(active.has('b')).toBe(true)
|
|
expect(active.has('c')).toBe(true)
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|