Files
ComfyUI_frontend/tests-ui/tests/services/graphMutationService.test.ts
2025-08-17 23:48:31 -04:00

1050 lines
31 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IGraphMutationService } from '@/services/IGraphMutationService'
import {
GraphMutationService,
ValidationException,
useGraphMutationService
} from '@/services/graphMutationService'
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: [],
_nodes: [],
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: mockGraph
}))
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
}))
vi.mock('@/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
}))
}))
vi.mock('@/lib/litegraph/src/subgraph/SubgraphNode', () => ({
SubgraphNode: vi.fn().mockImplementation((graph, subgraph, data) => ({
id: data.id,
subgraph,
graph: graph
}))
}))
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('addNode')
expect(service).toHaveProperty('removeNode')
expect(service).toHaveProperty('connect')
expect(service).toHaveProperty('transaction')
expect(service).toHaveProperty('undo')
expect(service).toHaveProperty('redo')
expect(typeof service.addNode).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('node operations', () => {
describe('addNode', () => {
it('should add a node successfully', async () => {
const params = {
type: 'LoadImage',
pos: [100, 200] as [number, number],
title: 'My Image Loader',
properties: { seed: 12345 }
}
const nodeId = await service.addNode(params)
expect(mockLiteGraph.createNode).toHaveBeenCalledWith('LoadImage')
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.add).toHaveBeenCalledWith(mockNode)
expect(mockGraph.afterChange).toHaveBeenCalled()
expect(
mockWorkflowStore.activeWorkflow.changeTracker.checkState
).toHaveBeenCalled()
expect(nodeId).toBe('node-1')
})
it('should handle node creation failure', async () => {
mockLiteGraph.createNode.mockReturnValue(null)
await expect(service.addNode({ type: 'InvalidType' })).rejects.toThrow(
'Failed to create node of type: InvalidType'
)
})
it('should handle graph add failure', async () => {
mockGraph.add.mockReturnValue(null)
await expect(service.addNode({ type: 'TestNode' })).rejects.toThrow(
'Failed to add node to graph'
)
})
it('should set node properties correctly', async () => {
const params = {
type: 'TestNode',
pos: [50, 75] as [number, number],
title: 'Custom Title',
properties: { prop1: 'value1', prop2: 42 }
}
await service.addNode(params)
expect(mockNode.pos).toEqual([50, 75])
expect(mockNode.title).toBe('Custom Title')
expect(mockNode.properties).toBeDefined()
})
})
describe('removeNode', () => {
it('should remove a node successfully', async () => {
await service.removeNode('node-1')
expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1')
expect(mockGraph.remove).toHaveBeenCalledWith(mockNode)
expect(
mockWorkflowStore.activeWorkflow.changeTracker.checkState
).toHaveBeenCalled()
})
it('should handle node not found', async () => {
mockGraph.getNodeById.mockReturnValue(null)
await expect(service.removeNode('nonexistent')).rejects.toThrow(
'Node with id nonexistent not found'
)
})
})
describe('updateNodeProperty', () => {
it('should update node property successfully', async () => {
await service.updateNodeProperty('node-1', 'seed', 54321)
expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1')
expect(mockNode.setProperty).toHaveBeenCalledWith('seed', 54321)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should handle node not found', async () => {
mockGraph.getNodeById.mockReturnValue(null)
await expect(
service.updateNodeProperty('nonexistent', 'prop', 'value')
).rejects.toThrow('Node with id nonexistent not found')
})
})
describe('updateNodeTitle', () => {
it('should update node title successfully', async () => {
await service.updateNodeTitle('node-1', 'New Title')
expect(mockNode.title).toBe('New Title')
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
})
describe('changeNodeMode', () => {
it('should change node mode successfully', async () => {
mockNode.changeMode.mockReturnValue(true)
await service.changeNodeMode('node-1', 4)
expect(mockNode.changeMode).toHaveBeenCalledWith(4)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should handle mode change failure', async () => {
mockNode.changeMode.mockReturnValue(false)
await expect(service.changeNodeMode('node-1', 999)).rejects.toThrow(
'Failed to change node mode to 999'
)
})
})
describe('cloneNode', () => {
it('should clone node with custom position', async () => {
const clonedId = await service.cloneNode('node-1', [300, 400])
expect(mockNode.clone).toHaveBeenCalled()
expect(mockGraph.add).toHaveBeenCalled()
expect(clonedId).toBe('node-1') // mockNode.id is returned
})
it('should clone node with offset position', async () => {
mockNode.pos = [100, 200]
await service.cloneNode('node-1')
const clonedNode = mockNode.clone()
expect(clonedNode.pos).toEqual([150, 250]) // Original + 50 offset
})
it('should handle clone failure', async () => {
mockNode.clone.mockReturnValue(null)
await expect(service.cloneNode('node-1')).rejects.toThrow(
'Failed to clone node'
)
})
})
})
describe('connection operations', () => {
describe('connect', () => {
it('should create connection successfully', async () => {
const params = {
sourceNodeId: 'node-1' as any,
sourceSlot: 0,
targetNodeId: 'node-2' as any,
targetSlot: 1
}
const linkId = await service.connect(params)
expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-1')
expect(mockGraph.getNodeById).toHaveBeenCalledWith('node-2')
expect(mockNode.connect).toHaveBeenCalledWith(0, mockNode, 1)
expect(linkId).toBe('123')
})
it('should handle source node not found', async () => {
mockGraph.getNodeById.mockImplementation((id: string) =>
id === 'node-1' ? null : mockNode
)
const params = {
sourceNodeId: 'node-1' as any,
sourceSlot: 0,
targetNodeId: 'node-2' as any,
targetSlot: 1
}
await expect(service.connect(params)).rejects.toThrow(
'Source node with id node-1 not found'
)
})
it('should handle connection failure', async () => {
mockNode.connect.mockReturnValue(null)
const params = {
sourceNodeId: 'node-1' as any,
sourceSlot: 0,
targetNodeId: 'node-2' as any,
targetSlot: 1
}
await expect(service.connect(params)).rejects.toThrow(
'Failed to create connection'
)
})
})
describe('disconnect operations', () => {
it('should disconnect input successfully', async () => {
mockNode.disconnectInput.mockReturnValue(true)
const result = await service.disconnectInput('node-1', 0)
expect(mockNode.disconnectInput).toHaveBeenCalledWith(0)
expect(result).toBe(true)
})
it('should disconnect output successfully', async () => {
mockNode.disconnectOutput.mockReturnValue(true)
const result = await service.disconnectOutput('node-1', 0)
expect(mockNode.disconnectOutput).toHaveBeenCalledWith(0)
expect(result).toBe(true)
})
it('should disconnect output to specific target', async () => {
mockNode.disconnectOutput.mockReturnValue(true)
const result = await service.disconnectOutputTo('node-1', 0, 'node-2')
expect(mockNode.disconnectOutput).toHaveBeenCalledWith(0, mockNode)
expect(result).toBe(true)
})
})
describe('disconnectLink', () => {
it('should disconnect link successfully', async () => {
await service.disconnectLink(123)
expect(mockGraph.removeLink).toHaveBeenCalledWith(123)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
})
})
describe('group operations', () => {
let mockGroup: any
beforeEach(() => {
mockGroup = new mockLGraphGroup('Test Group')
mockGroup.id = 999
mockGraph._groups = [mockGroup] as any
})
describe('createGroup', () => {
it('should create group with all parameters', async () => {
const params = {
title: 'My Group',
pos: [50, 60] as [number, number],
size: [300, 250] as [number, number],
color: '#ff0000',
fontSize: 16
}
const groupId = await service.createGroup(params)
expect(mockGraph.add).toHaveBeenCalled()
expect(typeof groupId).toBe('number')
expect(groupId).toBeGreaterThanOrEqual(1000)
})
it('should create group with default values', async () => {
const groupId = await service.createGroup({})
expect(mockGraph.add).toHaveBeenCalled()
expect(typeof groupId).toBe('number')
expect(groupId).toBeGreaterThanOrEqual(1000)
})
})
describe('removeGroup', () => {
it('should remove group successfully', async () => {
await service.removeGroup(mockGroup.id)
expect(mockGraph.remove).toHaveBeenCalledWith(mockGroup)
})
it('should handle group not found', async () => {
await expect(service.removeGroup(123456)).rejects.toThrow(
'Group with id 123456 not found'
)
})
})
describe('updateGroupTitle', () => {
it('should update group title successfully', async () => {
await service.updateGroupTitle(mockGroup.id, 'New Title')
expect(mockGroup.title).toBe('New Title')
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
})
describe('moveGroup', () => {
it('should move group successfully', async () => {
await service.moveGroup(mockGroup.id, 25, 30)
expect(mockGroup.move).toHaveBeenCalledWith(25, 30, false)
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
})
describe('addNodesToGroup', () => {
it('should add nodes to group successfully', async () => {
await service.addNodesToGroup(mockGroup.id, ['node-1', 'node-2'])
expect(mockGroup.addNodes).toHaveBeenCalledWith([mockNode, mockNode])
expect(mockGroup.recomputeInsideNodes).toHaveBeenCalled()
})
it('should handle node not found', async () => {
mockGraph.getNodeById.mockImplementation((id: string) =>
id === 'node-1' ? mockNode : null
)
await expect(
service.addNodesToGroup(mockGroup.id, ['node-1', 'nonexistent'])
).rejects.toThrow('Node with id nonexistent not found')
})
})
})
describe('batch operations', () => {
describe('addNodes', () => {
it('should add multiple nodes successfully', async () => {
const nodes = [
{ type: 'Node1', pos: [0, 0] as [number, number] },
{ type: 'Node2', pos: [100, 100] as [number, number] }
]
const nodeIds = await service.addNodes(nodes)
expect(mockLiteGraph.createNode).toHaveBeenCalledTimes(2)
expect(mockGraph.add).toHaveBeenCalledTimes(2)
expect(nodeIds).toHaveLength(2)
})
it('should handle partial failure and rollback', async () => {
mockLiteGraph.createNode
.mockReturnValueOnce(mockNode)
.mockReturnValueOnce(null)
const nodes = [{ type: 'Node1' }, { type: 'InvalidNode' }]
await expect(service.addNodes(nodes)).rejects.toThrow(
'Failed to create node of type: InvalidNode'
)
expect(mockGraph.afterChange).toHaveBeenCalled()
})
})
describe('removeNodes', () => {
it('should remove multiple nodes successfully', async () => {
await service.removeNodes(['node-1', 'node-2'])
expect(mockGraph.remove).toHaveBeenCalledTimes(2)
})
it('should validate all nodes exist first', async () => {
mockGraph.getNodeById.mockImplementation((id: string) =>
id === 'node-1' ? mockNode : null
)
await expect(
service.removeNodes(['node-1', 'nonexistent'])
).rejects.toThrow('Node with id nonexistent not found')
expect(mockGraph.remove).not.toHaveBeenCalled()
})
})
describe('duplicateNodes', () => {
beforeEach(() => {
const node1 = {
...mockNode,
id: 'node-1',
outputs: [{ links: [123] }]
}
const node2 = { ...mockNode, id: 'node-2' }
mockGraph.getNodeById.mockImplementation((id: string) => {
if (id === 'node-1') return node1
if (id === 'node-2') return node2
if (id === 'cloned-node') return { ...mockNode, id: 'cloned-node' }
return null
})
mockGraph.add.mockImplementation((node: any) => ({
...node,
id: 'cloned-node'
}))
})
it('should duplicate nodes with connections', async () => {
const newNodeIds = await service.duplicateNodes(
['node-1', 'node-2'],
[100, 50]
)
expect(mockNode.clone).toHaveBeenCalledTimes(2)
expect(mockGraph.add).toHaveBeenCalledTimes(2)
expect(newNodeIds).toHaveLength(2)
})
it('should use default offset when not provided', async () => {
const newNodeIds = await service.duplicateNodes(['node-1'])
expect(newNodeIds).toHaveLength(1)
})
})
})
describe('clipboard operations', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear()
})
describe('copyNodes', () => {
it('should copy nodes to clipboard', async () => {
// Mock node.clone() to return a clonable node
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
mockNode.clone = vi.fn(() => clonedNode)
await service.copyNodes(['node-1'])
const storedData = localStorage.getItem('litegrapheditor_clipboard')
expect(storedData).not.toBeNull()
const clipboard = JSON.parse(storedData!)
expect(clipboard.nodes).toHaveLength(1)
expect(clipboard.nodes[0].id).toBe('node-1')
expect(mockNode.clone).toHaveBeenCalled()
const clipboardData = service.getClipboard()
expect(clipboardData).not.toBeNull()
expect(clipboardData!.nodes).toHaveLength(1)
expect(clipboardData!.isCut).toBe(false)
})
it('should handle empty node list', async () => {
await expect(service.copyNodes([])).rejects.toThrow('No nodes to copy')
})
it('should handle node not found', async () => {
mockGraph.getNodeById.mockReturnValue(null)
await expect(service.copyNodes(['nonexistent'])).rejects.toThrow(
'Node with id nonexistent not found'
)
})
})
describe('cutNodes', () => {
it('should cut nodes to clipboard', async () => {
// Mock node.clone() to return a clonable node
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
mockNode.clone = vi.fn(() => clonedNode)
await service.cutNodes(['node-1'])
const clipboard = service.getClipboard()
expect(clipboard!.isCut).toBe(true)
const storedData = localStorage.getItem('litegrapheditor_clipboard')
const clipboardData = JSON.parse(storedData!)
expect(clipboardData.isCut).toBe(true)
expect(clipboardData.originalIds).toEqual(['node-1'])
})
})
describe('pasteNodes', () => {
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 from clipboard', async () => {
const pastedIds = await service.pasteNodes([200, 300])
expect(mockLiteGraph.createNode).toHaveBeenCalledWith('TestNode')
expect(mockGraph.add).toHaveBeenCalled()
expect(pastedIds).toHaveLength(1)
expect(pastedIds[0]).toBe('new-node-1')
})
it('should handle empty clipboard', async () => {
localStorage.clear()
await expect(service.pasteNodes()).rejects.toThrow('Clipboard is empty')
})
it('should use default offset when position not provided', async () => {
const pastedIds = await service.pasteNodes()
expect(pastedIds).toHaveLength(1)
})
})
describe('clipboard utilities', () => {
it('should check clipboard content correctly', async () => {
expect(service.hasClipboardContent()).toBe(false)
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
mockNode.clone = vi.fn(() => clonedNode)
await service.copyNodes(['node-1'])
expect(service.hasClipboardContent()).toBe(true)
})
it('should clear clipboard', async () => {
// Setup clipboard data
localStorage.setItem(
'litegrapheditor_clipboard',
JSON.stringify({
nodes: [{ id: 'node-1', type: 'TestNode' }],
links: []
})
)
expect(service.hasClipboardContent()).toBe(true)
service.clearClipboard()
expect(service.hasClipboardContent()).toBe(false)
expect(localStorage.getItem('litegrapheditor_clipboard')).toBeNull()
})
})
})
describe('transaction support', () => {
it('should execute transaction successfully', async () => {
let executionCount = 0
const result = await service.transaction(async () => {
executionCount++
await service.addNode({ type: 'TestNode' })
return 'success'
})
expect(result).toBe('success')
expect(executionCount).toBe(1)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should handle transaction errors', async () => {
await expect(
service.transaction(async () => {
throw new Error('Transaction failed')
})
).rejects.toThrow('Transaction failed')
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should support nested transactions', async () => {
await service.transaction(async () => {
await service.transaction(async () => {
await service.addNode({ type: 'NestedNode' })
})
})
expect(mockGraph.beforeChange).toHaveBeenCalledTimes(2)
expect(mockGraph.afterChange).toHaveBeenCalledTimes(2)
})
})
describe('undo/redo operations', () => {
it('should call undo on change tracker', async () => {
await service.undo()
expect(
mockWorkflowStore.activeWorkflow.changeTracker.undo
).toHaveBeenCalled()
})
it('should call redo on change tracker', async () => {
await service.redo()
expect(
mockWorkflowStore.activeWorkflow.changeTracker.redo
).toHaveBeenCalled()
})
it('should handle missing change tracker', async () => {
const localService = new GraphMutationService()
const originalActiveWorkflow = mockWorkflowStore.activeWorkflow
mockWorkflowStore.activeWorkflow = null as any
await expect(localService.undo()).rejects.toThrow(
'No active workflow or change tracker'
)
mockWorkflowStore.activeWorkflow = originalActiveWorkflow
})
})
describe('graph-level operations', () => {
describe('clearGraph', () => {
it('should clear graph successfully', async () => {
await service.clearGraph()
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.clear).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
if (mockWorkflowStore.activeWorkflow?.changeTracker) {
expect(
mockWorkflowStore.activeWorkflow.changeTracker.checkState
).toHaveBeenCalled()
}
})
})
describe('execution control', () => {
it('should bypass node successfully', async () => {
await service.bypassNode('node-1')
expect(mockNode.mode).toBe(4) // LGraphEventMode.BYPASS
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
it('should unbypass node successfully', async () => {
await service.unbypassNode('node-1')
expect(mockNode.mode).toBe(0) // LGraphEventMode.ALWAYS
expect(mockGraph.setDirtyCanvas).toHaveBeenCalledWith(true, false)
})
})
})
describe('validation system', () => {
it('should handle ValidationException correctly', () => {
const errors = [
{ code: 'INVALID_TYPE', message: 'Invalid node type' },
{ code: 'MISSING_PARAM', message: 'Missing parameter' }
]
const exception = new ValidationException(errors)
expect(exception.name).toBe('ValidationException')
expect(exception.errors).toEqual(errors)
expect(exception.message).toBe('Invalid node type, Missing parameter')
})
})
describe('error handling', () => {
it('should maintain graph state on errors', async () => {
mockLiteGraph.createNode.mockImplementation(() => {
throw new Error('Node creation failed')
})
await expect(service.addNode({ type: 'FailNode' })).rejects.toThrow()
expect(mockLiteGraph.createNode).toHaveBeenCalled()
})
it('should handle change tracker unavailable', async () => {
const originalChangeTracker =
mockWorkflowStore.activeWorkflow?.changeTracker
if (mockWorkflowStore.activeWorkflow) {
mockWorkflowStore.activeWorkflow.changeTracker = null as any
}
await service.addNode({ type: 'TestNode' })
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
if (mockWorkflowStore.activeWorkflow) {
mockWorkflowStore.activeWorkflow.changeTracker = originalChangeTracker
}
})
})
describe('subgraph operations', () => {
let mockSubgraphNode: any
let mockSubgraph: any
beforeEach(() => {
mockSubgraph = {
id: 'subgraph-1',
nodes: [],
groups: [],
reroutes: new Map()
}
mockSubgraphNode = {
id: 'subgraph-node-1',
type: 'subgraph',
subgraph: mockSubgraph,
isSubgraphNode: vi.fn(() => true)
}
})
describe('createSubgraph', () => {
it('should create subgraph from selected items', async () => {
const selectedItems = new Set([mockNode])
const expectedResult = {
subgraph: mockSubgraph,
node: mockSubgraphNode
}
mockGraph.convertToSubgraph.mockReturnValue(expectedResult)
const result = await service.createSubgraph({ selectedItems })
expect(mockGraph.convertToSubgraph).toHaveBeenCalledWith(selectedItems)
expect(result).toBe(expectedResult)
})
it('should throw error when no items selected', async () => {
const selectedItems = new Set()
await expect(service.createSubgraph({ selectedItems })).rejects.toThrow(
'Cannot create subgraph: no items selected'
)
})
it('should handle convertToSubgraph failure', async () => {
const selectedItems = new Set([mockNode])
mockGraph.convertToSubgraph.mockReturnValue(null)
await expect(service.createSubgraph({ selectedItems })).rejects.toThrow(
'Failed to create subgraph'
)
})
})
describe('unpackSubgraph', () => {
it('should unpack subgraph node successfully', async () => {
mockGraph.getNodeById.mockReturnValue(mockSubgraphNode)
mockGraph.unpackSubgraph.mockImplementation(() => {})
await service.unpackSubgraph('subgraph-node-1')
expect(mockGraph.unpackSubgraph).toHaveBeenCalledWith(mockSubgraphNode)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('should throw error for non-existent node', async () => {
mockGraph.getNodeById.mockReturnValue(null)
await expect(service.unpackSubgraph('non-existent')).rejects.toThrow(
'Node with id non-existent not found'
)
})
it('should throw error for non-subgraph node', async () => {
const regularNode = {
...mockNode,
isSubgraphNode: undefined,
subgraph: undefined
}
mockGraph.getNodeById.mockReturnValue(regularNode)
await expect(service.unpackSubgraph('node-1')).rejects.toThrow(
'Node is not a subgraph node'
)
})
it('should handle unpack errors gracefully', async () => {
mockGraph.getNodeById.mockReturnValue(mockSubgraphNode)
mockGraph.unpackSubgraph
.mockImplementation(() => {})
.mockImplementation(() => {
throw new Error('Unpack failed')
})
await expect(service.unpackSubgraph('subgraph-node-1')).rejects.toThrow(
'Unpack failed'
)
expect(mockGraph.afterChange).toHaveBeenCalled()
})
})
})
describe('edge cases', () => {
it('should handle nodes without outputs in duplication', async () => {
const nodeWithoutOutputs = { ...mockNode, outputs: null }
mockGraph.getNodeById.mockReturnValue(nodeWithoutOutputs)
const newNodeIds = await service.duplicateNodes(['node-1'])
expect(newNodeIds).toHaveLength(1)
})
it('should handle empty connections in clipboard', async () => {
const nodeWithoutConnections = { ...mockNode, outputs: [], inputs: [] }
mockGraph.getNodeById.mockReturnValue(nodeWithoutConnections)
// Mock node.clone()
const clonedNode = {
serialize: vi.fn(() => ({
id: 'node-1',
type: 'TestNode',
pos: [0, 0]
}))
}
nodeWithoutConnections.clone = vi.fn(() => clonedNode)
await service.copyNodes(['node-1'])
const clipboard = service.getClipboard()
expect(clipboard!.connections).toHaveLength(0)
})
})
})