Files
ComfyUI_frontend/tests-ui/tests/services/graphMutationService.test.ts
2025-09-19 15:30:11 -04:00

1656 lines
48 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GraphMutationError } from '@/core/graph/operations/GraphMutationError'
import type { IGraphMutationService } from '@/core/graph/operations/IGraphMutationService'
import {
GraphMutationService,
useGraphMutationService
} from '@/core/graph/operations/graphMutationService'
import {
CommandOrigin,
type GraphMutationOperation
} from '@/core/graph/operations/types'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
const mockGraph = vi.hoisted(() => ({
beforeChange: vi.fn(),
afterChange: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
getNodeById: vi.fn(),
removeLink: vi.fn(),
clear: vi.fn(),
setDirtyCanvas: vi.fn(),
_links: new Map(),
_groups: [] as LGraphGroup[],
_nodes: [] as LGraphNode[],
reroutes: new Map(),
createReroute: vi.fn(),
removeReroute: vi.fn(),
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
version: '1.0.0',
config: {}
}))
const mockApp = vi.hoisted(() => ({
graph: null as any,
changeTracker: null as any,
canvas: null as any
}))
Object.defineProperty(mockApp, 'graph', {
writable: true,
value: null
})
Object.defineProperty(mockApp, 'changeTracker', {
writable: true,
value: null
})
Object.defineProperty(mockApp, 'canvas', {
writable: true,
value: null
})
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
changeTracker: {
checkState: vi.fn(),
undo: vi.fn(),
redo: vi.fn()
}
}
}))
const mockLiteGraph = vi.hoisted(() => ({
createNode: vi.fn(),
uuidv4: vi.fn(() => 'mock-uuid-' + Math.random())
}))
const mockLGraphNode = vi.hoisted(() => ({
connect: vi.fn(),
disconnectInput: vi.fn(),
disconnectOutput: vi.fn(),
setProperty: vi.fn(),
changeMode: vi.fn(),
clone: vi.fn(),
serialize: vi.fn(),
configure: vi.fn(),
addInput: vi.fn(),
addOutput: vi.fn(),
removeInput: vi.fn(),
removeOutput: vi.fn()
}))
const mockLGraphGroup = vi.hoisted(() => {
let idCounter = 1000
return class MockLGraphGroup {
id = idCounter++
title = 'Group'
pos = [0, 0]
size = [200, 200]
color = '#335577'
font_size = 14
constructor(title?: string) {
if (title) this.title = title
}
move = vi.fn()
resize = vi.fn(() => true)
addNodes = vi.fn()
recomputeInsideNodes = vi.fn()
}
})
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
mockApp.graph = mockGraph
mockApp.changeTracker = mockWorkflowStore.activeWorkflow.changeTracker
mockApp.canvas = { subgraph: null }
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: mockLiteGraph,
LGraphNode: mockLGraphNode,
LGraphEventMode: {
ALWAYS: 0,
BYPASS: 4
}
}))
vi.mock('@/lib/litegraph/src/LGraphGroup', () => ({
LGraphGroup: mockLGraphGroup
}))
vi.mock('@/lib/litegraph/src/LGraph', () => ({
Subgraph: vi.fn().mockImplementation((graph, data) => ({
id: data.id,
addInput: vi.fn(),
addOutput: vi.fn(),
removeInput: vi.fn(),
removeOutput: vi.fn(),
inputs: [],
outputs: [],
graph: graph
}))
}))
const MockSubgraphNode = vi.hoisted(() => {
return class MockSubgraphNode {
constructor(graph: any, subgraph: any, data: any) {
this.id = data?.id
this.subgraph = subgraph
this.graph = graph
}
id: any
subgraph: any
graph: any
}
})
vi.mock('@/lib/litegraph/src/subgraph/SubgraphNode', () => ({
SubgraphNode: MockSubgraphNode
}))
describe('GraphMutationService', () => {
let service: IGraphMutationService
let mockNode: any
let mockLink: any
beforeEach(() => {
vi.clearAllMocks()
service = new GraphMutationService()
mockNode = {
id: 'node-1',
pos: [100, 100],
title: 'Test Node',
properties: {},
outputs: [],
inputs: [],
...mockLGraphNode
}
mockLink = {
id: '123',
origin_id: 'node-1',
origin_slot: 0,
target_id: 'node-2',
target_slot: 0
}
mockGraph.getNodeById.mockImplementation((id: string) => {
if (id === 'node-1' || id === 'node-2') return mockNode
return null
})
mockGraph.add.mockReturnValue(mockNode)
mockGraph._links.set(123, mockLink)
mockLiteGraph.createNode.mockReturnValue(mockNode)
mockNode.connect.mockReturnValue(mockLink)
mockNode.clone.mockReturnValue({ ...mockNode, id: 'cloned-node' })
mockNode.serialize.mockReturnValue({
type: 'TestNode',
id: 'node-1',
pos: [100, 100]
})
})
describe('initialization', () => {
it('should implement IGraphMutationService interface', () => {
expect(service).toHaveProperty('applyOperation')
expect(service).toHaveProperty('createNode')
expect(service).toHaveProperty('removeNode')
expect(service).toHaveProperty('connect')
expect(service).toHaveProperty('disconnect')
expect(service).toHaveProperty('undo')
expect(service).toHaveProperty('redo')
expect(typeof service.applyOperation).toBe('function')
expect(typeof service.createNode).toBe('function')
expect(typeof service.removeNode).toBe('function')
expect(typeof service.connect).toBe('function')
})
it('should have singleton behavior through useGraphMutationService', () => {
const instance1 = useGraphMutationService()
const instance2 = useGraphMutationService()
expect(instance1).toBe(instance2)
})
})
describe('command pattern operations', () => {
describe('applyOperation', () => {
it('should apply createNode command and return Result<NodeId>', async () => {
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
type: 'LoadImage',
title: 'My Image Loader',
properties: { seed: 12345 }
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('node-1')
}
expect(mockLiteGraph.createNode).toHaveBeenCalledWith(
'LoadImage',
'My Image Loader'
)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.add).toHaveBeenCalledWith(mockNode)
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should return GraphMutationError on failure', async () => {
mockLiteGraph.createNode.mockReturnValue(null)
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
type: 'InvalidType'
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toBeInstanceOf(GraphMutationError)
expect(result.error.message).toBe('Failed to add node')
expect(result.error.context).toMatchObject({
operation: 'addNode',
params: operation.params
})
expect(result.error.context.cause).toBeDefined()
}
})
it('should handle unknown operation type', async () => {
const operation: any = {
type: 'unknownOperation',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toBeInstanceOf(GraphMutationError)
expect(result.error.message).toBe('Unknown operation type')
expect(result.error.code).toBe('GRAPH_MUTATION_ERROR')
expect(result.error.context.operation).toBe('unknownOperation')
}
})
})
describe('Result<T, E> pattern', () => {
it('should return success Result with data', async () => {
const result = await service.createNode({ type: 'TestNode' })
expect(result).toMatchObject({
success: true,
data: 'node-1'
})
})
it('should return failure Result with GraphMutationError', async () => {
mockLiteGraph.createNode.mockReturnValue(null)
const result = await service.createNode({ type: 'InvalidType' })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toBeInstanceOf(GraphMutationError)
expect(result.error.message).toBe('Failed to add node')
expect(result.error.context.cause).toBeInstanceOf(Error)
}
})
it('should preserve error context in Result', async () => {
mockGraph.add.mockReturnValue(null)
const params = { type: 'TestNode', properties: { test: 123 } }
const result = await service.createNode(params)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context).toMatchObject({
operation: 'addNode',
params: params
})
}
})
})
describe('GraphMutationError', () => {
it('should create error with proper code and context', async () => {
mockGraph.getNodeById.mockReturnValue(null)
const result = await service.removeNode('nonexistent' as any)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('GRAPH_MUTATION_ERROR')
expect(result.error.message).toBe('Failed to remove node')
expect(result.error.context).toMatchObject({
operation: 'removeNode',
params: 'nonexistent'
})
}
})
it('should preserve original error as cause', async () => {
const originalError = new Error('Test error')
mockLiteGraph.createNode.mockImplementation(() => {
throw originalError
})
const result = await service.createNode({ type: 'TestNode' })
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause).toBe(originalError)
}
})
})
})
describe('node operations via commands', () => {
describe('removeNode command', () => {
it('should remove node and return success Result', async () => {
const operation: GraphMutationOperation = {
type: 'removeNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.remove).toHaveBeenCalledWith(mockNode)
})
it('should return error Result when node not found', async () => {
mockGraph.getNodeById.mockReturnValue(null)
const operation: GraphMutationOperation = {
type: 'removeNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'nonexistent' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Failed to remove node')
}
})
})
describe('updateNodeProperty command', () => {
it('should update property via command', async () => {
const operation: GraphMutationOperation = {
type: 'updateNodeProperty',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
nodeId: 'node-1' as any,
property: 'seed',
value: 54321
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockNode.setProperty).toHaveBeenCalledWith('seed', 54321)
})
})
describe('updateNodeTitle command', () => {
it('should update title and handle transaction boundaries', async () => {
const operation: GraphMutationOperation = {
type: 'updateNodeTitle',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
nodeId: 'node-1' as any,
title: 'New Title'
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockNode.title).toBe('New Title')
expect(mockGraph.beforeChange).toHaveBeenCalledBefore(
mockGraph.afterChange as any
)
})
})
describe('changeNodeMode command', () => {
it('should change mode via command', async () => {
mockNode.changeMode.mockReturnValue(true)
const operation: GraphMutationOperation = {
type: 'changeNodeMode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
nodeId: 'node-1' as any,
mode: 4
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockNode.changeMode).toHaveBeenCalledWith(4)
})
it('should return error when mode change fails', async () => {
mockNode.changeMode.mockReturnValue(false)
const operation: GraphMutationOperation = {
type: 'changeNodeMode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
nodeId: 'node-1' as any,
mode: 999
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Failed to update node property')
expect(result.error.context.cause.message).toContain('999')
}
})
})
describe('cloneNode command', () => {
it('should clone node via command', async () => {
const operation: GraphMutationOperation = {
type: 'cloneNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('node-1')
}
expect(mockNode.clone).toHaveBeenCalled()
})
it('should handle clone failure with proper error', async () => {
mockNode.clone.mockReturnValue(null)
const operation: GraphMutationOperation = {
type: 'cloneNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.operation).toBe('cloneNode')
}
})
})
})
describe('connection operations via commands', () => {
describe('connect command', () => {
it('should create connection and return LinkId', async () => {
const operation: GraphMutationOperation = {
type: 'connect',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
sourceNodeId: 'node-1' as any,
sourceSlot: 0,
targetNodeId: 'node-2' as any,
targetSlot: 1
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('123')
}
expect(mockNode.connect).toHaveBeenCalledWith(0, mockNode, 1)
})
it('should return error when nodes not found', async () => {
mockGraph.getNodeById.mockImplementation((id: string) =>
id === 'node-1' ? null : mockNode
)
const operation: GraphMutationOperation = {
type: 'connect',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
sourceNodeId: 'node-1' as any,
sourceSlot: 0,
targetNodeId: 'node-2' as any,
targetSlot: 1
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Failed to clone node')
expect(result.error.context.cause.message).toContain('node-1')
}
})
})
describe('disconnect command', () => {
it('should disconnect node slot', async () => {
mockNode.disconnectInput.mockReturnValue(true)
const operation: GraphMutationOperation = {
type: 'disconnect',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
nodeId: 'node-1' as any,
slot: 0,
slotType: 'input'
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe(true)
}
expect(mockNode.disconnectInput).toHaveBeenCalledWith(0)
})
})
describe('disconnectLink command', () => {
it('should disconnect specific link', async () => {
const operation: GraphMutationOperation = {
type: 'disconnectLink',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 123 as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.removeLink).toHaveBeenCalledWith(123)
})
})
})
describe('group operations via commands', () => {
let mockGroup: any
beforeEach(() => {
mockGroup = new mockLGraphGroup('Test Group')
mockGroup.id = 999
mockGraph._groups = [mockGroup] as any
})
describe('createGroup command', () => {
it('should create group with parameters', async () => {
const operation: GraphMutationOperation = {
type: 'createGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
title: 'My Group',
size: [300, 250] as [number, number],
color: '#ff0000',
fontSize: 16
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(typeof result.data).toBe('number')
expect(result.data).toBeGreaterThanOrEqual(1000)
}
})
})
describe('removeGroup command', () => {
it('should remove group and return success', async () => {
const operation: GraphMutationOperation = {
type: 'removeGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: mockGroup.id
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.remove).toHaveBeenCalledWith(mockGroup)
})
it('should return error when group not found', async () => {
const operation: GraphMutationOperation = {
type: 'removeGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 123456 as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Failed to remove group')
}
})
})
describe('addNodesToGroup command', () => {
it('should add nodes to group via command', async () => {
const operation: GraphMutationOperation = {
type: 'addNodesToGroup',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
groupId: mockGroup.id,
nodeIds: ['node-1' as any, 'node-2' as any]
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGroup.addNodes).toHaveBeenCalledWith([mockNode, mockNode])
})
})
})
describe('command timestamp and origin', () => {
it('should validate timestamp exists on all commands', async () => {
const timestamp = Date.now()
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: timestamp,
origin: CommandOrigin.Local,
params: { type: 'TestNode' }
}
await service.applyOperation(operation)
expect(operation.timestamp).toBe(timestamp)
expect(operation.timestamp).toBeLessThanOrEqual(Date.now())
})
it('should validate origin field on commands', async () => {
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: { type: 'TestNode' }
}
await service.applyOperation(operation)
expect(operation.origin).toBe(CommandOrigin.Local)
})
})
describe('clipboard operations via commands', () => {
beforeEach(() => {
localStorage.clear()
})
describe('copyNodes command', () => {
it('should copy nodes and return success Result', async () => {
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
mockNode.clone = vi.fn(() => clonedNode)
const operation: GraphMutationOperation = {
type: 'copyNodes',
timestamp: Date.now(),
origin: CommandOrigin.Local,
nodeIds: ['node-1' as any]
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
const storedData = localStorage.getItem('litegrapheditor_clipboard')
expect(storedData).not.toBeNull()
})
it('should return error for empty node list', async () => {
const operation: GraphMutationOperation = {
type: 'copyNodes',
timestamp: Date.now(),
origin: CommandOrigin.Local,
nodeIds: []
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('No nodes')
}
})
})
describe('cutNodes command', () => {
it('should cut nodes via command', async () => {
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
mockNode.clone = vi.fn(() => clonedNode)
const operation: GraphMutationOperation = {
type: 'cutNodes',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: ['node-1' as any]
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
const storedData = localStorage.getItem('litegrapheditor_clipboard')
const clipboardData = JSON.parse(storedData!)
expect(clipboardData.isCut).toBe(true)
})
})
describe('pasteNodes command', () => {
beforeEach(async () => {
const clipboardData = {
nodes: [
{
id: 'node-1',
type: 'TestNode',
pos: [100, 100]
}
],
links: [],
isCut: false
}
localStorage.setItem(
'litegrapheditor_clipboard',
JSON.stringify(clipboardData)
)
const newNode = { ...mockNode, id: 'new-node-1', configure: vi.fn() }
mockLiteGraph.createNode.mockReturnValue(newNode)
mockGraph.add.mockReturnValue(newNode)
})
it('should paste nodes and return node IDs', async () => {
const operation: GraphMutationOperation = {
type: 'pasteNodes',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(Array.isArray(result.data)).toBe(true)
expect(result.data).toHaveLength(1)
}
})
it('should return error for empty clipboard', async () => {
localStorage.clear()
const operation: GraphMutationOperation = {
type: 'pasteNodes',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('Clipboard')
}
})
})
})
describe('undo/redo operations via commands', () => {
it('should execute undo command', async () => {
const operation: GraphMutationOperation = {
type: 'undo',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(
mockWorkflowStore.activeWorkflow.changeTracker.undo
).toHaveBeenCalled()
})
it('should execute redo command', async () => {
const operation: GraphMutationOperation = {
type: 'redo',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(
mockWorkflowStore.activeWorkflow.changeTracker.redo
).toHaveBeenCalled()
})
it('should return error when change tracker missing', async () => {
const originalActiveWorkflow = mockWorkflowStore.activeWorkflow
mockWorkflowStore.activeWorkflow = null as any
const operation: GraphMutationOperation = {
type: 'undo',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('workflow')
}
mockWorkflowStore.activeWorkflow = originalActiveWorkflow
})
})
describe('graph-level operations via commands', () => {
describe('clearGraph command', () => {
it('should clear graph via command', async () => {
const operation: GraphMutationOperation = {
type: 'clearGraph',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.clear).toHaveBeenCalled()
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
})
describe('bypass commands', () => {
it('should bypass node via command', async () => {
const operation: GraphMutationOperation = {
type: 'bypassNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockNode.mode).toBe(4)
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
it('should unbypass node via command', async () => {
const operation: GraphMutationOperation = {
type: 'unbypassNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockNode.mode).toBe(0)
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
})
})
describe('error propagation and context', () => {
it('should wrap thrown errors in GraphMutationError', async () => {
const originalError = new Error('Node creation failed')
mockLiteGraph.createNode.mockImplementation(() => {
throw originalError
})
const operation: GraphMutationOperation = {
type: 'createNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: { type: 'FailNode' }
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toBeInstanceOf(GraphMutationError)
expect(result.error.context.cause).toBe(originalError)
expect(result.error.context.operation).toBe('addNode')
}
})
it('should preserve full error context', async () => {
mockGraph.getNodeById.mockReturnValue(null)
const params = {
nodeId: 'test-node' as any,
property: 'testProp',
value: 'testValue'
}
const operation: GraphMutationOperation = {
type: 'updateNodeProperty',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context).toMatchObject({
operation: 'updateNodeProperty',
params: params
})
expect(result.error.context.cause).toBeDefined()
}
})
it('should handle async errors properly', async () => {
mockWorkflowStore.activeWorkflow.changeTracker.undo.mockRejectedValue(
new Error('Undo failed')
)
const operation: GraphMutationOperation = {
type: 'undo',
timestamp: Date.now(),
origin: CommandOrigin.Local
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.message).toBe('Failed to undo')
expect(result.error.context.cause.message).toBe('Undo failed')
}
})
})
describe('subgraph operations via commands', () => {
let mockSubgraphNode: any
let mockSubgraph: any
beforeEach(() => {
mockSubgraph = {
id: 'subgraph-1',
nodes: [],
groups: [],
reroutes: new Map(),
inputs: [],
outputs: [],
addInput: vi.fn(),
addOutput: vi.fn(),
removeInput: vi.fn(),
removeOutput: vi.fn()
}
mockSubgraphNode = new MockSubgraphNode(mockGraph, mockSubgraph, {
id: 'subgraph-node-1'
})
mockSubgraphNode.type = 'subgraph'
mockSubgraphNode.isSubgraphNode = vi.fn(() => true)
})
describe('createSubgraph command', () => {
it('should create subgraph via command', async () => {
const selectedItems = new Set([mockNode])
const expectedResult = {
subgraph: mockSubgraph,
node: mockSubgraphNode
}
mockGraph.convertToSubgraph.mockReturnValue(expectedResult)
const operation: GraphMutationOperation = {
type: 'createSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: { selectedItems }
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe(expectedResult)
}
})
it('should return error when no items selected', async () => {
const operation: GraphMutationOperation = {
type: 'createSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: { selectedItems: new Set() }
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('no items')
}
})
})
describe('unpackSubgraph command', () => {
it('should unpack subgraph via command', async () => {
mockGraph.getNodeById.mockReturnValue(mockSubgraphNode)
mockGraph.unpackSubgraph.mockImplementation(() => {})
const operation: GraphMutationOperation = {
type: 'unpackSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'subgraph-node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(mockSubgraphNode)
})
it('should return error for non-subgraph node', async () => {
const regularNode = {
...mockNode,
isSubgraphNode: undefined,
subgraph: undefined
}
mockGraph.getNodeById.mockReturnValue(regularNode)
const operation: GraphMutationOperation = {
type: 'unpackSubgraph',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('not a subgraph')
}
})
})
describe('subgraph input/output commands', () => {
beforeEach(() => {
mockGraph._nodes = [mockSubgraphNode]
})
it('should add subgraph input via command', async () => {
const operation: GraphMutationOperation = {
type: 'addSubgraphInput',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
subgraphId: 'subgraph-1' as any,
name: 'input1',
type: 'number'
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockSubgraph.addInput).toHaveBeenCalledWith('input1', 'number')
})
it('should remove subgraph output via command', async () => {
mockSubgraph.outputs = ['output1']
const operation: GraphMutationOperation = {
type: 'removeSubgraphOutput',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
subgraphId: 'subgraph-1' as any,
index: 0
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockSubgraph.removeOutput).toHaveBeenCalledWith('output1')
})
})
})
describe('reroute operations via commands', () => {
describe('addReroute command', () => {
it('should add reroute to link', async () => {
const mockReroute = { id: 'reroute-1' }
mockGraph.createReroute.mockReturnValue(mockReroute)
mockGraph._links.set(123, mockLink)
const operation: GraphMutationOperation = {
type: 'addReroute',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
pos: [100, 100],
linkId: 123 as any
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('reroute-1')
}
})
it('should return error when link not found', async () => {
mockGraph._links.clear()
const operation: GraphMutationOperation = {
type: 'addReroute',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: {
pos: [100, 100],
linkId: 999 as any
}
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain('999')
}
})
})
describe('removeReroute command', () => {
it('should remove reroute', async () => {
mockGraph.reroutes.set('reroute-1' as any, {})
const operation: GraphMutationOperation = {
type: 'removeReroute',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'reroute-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.removeReroute).toHaveBeenCalledWith('reroute-1')
})
})
})
describe('edge cases', () => {
describe('canvas null scenarios', () => {
it('should handle operations when canvas is not initialized', async () => {
mockGraph.setDirtyCanvas.mockImplementation(() => {
throw new Error('Canvas not initialized')
})
const operation: GraphMutationOperation = {
type: 'bypassNode',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'node-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.context.cause.message).toContain(
'Canvas not initialized'
)
}
})
it('should handle group operations without canvas', async () => {
mockGraph.setDirtyCanvas.mockImplementation(() => {
throw new Error('Canvas is null')
})
const mockGroup = new mockLGraphGroup('Test Group')
mockGroup.id = 999
mockGraph._groups = [mockGroup] as any
const result = await service.updateGroupTitle({
groupId: mockGroup.id,
title: 'New Title'
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error).toBeInstanceOf(GraphMutationError)
expect(result.error.message).toBe('Failed to update group title')
expect(result.error.context.cause.message).toBe('Canvas is null')
}
})
it('should handle reroute operations without canvas', async () => {
mockGraph.setDirtyCanvas.mockImplementation(() => {
console.warn('Canvas not available')
})
mockGraph.reroutes.set('reroute-1' as any, {})
const operation: GraphMutationOperation = {
type: 'removeReroute',
timestamp: Date.now(),
origin: CommandOrigin.Local,
params: 'reroute-1' as any
}
const result = await service.applyOperation(operation)
expect(result.success).toBe(true)
expect(mockGraph.removeReroute).toHaveBeenCalledWith('reroute-1')
})
})
describe('subgraph context switching', () => {
let parentGraph: any
let subgraphContext: any
let mockSubgraphNode: any
let mockSubgraph: any
beforeEach(() => {
parentGraph = {
beforeChange: vi.fn(),
afterChange: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
getNodeById: vi.fn(),
removeLink: vi.fn(),
clear: vi.fn(),
setDirtyCanvas: vi.fn(),
_links: new Map(),
_groups: [] as any[],
_nodes: [] as any[],
reroutes: new Map(),
createReroute: vi.fn(),
removeReroute: vi.fn(),
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
version: '1.0.0',
config: {}
}
mockSubgraph = {
id: 'subgraph-1',
nodes: [],
groups: [],
reroutes: new Map(),
inputs: [],
outputs: [],
addInput: vi.fn(),
addOutput: vi.fn(),
removeInput: vi.fn(),
removeOutput: vi.fn(),
_nodes: [] as any[]
}
mockSubgraphNode = new MockSubgraphNode(parentGraph, mockSubgraph, {
id: 'subgraph-node-1'
})
mockSubgraphNode.type = 'subgraph'
mockSubgraphNode.isSubgraphNode = vi.fn(() => true)
subgraphContext = {
beforeChange: vi.fn(),
afterChange: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
getNodeById: vi.fn(),
removeLink: vi.fn(),
clear: vi.fn(),
setDirtyCanvas: vi.fn(),
_links: new Map(),
_groups: [] as any[],
_nodes: [] as any[],
reroutes: new Map(),
createReroute: vi.fn(),
removeReroute: vi.fn(),
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
version: '1.0.0',
config: {},
_parent_graph: parentGraph,
_is_subgraph: true
}
parentGraph._nodes = [mockSubgraphNode]
})
it('should handle mutations during subgraph navigation', async () => {
const originalGraph = mockApp.graph
mockApp.graph = subgraphContext
const nodeInSubgraph = { ...mockNode, id: 'subgraph-inner-node' }
mockLiteGraph.createNode.mockReturnValue(nodeInSubgraph)
subgraphContext.add.mockReturnValue(nodeInSubgraph)
const result = await service.createNode({
type: 'TestNode',
title: 'Node in Subgraph'
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('subgraph-inner-node')
}
expect(subgraphContext.add).toHaveBeenCalledWith(nodeInSubgraph)
expect(parentGraph.add).not.toHaveBeenCalled()
mockApp.graph = originalGraph
})
it('should handle widget configuration in subgraph context', async () => {
const originalGraph = mockApp.graph
mockApp.graph = subgraphContext
const nodeWithWidgets = {
...mockNode,
id: 'widget-node',
widgets: [
{ type: 'number', name: 'seed', value: 123 },
{ type: 'combo', name: 'sampler', value: 'euler' }
]
}
mockLiteGraph.createNode.mockReturnValue(nodeWithWidgets)
subgraphContext.add.mockReturnValue(nodeWithWidgets)
subgraphContext.getNodeById.mockReturnValue(nodeWithWidgets)
const createResult = await service.createNode({
type: 'KSampler',
properties: { seed: 456 }
})
expect(createResult.success).toBe(true)
const updateResult = await service.updateNodeProperty({
nodeId: 'widget-node' as any,
property: 'seed',
value: 789
})
expect(updateResult.success).toBe(true)
expect(nodeWithWidgets.setProperty).toHaveBeenCalledWith('seed', 789)
mockApp.graph = originalGraph
})
it('should switch between parent and subgraph contexts correctly', async () => {
const originalGraph = mockApp.graph
mockApp.graph = parentGraph
parentGraph.getNodeById.mockReturnValue(mockSubgraphNode)
parentGraph._nodes = [mockSubgraphNode]
const getSubgraphPrivate = (service as any).getSubgraph.bind(service)
const foundSubgraph = getSubgraphPrivate('subgraph-1')
expect(foundSubgraph).toBe(mockSubgraph)
mockApp.graph = subgraphContext
const innerNode = { ...mockNode, id: 'inner-node' }
subgraphContext.getNodeById.mockReturnValue(innerNode)
const result = await service.removeNode('inner-node' as any)
expect(result.success).toBe(true)
expect(subgraphContext.remove).toHaveBeenCalledWith(innerNode)
mockApp.graph = originalGraph
})
})
describe('link ID preservation', () => {
it('should preserve link IDs across subgraph operations', async () => {
const originalLinkId = 'link-123'
const sourceNode = {
...mockNode,
id: 'source-1',
outputs: [{ links: [originalLinkId] }]
}
const targetNode = {
...mockNode,
id: 'target-1',
inputs: [{ link: originalLinkId }]
}
mockGraph.getNodeById.mockImplementation((id: string) => {
if (id === 'source-1') return sourceNode
if (id === 'target-1') return targetNode
return null
})
const originalLink = {
id: originalLinkId,
origin_id: 'source-1',
origin_slot: 0,
target_id: 'target-1',
target_slot: 0,
type: 'IMAGE'
}
mockGraph._links.set(originalLinkId, originalLink)
const selectedItems = new Set([sourceNode, targetNode])
const subgraph = {
id: 'test-subgraph',
nodes: [],
_links: new Map(),
addNode: vi.fn(),
addLink: vi.fn()
}
const subgraphNode = {
id: 'subgraph-node-1',
type: 'subgraph',
subgraph: subgraph,
inputs: [],
outputs: []
}
mockGraph.convertToSubgraph.mockReturnValue({
subgraph: subgraph,
node: subgraphNode
})
const createResult = await service.createSubgraph({ selectedItems })
expect(createResult.success).toBe(true)
if (createResult.success) {
expect(createResult.data.subgraph).toBe(subgraph)
expect(createResult.data.node).toBe(subgraphNode)
}
mockGraph.getNodeById.mockReturnValue(subgraphNode)
mockGraph.unpackSubgraph.mockImplementation(() => {
const newSourceNode = { ...sourceNode, id: 'source-2' }
const newTargetNode = { ...targetNode, id: 'target-2' }
const newLink = {
id: 'new-link-789',
origin_id: newSourceNode.id,
origin_slot: 0,
target_id: newTargetNode.id,
target_slot: 0,
type: originalLink.type
}
mockGraph._nodes.push(newSourceNode, newTargetNode)
mockGraph._links.set('new-link-789', newLink)
newSourceNode.outputs[0].links = ['new-link-789']
newTargetNode.inputs[0].link = 'new-link-789'
})
const unpackResult = await service.unpackSubgraph(
'subgraph-node-1' as any
)
expect(unpackResult.success).toBe(true)
expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode)
const newLink = mockGraph._links.get('new-link-789')
expect(newLink).toBeDefined()
expect(newLink.type).toBe(originalLink.type)
})
it('should maintain link integrity when copying subgraph nodes', async () => {
const subgraphNode = {
...mockNode,
id: 'subgraph-1',
type: 'subgraph',
isSubgraphNode: vi.fn(() => true),
subgraph: {
nodes: [
{ id: 'internal-1', outputs: [{ links: ['internal-link-1'] }] },
{ id: 'internal-2', inputs: [{ link: 'internal-link-1' }] }
],
_links: new Map([
[
'internal-link-1',
{
id: 'internal-link-1',
origin_id: 'internal-1',
target_id: 'internal-2'
}
]
])
}
}
mockGraph.getNodeById.mockReturnValue(subgraphNode)
const clonedSubgraph = {
...subgraphNode,
id: 'subgraph-2',
subgraph: {
nodes: [
{ id: 'internal-3', outputs: [{ links: ['internal-link-2'] }] },
{ id: 'internal-4', inputs: [{ link: 'internal-link-2' }] }
],
_links: new Map([
[
'internal-link-2',
{
id: 'internal-link-2',
origin_id: 'internal-3',
target_id: 'internal-4'
}
]
])
}
}
subgraphNode.clone = vi.fn(() => clonedSubgraph)
mockGraph.add.mockReturnValue(clonedSubgraph)
const result = await service.cloneNode('subgraph-1' as any)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toBe('subgraph-2')
}
expect(clonedSubgraph.subgraph._links.size).toBe(1)
const clonedLink = clonedSubgraph.subgraph._links.get('internal-link-2')
expect(clonedLink).toBeDefined()
expect(clonedLink.id).not.toBe('internal-link-1')
expect(clonedLink.origin_id).toBe('internal-3')
expect(clonedLink.target_id).toBe('internal-4')
})
})
})
})