[1.24.x] Cherry-pick post-1.24.2 fixes including subgraph improvements (#4672)

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
This commit is contained in:
Christian Byrne
2025-08-04 09:49:54 -07:00
committed by GitHub
parent 309a5b8c9a
commit 6eb5a2e010
88 changed files with 6218 additions and 554 deletions

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
// Mock canvas store
let mockGetCanvas = vi.fn()
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: mockGetCanvas
}))
}))
describe('useCanvasTransformSync', () => {
let mockCanvas: { ds: { scale: number; offset: [number, number] } }
let syncFn: ReturnType<typeof vi.fn>
beforeEach(() => {
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
}
}
syncFn = vi.fn()
mockGetCanvas = vi.fn(() => mockCanvas)
vi.clearAllMocks()
})
it('should not call syncFn when transform has not changed', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should call once initially
expect(syncFn).toHaveBeenCalledTimes(1)
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again since transform didn't change
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should call syncFn when scale changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change scale
mockCanvas.ds.scale = 2
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should call syncFn when offset changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change offset
mockCanvas.ds.offset = [10, 20]
// Wait for next RAF cycles
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should stop calling syncFn after stopSync is called', async () => {
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false
})
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
stopSync()
// Change transform after stopping
mockCanvas.ds.scale = 2
// Wait for RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should handle null canvas gracefully', async () => {
mockGetCanvas.mockReturnValue(null)
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should not call syncFn with null canvas
expect(syncFn).not.toHaveBeenCalled()
})
it('should call onStart and onStop callbacks', () => {
const onStart = vi.fn()
const onStop = vi.fn()
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false,
onStart,
onStop
})
startSync()
expect(onStart).toHaveBeenCalledTimes(1)
stopSync()
expect(onStop).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,372 @@
import {
LGraphEventMode,
LGraphNode,
Positionable,
Reroute
} from '@comfyorg/litegraph'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
// Mock the app module
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
selected_nodes: null
}
}
}))
// Mock the litegraph module
vi.mock('@comfyorg/litegraph', () => ({
Reroute: class Reroute {
constructor() {}
},
LGraphEventMode: {
ALWAYS: 0,
NEVER: 2,
BYPASS: 4
}
}))
// Mock Positionable objects
// @ts-expect-error - Mock implementation for testing
class MockNode implements Positionable {
pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [100, 100]
) {
this.pos = pos
this.size = size
}
}
class MockReroute extends Reroute implements Positionable {
// @ts-expect-error - Override for testing
override pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [20, 20]
) {
// @ts-expect-error - Mock constructor
super()
this.pos = pos
this.size = size
}
}
describe('useSelectedLiteGraphItems', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let mockCanvas: any
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
// Mock canvas with selectedItems Set
mockCanvas = {
selectedItems: new Set<Positionable>()
}
// Mock getCanvas to return our mock canvas
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
})
describe('isIgnoredItem', () => {
it('should return true for Reroute instances', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
expect(isIgnoredItem(reroute)).toBe(true)
})
it('should return false for non-Reroute items', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const node = new MockNode()
// @ts-expect-error - Test mock
expect(isIgnoredItem(node)).toBe(false)
})
})
describe('filterSelectableItems', () => {
it('should filter out Reroute items', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode([0, 0])
const node2 = new MockNode([100, 100])
const reroute = new MockReroute([50, 50])
// @ts-expect-error - Test mocks
const items = new Set<Positionable>([node1, node2, reroute])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(2)
// @ts-expect-error - Test mocks
expect(filtered.has(node1)).toBe(true)
// @ts-expect-error - Test mocks
expect(filtered.has(node2)).toBe(true)
expect(filtered.has(reroute)).toBe(false)
})
it('should return empty set when all items are ignored', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const reroute1 = new MockReroute([0, 0])
const reroute2 = new MockReroute([50, 50])
const items = new Set<Positionable>([reroute1, reroute2])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
it('should handle empty set', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const items = new Set<Positionable>()
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
})
describe('methods', () => {
it('getSelectableItems should return only non-ignored items', () => {
const { getSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(node1)
mockCanvas.selectedItems.add(node2)
mockCanvas.selectedItems.add(reroute)
const selectableItems = getSelectableItems()
expect(selectableItems.size).toBe(2)
// @ts-expect-error - Test mock
expect(selectableItems.has(node1)).toBe(true)
// @ts-expect-error - Test mock
expect(selectableItems.has(node2)).toBe(true)
expect(selectableItems.has(reroute)).toBe(false)
})
it('hasSelectableItems should be true when there are selectable items', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
expect(hasSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node)
expect(hasSelectableItems()).toBe(true)
})
it('hasSelectableItems should be false when only ignored items are selected', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(reroute)
expect(hasSelectableItems()).toBe(false)
})
it('hasMultipleSelectableItems should be true when there are 2+ selectable items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node1)
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node2)
expect(hasMultipleSelectableItems()).toBe(true)
})
it('hasMultipleSelectableItems should not count ignored items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
const reroute1 = new MockReroute()
const reroute2 = new MockReroute()
mockCanvas.selectedItems.add(node)
mockCanvas.selectedItems.add(reroute1)
mockCanvas.selectedItems.add(reroute2)
// Even though there are 3 items total, only 1 is selectable
expect(hasMultipleSelectableItems()).toBe(false)
})
})
describe('node-specific methods', () => {
it('getSelectedNodes should return only LGraphNode instances', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
// Mock app.canvas.selected_nodes
app.canvas.selected_nodes = { '0': node1, '1': node2 }
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(2)
expect(selectedNodes[0]).toBe(node1)
expect(selectedNodes[1]).toBe(node2)
})
it('getSelectedNodes should return empty array when no nodes selected', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
// @ts-expect-error - Testing null case
app.canvas.selected_nodes = null
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(0)
})
it('toggleSelectedNodesMode should toggle node modes correctly', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode
const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
app.canvas.selected_nodes = { '0': node1, '1': node2 }
// Toggle to NEVER mode
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// node1 should change from ALWAYS to NEVER
// node2 should change from NEVER to ALWAYS (since it was already NEVER)
expect(node1.mode).toBe(LGraphEventMode.NEVER)
expect(node2.mode).toBe(LGraphEventMode.ALWAYS)
})
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': node }
// Toggle to BYPASS mode (node is already BYPASS)
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
// Should change to ALWAYS
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
})
it('getSelectedNodes should include nodes from subgraphs', () => {
const { getSelectedNodes } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
const selectedNodes = getSelectedNodes()
expect(selectedNodes).toHaveLength(4) // subgraphNode + 2 sub nodes + regularNode
expect(selectedNodes).toContainEqual(subgraphNode)
expect(selectedNodes).toContainEqual(regularNode)
expect(selectedNodes).toContainEqual(subNode1)
expect(selectedNodes).toContainEqual(subNode2)
})
it('toggleSelectedNodesMode should apply unified state to subgraph children', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
const subgraphNode = {
id: 1,
mode: LGraphEventMode.ALWAYS,
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
// Toggle to NEVER mode
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// Selected nodes follow standard toggle logic:
// subgraphNode: ALWAYS -> NEVER (since ALWAYS != NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.NEVER)
// regularNode: BYPASS -> NEVER (since BYPASS != NEVER)
expect(regularNode.mode).toBe(LGraphEventMode.NEVER)
// Subgraph children get unified state (same as their parent):
// Both children should now be NEVER, regardless of their previous states
expect(subNode1.mode).toBe(LGraphEventMode.NEVER) // was ALWAYS, now NEVER
expect(subNode2.mode).toBe(LGraphEventMode.NEVER) // was NEVER, stays NEVER
})
it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => {
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
const subgraphNode = {
id: 1,
mode: LGraphEventMode.NEVER, // Already in NEVER mode
isSubgraphNode: () => true,
subgraph: {
nodes: [subNode1, subNode2]
}
} as unknown as LGraphNode
app.canvas.selected_nodes = { '0': subgraphNode }
// Toggle to NEVER mode (but subgraphNode is already NEVER)
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// Selected subgraph should toggle to ALWAYS (since it was already NEVER)
expect(subgraphNode.mode).toBe(LGraphEventMode.ALWAYS)
// All children should also get ALWAYS (unified with parent's new state)
expect(subNode1.mode).toBe(LGraphEventMode.ALWAYS)
expect(subNode2.mode).toBe(LGraphEventMode.ALWAYS)
})
})
describe('dynamic behavior', () => {
it('methods should reflect changes when selectedItems change', () => {
const {
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
} = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasSelectableItems()).toBe(false)
expect(hasMultipleSelectableItems()).toBe(false)
// Add first node
mockCanvas.selectedItems.add(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
// Add second node
mockCanvas.selectedItems.add(node2)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(true)
expect(getSelectableItems().size).toBe(2)
// Remove a node
mockCanvas.selectedItems.delete(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
})
})
})

View File

@@ -603,7 +603,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.25/Run')
expect(price).toBe('$0.4/Run')
})
it('should return range when widgets are missing', () => {
@@ -771,14 +771,14 @@ describe('useNodePricing', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV1')
expect(widgetNames).toEqual(['num_images'])
expect(widgetNames).toEqual(['num_images', 'turbo'])
})
it('should return correct widget names for IdeogramV2', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV2')
expect(widgetNames).toEqual(['num_images'])
expect(widgetNames).toEqual(['num_images', 'turbo'])
})
it('should return correct widget names for IdeogramV3', () => {
@@ -832,7 +832,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06 x num_images/Run')
expect(price).toBe('$0.02-0.06 x num_images/Run')
})
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
@@ -840,7 +840,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08 x num_images/Run')
expect(price).toBe('$0.05-0.08 x num_images/Run')
})
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
@@ -850,7 +850,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run') // 0.06 * 1
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
})
})
@@ -1036,13 +1036,15 @@ describe('useNodePricing', () => {
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model',
'model_version',
'quad',
'style',
'texture',
'texture_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model',
'model_version',
'quad',
'style',
'texture',
'texture_quality'
])
})
@@ -1075,6 +1077,26 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/second')
})
it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 0 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.00/Run') // 0.05 * 0 = 0
})
it('should handle NaN duration for RunwayImageToVideoNodeGen3a', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 'invalid' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5
})
})
describe('Rodin nodes', () => {
@@ -1091,7 +1113,7 @@ describe('useNodePricing', () => {
const node = createMockNode('Rodin3D_Detail')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
expect(price).toBe('$0.4/Run')
})
it('should return addon price for Rodin3D_Smooth', () => {
@@ -1099,7 +1121,7 @@ describe('useNodePricing', () => {
const node = createMockNode('Rodin3D_Smooth')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.2/Run')
expect(price).toBe('$0.4/Run')
})
})
@@ -1107,44 +1129,53 @@ describe('useNodePricing', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'quad', value: false },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
{ name: 'texture_quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
expect(price).toBe('$0.15/Run') // any style, no quad, no texture
})
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5-20250123' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v1.4-legacy' }
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
{ name: 'texture_quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
expect(price).toBe('$0.10/Run') // none style, no quad, no texture
})
it('should return static price for TripoRefineNode', () => {
@@ -1160,7 +1191,9 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoTextToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
it('should return texture-based pricing for TripoTextureNode', () => {
@@ -1176,25 +1209,85 @@ describe('useNodePricing', () => {
expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run')
})
it('should handle various Tripo model version formats', () => {
it('should handle various Tripo parameter combinations', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test different model version formats
// Test different parameter combinations
const testCases = [
{ model: 'v2.0-20240919', expected: '$0.2/Run' },
{ model: 'v2.5-20250123', expected: '$0.2/Run' },
{ model: 'v1.4', expected: '$0.2/Run' },
{ model: 'unknown-model', expected: '$0.2/Run' }
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
{
quad: false,
style: 'any style',
texture: false,
expected: '$0.15/Run'
},
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
{
quad: true,
style: 'any style',
texture: false,
expected: '$0.25/Run'
}
]
testCases.forEach(({ model, expected }) => {
testCases.forEach(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: model },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture },
{ name: 'texture_quality', value: 'standard' }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return static price for TripoConvertModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoConvertModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})
it('should return static price for TripoRetargetRiggedModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetRiggedModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})
it('should return dynamic pricing for TripoMultiviewToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test basic case - no style, no quad, no texture
const basicNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
{ name: 'texture_quality', value: 'standard' }
])
expect(getNodeDisplayPrice(basicNode)).toBe('$0.20/Run')
// Test high-end case - any style, quad, texture, detailed
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'quad', value: true },
{ name: 'style', value: 'stylized' },
{ name: 'texture', value: true },
{ name: 'texture_quality', value: 'detailed' }
])
expect(getNodeDisplayPrice(highEndNode)).toBe('$0.50/Run')
})
it('should return fallback for TripoMultiviewToModelNode without widgets', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoMultiviewToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
)
})
})
describe('Gemini and OpenAI Chat nodes', () => {
@@ -1204,11 +1297,11 @@ describe('useNodePricing', () => {
const testCases = [
{
model: 'gemini-2.5-pro-preview-05-06',
expected: '$0.0035/$0.0008 per 1K tokens'
expected: '$0.00016/$0.0006 per 1K tokens'
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: '$0.0015/$0.0004 per 1K tokens'
expected: '$0.00125/$0.01 per 1K tokens'
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
]
@@ -1315,7 +1408,7 @@ describe('useNodePricing', () => {
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.25/Run' }, // Falls back to 5 seconds (0 || 5)
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
]
@@ -1359,8 +1452,8 @@ describe('useNodePricing', () => {
const testCases = [
{ nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Detail', expected: '$1.2/Run' },
{ nodeType: 'Rodin3D_Smooth', expected: '$1.2/Run' }
{ nodeType: 'Rodin3D_Detail', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Smooth', expected: '$0.4/Run' }
]
testCases.forEach(({ nodeType, expected }) => {
@@ -1371,24 +1464,42 @@ describe('useNodePricing', () => {
})
describe('Comprehensive Tripo edge case testing', () => {
it('should handle TripoImageToModelNode with various model versions', () => {
it('should handle TripoImageToModelNode with various parameter combinations', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'v1.4-legacy', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.0-20240919', texture: 'detailed', expected: '$0.4/Run' },
{ model: 'v2.5-20250123', texture: 'standard', expected: '$0.3/Run' },
{ model: 'v2.5-20250123', texture: 'detailed', expected: '$0.4/Run' }
{ quad: false, style: 'none', texture: false, expected: '$0.20/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.25/Run' },
{
quad: true,
style: 'any style',
texture: true,
textureQuality: 'detailed',
expected: '$0.50/Run'
},
{
quad: true,
style: 'any style',
texture: false,
textureQuality: 'standard',
expected: '$0.35/Run'
}
]
testCases.forEach(({ model, texture, expected }) => {
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: model },
{ name: 'texture_quality', value: texture }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
testCases.forEach(
({ quad, style, texture, textureQuality, expected }) => {
const widgets = [
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture }
]
if (textureQuality) {
widgets.push({ name: 'texture_quality', value: textureQuality })
}
const node = createMockNode('TripoImageToModelNode', widgets)
expect(getNodeDisplayPrice(node)).toBe(expected)
}
)
})
it('should return correct fallback for TripoImageToModelNode', () => {
@@ -1396,17 +1507,19 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoImageToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3-0.4/Run (varies with model & texture quality)')
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
)
})
it('should handle missing texture quality widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0-20240919' }
])
const node = createMockNode('TripoTextToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run') // Default to standard texture pricing
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
it('should handle missing model version widget', () => {
@@ -1416,7 +1529,9 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-0.3/Run (varies with model & texture quality)')
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
})
})

View File

@@ -0,0 +1,187 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
vi.mock('@/scripts/app', () => ({
app: {
clean: vi.fn(),
canvas: {
subgraph: null
},
graph: {
clear: vi.fn()
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
apiURL: vi.fn(() => 'http://localhost:8188')
}
}))
vi.mock('@/stores/settingStore')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
useFirebaseAuth: vi.fn(() => null)
}))
vi.mock('firebase/auth', () => ({
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn()
}))
vi.mock('@/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({}))
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({}))
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
}))
describe('useCoreCommands', () => {
const mockSubgraph = {
nodes: [
// Mock input node
{
constructor: { comfyClass: 'SubgraphInputNode' },
id: 'input1'
},
// Mock output node
{
constructor: { comfyClass: 'SubgraphOutputNode' },
id: 'output1'
},
// Mock user node
{
constructor: { comfyClass: 'SomeUserNode' },
id: 'user1'
},
// Another mock user node
{
constructor: { comfyClass: 'AnotherUserNode' },
id: 'user2'
}
],
remove: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Set up Pinia
setActivePinia(createPinia())
// Reset app state
app.canvas.subgraph = undefined
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(false) // Skip confirmation dialog
} as any)
// Mock global confirm
global.confirm = vi.fn().mockReturnValue(true)
})
describe('ClearWorkflow command', () => {
it('should clear main graph when not in subgraph', async () => {
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.graph.clear).toHaveBeenCalled()
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should preserve input/output nodes when clearing subgraph', async () => {
// Set up subgraph context
app.canvas.subgraph = mockSubgraph as any
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.graph.clear).not.toHaveBeenCalled()
// Should only remove user nodes, not input/output nodes
expect(mockSubgraph.remove).toHaveBeenCalledTimes(2)
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[0]
) // input1
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[1]
) // output1
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should respect confirmation setting', async () => {
// Mock confirmation required
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(true) // Require confirmation
} as any)
global.confirm = vi.fn().mockReturnValue(false) // User cancels
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
// Should not clear anything when user cancels
expect(app.clean).not.toHaveBeenCalled()
expect(app.graph.clear).not.toHaveBeenCalled()
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,832 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const mockPause = vi.fn()
const mockResume = vi.fn()
vi.mock('@vueuse/core', () => {
const callbacks: Record<string, () => void> = {}
let callbackId = 0
return {
useRafFn: vi.fn((callback, options) => {
const id = callbackId++
callbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
return {
pause: mockPause,
resume: vi.fn(() => {
mockResume()
void Promise.resolve().then(() => callbacks[id]?.())
})
}
}),
useThrottleFn: vi.fn((callback) => {
return (...args: any[]) => {
return callback(...args)
}
})
}
})
let mockCanvas: any
let mockGraph: any
const setupMocks = () => {
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
}
setupMocks()
const defaultCanvasStore = {
canvas: mockCanvas,
getCanvas: () => defaultCanvasStore.canvas
}
const defaultSettingStore = {
get: vi.fn().mockReturnValue(true),
set: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => defaultCanvasStore)
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn().mockReturnValue('http://localhost:8188')
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: mockGraph
}
}
}))
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeSubgraph: null
}))
}))
const { useMinimap } = await import('@/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
let mockCanvas: any
let mockGraph: any
let mockCanvasElement: any
let mockContainerElement: any
let mockContext2D: any
const createAndInitializeMinimap = async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await nextTick()
await flushPromises()
return minimap
}
beforeEach(() => {
vi.clearAllMocks()
mockPause.mockClear()
mockResume.mockClear()
mockContext2D = {
clearRect: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 0
}
mockCanvasElement = {
getContext: vi.fn().mockReturnValue(mockContext2D),
width: 250,
height: 200,
clientWidth: 250,
clientHeight: 200
}
const mockRect = {
left: 100,
top: 100,
width: 250,
height: 200,
right: 350,
bottom: 300,
x: 100,
y: 100
}
mockContainerElement = {
getBoundingClientRect: vi.fn(() => ({ ...mockRect }))
}
const mockNodes = [
{
id: 'node1',
pos: [0, 0],
size: [100, 50],
color: '#ff0000',
constructor: { color: '#666' },
outputs: [
{
links: ['link1']
}
]
},
{
id: 'node2',
pos: [200, 100],
size: [150, 75],
constructor: { color: '#666' },
outputs: []
}
]
mockGraph = {
_nodes: mockNodes,
links: {
link1: {
id: 'link1',
target_id: 'node2'
}
},
getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)),
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null
}
mockCanvas = {
graph: mockGraph,
canvas: {
width: 1000,
height: 800,
clientWidth: 1000,
clientHeight: 800
},
ds: {
scale: 1,
offset: [0, 0]
},
setDirty: vi.fn()
}
defaultCanvasStore.canvas = mockCanvas
defaultSettingStore.get = vi.fn().mockReturnValue(true)
defaultSettingStore.set = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(window, 'devicePixelRatio', {
writable: true,
configurable: true,
value: 1
})
window.addEventListener = vi.fn()
window.removeEventListener = vi.fn()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
expect(minimap.width).toBe(250)
expect(minimap.height).toBe(200)
expect(minimap.visible.value).toBe(true)
expect(minimap.initialized.value).toBe(false)
defaultCanvasStore.canvas = originalCanvas
})
it('should initialize minimap when canvas is available', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.initialized.value).toBe(true)
expect(defaultSettingStore.get).toHaveBeenCalledWith(
'Comfy.Minimap.Visible'
)
expect(api.addEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
if (minimap.visible.value) {
expect(mockResume).toHaveBeenCalled()
}
})
it('should not initialize without canvas and graph', async () => {
const originalCanvas = defaultCanvasStore.canvas
defaultCanvasStore.canvas = null
const minimap = useMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(false)
expect(api.addEventListener).not.toHaveBeenCalled()
defaultCanvasStore.canvas = originalCanvas
})
it('should setup event listeners on graph', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(mockGraph.onNodeAdded).toBeDefined()
expect(mockGraph.onNodeRemoved).toBeDefined()
expect(mockGraph.onConnectionChange).toBeDefined()
})
it('should handle visibility from settings', async () => {
defaultSettingStore.get.mockReturnValue(false)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.visible.value).toBe(false)
expect(mockResume).not.toHaveBeenCalled()
})
})
describe('destroy', () => {
it('should cleanup all resources', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
minimap.destroy()
expect(mockPause).toHaveBeenCalled()
expect(api.removeEventListener).toHaveBeenCalledWith(
'graphChanged',
expect.any(Function)
)
expect(window.removeEventListener).toHaveBeenCalled()
expect(minimap.initialized.value).toBe(false)
})
it('should restore original graph callbacks', async () => {
const originalCallbacks = {
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
}
mockGraph.onNodeAdded = originalCallbacks.onNodeAdded
mockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved
mockGraph.onConnectionChange = originalCallbacks.onConnectionChange
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
minimap.destroy()
expect(mockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded)
expect(mockGraph.onNodeRemoved).toBe(originalCallbacks.onNodeRemoved)
expect(mockGraph.onConnectionChange).toBe(
originalCallbacks.onConnectionChange
)
})
})
describe('toggle', () => {
it('should toggle visibility and save to settings', async () => {
const minimap = useMinimap()
const initialVisibility = minimap.visible.value
await minimap.toggle()
expect(minimap.visible.value).toBe(!initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
!initialVisibility
)
await minimap.toggle()
expect(minimap.visible.value).toBe(initialVisibility)
expect(defaultSettingStore.set).toHaveBeenCalledWith(
'Comfy.Minimap.Visible',
initialVisibility
)
})
})
describe('rendering', () => {
it('should verify context is obtained during render', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext')
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
})
it('should render at least once after initialization', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
const renderingOccurred =
mockContext2D.clearRect.mock.calls.length > 0 ||
mockContext2D.fillRect.mock.calls.length > 0
if (!renderingOccurred) {
console.log('Minimap visible:', minimap.visible.value)
console.log('Minimap initialized:', minimap.initialized.value)
console.log('Canvas exists:', !!defaultCanvasStore.canvas)
console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph)
}
expect(renderingOccurred).toBe(true)
})
it('should not render when context is null', async () => {
mockCanvasElement.getContext = vi.fn().mockReturnValue(null)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(mockContext2D.clearRect).not.toHaveBeenCalled()
mockCanvasElement.getContext = vi.fn().mockReturnValue(mockContext2D)
})
it('should handle empty graph', async () => {
const originalNodes = [...mockGraph._nodes]
mockGraph._nodes = []
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
// With the new reactive system, the minimap may still render some elements
// The key test is that it doesn't crash and properly initializes
expect(mockContext2D.clearRect).toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
})
describe('mouse interactions', () => {
it('should handle mouse down and start dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle mouse move while dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should not move when not dragging', async () => {
const minimap = await createAndInitializeMinimap()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle mouse up to stop dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
minimap.handleMouseUp()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('wheel interactions', () => {
it('should handle wheel zoom in', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle wheel zoom out', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(0.9)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should respect zoom limits', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.ds.scale = 0.1
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockCanvas.ds.scale).toBe(0.1)
})
it('should update container rect if needed', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
})
})
describe('viewport updates', () => {
it('should update viewport transform correctly', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await nextTick()
const viewportStyles = minimap.viewportStyles.value
expect(viewportStyles).toBeDefined()
expect(viewportStyles.transform).toMatch(
/translate\(-?\d+(\.\d+)?px, -?\d+(\.\d+)?px\)/
)
expect(viewportStyles.width).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.height).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.border).toBe('2px solid #FFF')
})
it('should handle canvas dimension updates', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.canvas.clientWidth = 1200
mockCanvas.canvas.clientHeight = 900
const resizeHandler = (window.addEventListener as any).mock.calls.find(
(call: any) => call[0] === 'resize'
)?.[1]
if (resizeHandler) {
resizeHandler()
}
await nextTick()
expect(minimap.viewportStyles.value).toBeDefined()
})
})
describe('graph change handling', () => {
it('should handle node addition', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const newNode = {
id: 'node3',
pos: [300, 200],
size: [100, 100],
constructor: { color: '#666' }
}
mockGraph._nodes.push(newNode)
if (mockGraph.onNodeAdded) {
mockGraph.onNodeAdded(newNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle node removal', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const removedNode = mockGraph._nodes[0]
mockGraph._nodes.splice(0, 1)
if (mockGraph.onNodeRemoved) {
mockGraph.onNodeRemoved(removedNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle connection changes', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
if (mockGraph.onConnectionChange) {
mockGraph.onConnectionChange(mockGraph._nodes[0])
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
})
describe('container styles', () => {
it('should provide correct container styles', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.borderRadius).toBe('8px')
})
})
describe('edge cases', () => {
it('should handle missing node outputs', async () => {
mockGraph._nodes[0].outputs = null
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle invalid link references', async () => {
mockGraph.links.link1.target_id = 'invalid-node'
mockGraph.getNodeById.mockReturnValue(null)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle high DPI displays', async () => {
window.devicePixelRatio = 2
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.initialized.value).toBe(true)
})
it('should handle nodes without color', async () => {
mockGraph._nodes[0].color = undefined
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const renderMinimap = (minimap as any).renderMinimap
if (renderMinimap) {
renderMinimap()
}
expect(mockContext2D.fillRect).toHaveBeenCalled()
expect(mockContext2D.fillStyle).toBeDefined()
})
})
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = { value: 'test-ref' }
minimap.setMinimapRef(ref)
expect(() => minimap.setMinimapRef(ref)).not.toThrow()
})
})
})

View File

@@ -7,6 +7,7 @@ import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
@@ -38,9 +39,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
const mockCollectAllNodes = vi.mocked(collectAllNodes)
describe('useMissingNodes', () => {
const mockWorkflowPacks = [
@@ -95,6 +101,9 @@ describe('useMissingNodes', () => {
// Reset app.graph.nodes
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = []
// Default mock for collectAllNodes - returns empty array
mockCollectAllNodes.mockReturnValue([])
})
describe('core filtering logic', () => {
@@ -286,14 +295,9 @@ describe('useMissingNodes', () => {
it('identifies missing core nodes not in nodeDefStore', () => {
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
const registeredNode = createMockNode(
'RegisteredNode',
'comfy-core',
'1.0.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
// Mock collectAllNodes to return only the filtered nodes (missing core nodes)
mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
@@ -316,8 +320,8 @@ describe('useMissingNodes', () => {
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [node120, node130, nodeNoVer]
// Mock collectAllNodes to return these nodes
mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
@@ -334,11 +338,9 @@ describe('useMissingNodes', () => {
it('ignores non-core nodes', () => {
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
const noPackNode = createMockNode('NoPackNode')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode, customNode, noPackNode]
// Mock collectAllNodes to return only the filtered nodes (core nodes only)
mockCollectAllNodes.mockReturnValue([coreNode])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
@@ -353,19 +355,8 @@ describe('useMissingNodes', () => {
})
it('returns empty object when no core nodes are missing', () => {
const registeredNode1 = createMockNode(
'RegisteredNode1',
'comfy-core',
'1.0.0'
)
const registeredNode2 = createMockNode(
'RegisteredNode2',
'comfy-core',
'1.1.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [registeredNode1, registeredNode2]
// Mock collectAllNodes to return empty array (no missing nodes after filtering)
mockCollectAllNodes.mockReturnValue([])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
@@ -382,4 +373,200 @@ describe('useMissingNodes', () => {
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
})
describe('subgraph support', () => {
const createMockNode = (
type: string,
packId?: string,
version?: string
): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
it('detects missing core nodes from subgraphs via collectAllNodes', () => {
const mainNode = createMockNode('MainNode', 'comfy-core', '1.0.0')
const subgraphNode1 = createMockNode(
'SubgraphNode1',
'comfy-core',
'1.0.0'
)
const subgraphNode2 = createMockNode(
'SubgraphNode2',
'comfy-core',
'1.1.0'
)
// Mock collectAllNodes to return all nodes including subgraph nodes
mockCollectAllNodes.mockReturnValue([
mainNode,
subgraphNode1,
subgraphNode2
])
// Mock none of the nodes as registered
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
// Should detect all 3 nodes as missing
expect(Object.keys(missingCoreNodes.value)).toHaveLength(2) // 2 versions: 1.0.0, 1.1.0
expect(missingCoreNodes.value['1.0.0']).toHaveLength(2) // MainNode + SubgraphNode1
expect(missingCoreNodes.value['1.1.0']).toHaveLength(1) // SubgraphNode2
})
it('calls collectAllNodes with the app graph and filter function', () => {
const mockGraph = { nodes: [], subgraphs: new Map() }
// @ts-expect-error - Mocking app.graph for testing
app.graph = mockGraph
const { missingCoreNodes } = useMissingNodes()
// Access the computed to trigger the function
void missingCoreNodes.value
expect(mockCollectAllNodes).toHaveBeenCalledWith(
mockGraph,
expect.any(Function)
)
})
it('handles collectAllNodes returning empty array', () => {
mockCollectAllNodes.mockReturnValue([])
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
it('filter function correctly identifies missing core nodes', () => {
const mockGraph = { nodes: [], subgraphs: new Map() }
// @ts-expect-error - Mocking app.graph for testing
app.graph = mockGraph
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
RegisteredCore: { name: 'RegisteredCore' }
}
})
let capturedFilterFunction: ((node: LGraphNode) => boolean) | undefined
mockCollectAllNodes.mockImplementation((_graph, filter) => {
capturedFilterFunction = filter
return []
})
const { missingCoreNodes } = useMissingNodes()
void missingCoreNodes.value
expect(capturedFilterFunction).toBeDefined()
if (capturedFilterFunction) {
const missingCoreNode = createMockNode(
'MissingCore',
'comfy-core',
'1.0.0'
)
const registeredCoreNode = createMockNode(
'RegisteredCore',
'comfy-core',
'1.0.0'
)
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
const nodeWithoutPack = createMockNode('NodeWithoutPack')
expect(capturedFilterFunction(missingCoreNode)).toBe(true)
expect(capturedFilterFunction(registeredCoreNode)).toBe(false)
expect(capturedFilterFunction(customNode)).toBe(false)
expect(capturedFilterFunction(nodeWithoutPack)).toBe(false)
}
})
it('integrates with collectAllNodes to find nodes from subgraphs', () => {
mockCollectAllNodes.mockImplementation((graph, filter) => {
const allNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
for (const subNode of node.subgraph.nodes) {
if (!filter || filter(subNode)) {
allNodes.push(subNode)
}
}
}
if (!filter || filter(node)) {
allNodes.push(node)
}
}
return allNodes
})
const mainMissingNode = createMockNode(
'MainMissing',
'comfy-core',
'1.0.0'
)
const subgraphMissingNode = createMockNode(
'SubgraphMissing',
'comfy-core',
'1.1.0'
)
const subgraphRegisteredNode = createMockNode(
'SubgraphRegistered',
'comfy-core',
'1.0.0'
)
const mockSubgraph = {
nodes: [subgraphMissingNode, subgraphRegisteredNode]
}
const mockSubgraphNode = {
isSubgraphNode: () => true,
subgraph: mockSubgraph,
type: 'SubgraphContainer',
properties: { cnr_id: 'custom-pack' }
}
const mockMainGraph = {
nodes: [mainMissingNode, mockSubgraphNode]
}
// @ts-expect-error - Mocking app.graph for testing
app.graph = mockMainGraph
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
SubgraphRegistered: { name: 'SubgraphRegistered' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(2)
expect(missingCoreNodes.value['1.0.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.1.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.0.0'][0].type).toBe('MainMissing')
expect(missingCoreNodes.value['1.1.0'][0].type).toBe('SubgraphMissing')
})
})
})

View File

@@ -0,0 +1,184 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: {
scale: 1,
offset: [0, 0]
}
},
setDirty: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
findSubgraphPathById: vi.fn()
}))
describe('useSubgraphNavigationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should not clear navigation stack when workflow internal state changes', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock a workflow
const mockWorkflow = {
path: 'test-workflow.json',
filename: 'test-workflow.json',
changeTracker: null
} as ComfyWorkflow
// Set the active workflow (cast to bypass TypeScript check in test)
workflowStore.activeWorkflow = mockWorkflow as any
// Simulate being in a subgraph by restoring state
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
expect(navigationStore.exportState()).toHaveLength(2)
// Simulate a change to the workflow's internal state
// (e.g., changeTracker.activeState being reassigned)
mockWorkflow.changeTracker = { activeState: {} } as any
// The navigation stack should NOT be cleared because the path hasn't changed
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
})
it('should preserve navigation stack per workflow', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock first workflow
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json',
changeTracker: {
restore: vi.fn(),
store: vi.fn()
}
} as unknown as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow = workflow1 as any
// Simulate the restore process that happens when loading a workflow
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// Verify navigation was set
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
// Switch to a different workflow with no subgraph state (root level)
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json',
changeTracker: {
restore: vi.fn(),
store: vi.fn()
}
} as unknown as ComfyWorkflow
workflowStore.activeWorkflow = workflow2 as any
// Simulate the restore process for workflow2
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState([])
// The navigation stack should be empty for workflow2 (at root level)
expect(navigationStore.exportState()).toHaveLength(0)
// Switch back to workflow1
workflowStore.activeWorkflow = workflow1 as any
// Simulate the restore process for workflow1 again
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// The navigation stack should be restored for workflow1
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
})
it('should clear navigation when activeSubgraph becomes undefined', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Create mock subgraph and graph structure
const mockSubgraph = {
id: 'subgraph-1',
rootGraph: (app as any).graph,
_nodes: [],
nodes: []
}
// Add the subgraph to the graph's subgraphs map
;(app as any).graph.subgraphs.set('subgraph-1', mockSubgraph)
// First set an active workflow
const mockWorkflow = {
path: 'test-workflow.json',
filename: 'test-workflow.json'
} as ComfyWorkflow
workflowStore.activeWorkflow = mockWorkflow as any
// Mock findSubgraphPathById to return the correct path
vi.mocked(findSubgraphPathById).mockReturnValue(['subgraph-1'])
// Set canvas.subgraph and trigger update to set activeSubgraph
;(app as any).canvas.subgraph = mockSubgraph
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change
await nextTick()
// Verify navigation was set by the watcher
expect(navigationStore.exportState()).toHaveLength(1)
expect(navigationStore.exportState()).toEqual(['subgraph-1'])
// Clear canvas.subgraph and trigger update (simulating navigating back to root)
;(app as any).canvas.subgraph = null
workflowStore.updateActiveGraph()
// Wait for Vue's reactivity to process the change
await nextTick()
// Stack should be cleared when activeSubgraph becomes undefined
expect(navigationStore.exportState()).toHaveLength(0)
})
})

View File

@@ -0,0 +1,254 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
ds: {
scale: 1,
offset: [0, 0],
state: {
scale: 1,
offset: [0, 0]
}
},
setDirty: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: mockCanvas
}
}
})
// Mock canvasStore
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})
}))
// Get reference to mock canvas
const mockCanvas = app.canvas as any
describe('useSubgraphNavigationStore - Viewport Persistence', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset canvas state
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.state.scale = 1
mockCanvas.ds.state.offset = [0, 0]
mockCanvas.setDirty.mockClear()
})
describe('saveViewport', () => {
it('should save viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 200]
// Save viewport for root
navigationStore.saveViewport('root')
// Check it was saved
const saved = navigationStore.viewportCache.get('root')
expect(saved).toEqual({
scale: 2,
offset: [100, 200]
})
})
it('should save viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Set viewport state
mockCanvas.ds.state.scale = 1.5
mockCanvas.ds.state.offset = [50, 75]
// Save viewport for subgraph
navigationStore.saveViewport('subgraph-123')
// Check it was saved
const saved = navigationStore.viewportCache.get('subgraph-123')
expect(saved).toEqual({
scale: 1.5,
offset: [50, 75]
})
})
it('should save viewport for current context when no ID provided', () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock being in a subgraph
const mockSubgraph = { id: 'sub-456' }
workflowStore.activeSubgraph = mockSubgraph as any
// Set viewport state
mockCanvas.ds.state.scale = 3
mockCanvas.ds.state.offset = [10, 20]
// Save viewport without ID (should default to root since activeSubgraph is not tracked by navigation store)
navigationStore.saveViewport('sub-456')
// Should save for the specified subgraph
const saved = navigationStore.viewportCache.get('sub-456')
expect(saved).toEqual({
scale: 3,
offset: [10, 20]
})
})
})
describe('restoreViewport', () => {
it('should restore viewport state for root graph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('root', {
scale: 2.5,
offset: [150, 250]
})
// Restore it
navigationStore.restoreViewport('root')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(2.5)
expect(mockCanvas.ds.offset).toEqual([150, 250])
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should restore viewport state for subgraph', () => {
const navigationStore = useSubgraphNavigationStore()
// Save a viewport state
navigationStore.viewportCache.set('sub-789', {
scale: 0.75,
offset: [-50, -100]
})
// Restore it
navigationStore.restoreViewport('sub-789')
// Check canvas was updated
expect(mockCanvas.ds.scale).toBe(0.75)
expect(mockCanvas.ds.offset).toEqual([-50, -100])
})
it('should do nothing if no saved viewport exists', () => {
const navigationStore = useSubgraphNavigationStore()
// Reset canvas
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.setDirty.mockClear()
// Try to restore non-existent viewport
navigationStore.restoreViewport('non-existent')
// Canvas should not change
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('navigation integration', () => {
it('should save and restore viewport when navigating between subgraphs', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Create mock subgraph with both _nodes and nodes properties
const mockRootGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
const subgraph1 = {
id: 'sub1',
rootGraph: mockRootGraph,
_nodes: [],
nodes: []
}
// Start at root with custom viewport
mockCanvas.ds.state.scale = 2
mockCanvas.ds.state.offset = [100, 100]
// Navigate to subgraph
workflowStore.activeSubgraph = subgraph1 as any
await nextTick()
// Root viewport should have been saved automatically
const rootViewport = navigationStore.viewportCache.get('root')
expect(rootViewport).toBeDefined()
expect(rootViewport?.scale).toBe(2)
expect(rootViewport?.offset).toEqual([100, 100])
// Change viewport in subgraph
mockCanvas.ds.state.scale = 0.5
mockCanvas.ds.state.offset = [-50, -50]
// Navigate back to root
workflowStore.activeSubgraph = undefined
await nextTick()
// Subgraph viewport should have been saved automatically
const sub1Viewport = navigationStore.viewportCache.get('sub1')
expect(sub1Viewport).toBeDefined()
expect(sub1Viewport?.scale).toBe(0.5)
expect(sub1Viewport?.offset).toEqual([-50, -50])
// Root viewport should be restored automatically
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 100])
})
it('should preserve viewport cache when switching workflows', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Add some viewport states
navigationStore.viewportCache.set('root', { scale: 2, offset: [0, 0] })
navigationStore.viewportCache.set('sub1', {
scale: 1.5,
offset: [10, 10]
})
expect(navigationStore.viewportCache.size).toBe(2)
// Switch workflows
const workflow1 = { path: 'workflow1.json' } as ComfyWorkflow
const workflow2 = { path: 'workflow2.json' } as ComfyWorkflow
workflowStore.activeWorkflow = workflow1 as any
await nextTick()
workflowStore.activeWorkflow = workflow2 as any
await nextTick()
// Cache should be preserved (LRU will manage memory)
expect(navigationStore.viewportCache.size).toBe(2)
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
})
})

View File

@@ -597,7 +597,9 @@ describe('useWorkflowStore', () => {
// Setup mock graph structure with subgraphs
const mockSubgraph = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
_nodes: []
rootGraph: null as any,
_nodes: [],
nodes: []
}
const mockNode = {
@@ -608,6 +610,7 @@ describe('useWorkflowStore', () => {
const mockRootGraph = {
_nodes: [mockNode],
nodes: [mockNode],
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
getNodeById: (id: string | number) => {
if (String(id) === '123') return mockNode
@@ -615,6 +618,8 @@ describe('useWorkflowStore', () => {
}
}
mockSubgraph.rootGraph = mockRootGraph as any
vi.mocked(comfyApp).graph = mockRootGraph as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
store.activeSubgraph = mockSubgraph as any

View File

@@ -3,13 +3,22 @@ import { describe, expect, it, vi } from 'vitest'
import {
collectAllNodes,
collectFromNodes,
findNodeInHierarchy,
findSubgraphByUuid,
forEachNode,
forEachSubgraphNode,
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes,
getLocalNodeIdFromExecutionId,
getNodeByExecutionId,
getNodeByLocatorId,
getRootGraph,
getSubgraphPathFromExecutionId,
mapAllNodes,
mapSubgraphNodes,
parseExecutionId,
traverseNodesDepthFirst,
traverseSubgraphPath,
triggerCallbackOnAllNodes,
visitGraphNodes
@@ -283,6 +292,141 @@ describe('graphTraversalUtil', () => {
})
})
describe('mapAllNodes', () => {
it('should map over all nodes in a flat graph', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
const graph = createMockGraph(nodes)
const results = mapAllNodes(graph, (node) => node.id)
expect(results).toEqual([1, 2, 3])
})
it('should map over nodes in subgraphs', () => {
const subNode = createMockNode(100)
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode(1),
createMockNode(2, { isSubgraph: true, subgraph })
]
const graph = createMockGraph(nodes)
const results = mapAllNodes(graph, (node) => node.id)
expect(results).toHaveLength(3)
expect(results).toContain(100)
})
it('should exclude undefined results', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
const graph = createMockGraph(nodes)
const results = mapAllNodes(graph, (node) => {
return Number(node.id) > 1 ? node.id : undefined
})
expect(results).toEqual([2, 3])
})
it('should handle deeply nested structures', () => {
const deepNode = createMockNode(300)
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
const midNode = createMockNode(200)
const midSubgraphNode = createMockNode(201, {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [
midNode,
midSubgraphNode
])
const nodes = [
createMockNode(1),
createMockNode(2, { isSubgraph: true, subgraph: midSubgraph })
]
const graph = createMockGraph(nodes)
const results = mapAllNodes(graph, (node) => `node-${node.id}`)
expect(results).toHaveLength(5)
expect(results).toContain('node-300')
})
})
describe('forEachNode', () => {
it('should execute function on all nodes in a flat graph', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
const graph = createMockGraph(nodes)
const visited: number[] = []
forEachNode(graph, (node) => {
visited.push(node.id as number)
})
expect(visited).toHaveLength(3)
expect(visited).toContain(1)
expect(visited).toContain(2)
expect(visited).toContain(3)
})
it('should execute function on nodes in subgraphs', () => {
const subNode = createMockNode(100)
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode(1),
createMockNode(2, { isSubgraph: true, subgraph })
]
const graph = createMockGraph(nodes)
const visited: number[] = []
forEachNode(graph, (node) => {
visited.push(node.id as number)
})
expect(visited).toHaveLength(3)
expect(visited).toContain(100)
})
it('should allow node mutations', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
const graph = createMockGraph(nodes)
// Add a title property to each node
forEachNode(graph, (node) => {
;(node as any).title = `Node ${node.id}`
})
expect(nodes[0]).toHaveProperty('title', 'Node 1')
expect(nodes[1]).toHaveProperty('title', 'Node 2')
expect(nodes[2]).toHaveProperty('title', 'Node 3')
})
it('should handle node type matching for subgraph references', () => {
const subgraphId = 'my-subgraph-123'
const nodes = [
createMockNode(1),
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
createMockNode(3),
{ ...createMockNode(4), type: subgraphId } as LGraphNode
]
const graph = createMockGraph(nodes)
const matchingNodes: number[] = []
forEachNode(graph, (node) => {
if (node.type === subgraphId) {
matchingNodes.push(node.id as number)
}
})
expect(matchingNodes).toEqual([2, 4])
})
})
describe('findNodeInHierarchy', () => {
it('should find node in root graph', () => {
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
@@ -482,5 +626,554 @@ describe('graphTraversalUtil', () => {
expect(found).toBeNull()
})
})
describe('getRootGraph', () => {
it('should return the same graph if it is already root', () => {
const graph = createMockGraph([])
expect(getRootGraph(graph)).toBe(graph)
})
it('should return root graph from subgraph', () => {
const rootGraph = createMockGraph([])
const subgraph = createMockSubgraph('sub-uuid', [])
;(subgraph as any).rootGraph = rootGraph
expect(getRootGraph(subgraph)).toBe(rootGraph)
})
it('should return root graph from deeply nested subgraph', () => {
const rootGraph = createMockGraph([])
const midSubgraph = createMockSubgraph('mid-uuid', [])
const deepSubgraph = createMockSubgraph('deep-uuid', [])
;(midSubgraph as any).rootGraph = rootGraph
;(deepSubgraph as any).rootGraph = midSubgraph
expect(getRootGraph(deepSubgraph)).toBe(rootGraph)
})
})
describe('forEachSubgraphNode', () => {
it('should apply function to all nodes matching subgraph type', () => {
const subgraphId = 'my-subgraph-123'
const nodes = [
createMockNode(1),
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
createMockNode(3),
{ ...createMockNode(4), type: subgraphId } as LGraphNode
]
const graph = createMockGraph(nodes)
const matchingIds: number[] = []
forEachSubgraphNode(graph, subgraphId, (node) => {
matchingIds.push(node.id as number)
})
expect(matchingIds).toEqual([2, 4])
})
it('should work with root graph directly', () => {
const subgraphId = 'target-subgraph'
const rootNodes = [
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
createMockNode(2),
{ ...createMockNode(3), type: subgraphId } as LGraphNode
]
const rootGraph = createMockGraph(rootNodes)
const matchingIds: number[] = []
forEachSubgraphNode(rootGraph, subgraphId, (node) => {
matchingIds.push(node.id as number)
})
expect(matchingIds).toEqual([1, 3])
})
it('should handle null inputs gracefully', () => {
const fn = vi.fn()
forEachSubgraphNode(null, 'id', fn)
forEachSubgraphNode(createMockGraph([]), null, fn)
forEachSubgraphNode(null, null, fn)
expect(fn).not.toHaveBeenCalled()
})
it('should allow node mutations like title updates', () => {
const subgraphId = 'my-subgraph'
const nodes = [
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
createMockNode(3)
]
const graph = createMockGraph(nodes)
forEachSubgraphNode(graph, subgraphId, (node) => {
;(node as any).title = 'Updated Title'
})
expect(nodes[0]).toHaveProperty('title', 'Updated Title')
expect(nodes[1]).toHaveProperty('title', 'Updated Title')
expect(nodes[2]).not.toHaveProperty('title', 'Updated Title')
})
})
describe('mapSubgraphNodes', () => {
it('should map over nodes matching subgraph type', () => {
const subgraphId = 'my-subgraph-123'
const nodes = [
createMockNode(1),
{ ...createMockNode(2), type: subgraphId } as LGraphNode,
createMockNode(3),
{ ...createMockNode(4), type: subgraphId } as LGraphNode
]
const graph = createMockGraph(nodes)
const results = mapSubgraphNodes(graph, subgraphId, (node) => node.id)
expect(results).toEqual([2, 4])
})
it('should return empty array for null inputs', () => {
expect(mapSubgraphNodes(null, 'id', (n) => n.id)).toEqual([])
expect(
mapSubgraphNodes(createMockGraph([]), null, (n) => n.id)
).toEqual([])
})
it('should work with complex transformations', () => {
const subgraphId = 'target'
const nodes = [
{ ...createMockNode(1), type: subgraphId } as LGraphNode,
{ ...createMockNode(2), type: 'other' } as LGraphNode,
{ ...createMockNode(3), type: subgraphId } as LGraphNode
]
const graph = createMockGraph(nodes)
const results = mapSubgraphNodes(graph, subgraphId, (node) => ({
id: node.id,
isTarget: true
}))
expect(results).toEqual([
{ id: 1, isTarget: true },
{ id: 3, isTarget: true }
])
})
})
describe('getAllNonIoNodesInSubgraph', () => {
it('should filter out SubgraphInputNode and SubgraphOutputNode', () => {
const nodes = [
{ id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } },
{ id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } },
{ id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } },
{ id: 'user2', constructor: { comfyClass: 'KSampler' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(2)
expect(nonIoNodes.map((n) => n.id)).toEqual(['user1', 'user2'])
})
it('should handle subgraph with only IO nodes', () => {
const nodes = [
{ id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } },
{ id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(0)
})
it('should handle subgraph with only user nodes', () => {
const nodes = [
{ id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } },
{ id: 'user2', constructor: { comfyClass: 'KSampler' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(2)
expect(nonIoNodes).toEqual(nodes)
})
it('should handle empty subgraph', () => {
const subgraph = createMockSubgraph('sub-uuid', [])
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(0)
})
})
describe('traverseNodesDepthFirst', () => {
it('should traverse nodes in depth-first order', () => {
const visited: string[] = []
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
traverseNodesDepthFirst(nodes, {
visitor: (node, context) => {
visited.push(`${node.id}:${context}`)
return `${context}-${node.id}`
},
initialContext: 'root'
})
expect(visited).toEqual(['3:root', '2:root', '1:root']) // DFS processes in LIFO order
})
it('should traverse into subgraphs when expandSubgraphs is true', () => {
const visited: string[] = []
const subNode = createMockNode('sub1')
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
traverseNodesDepthFirst(nodes, {
visitor: (node, depth: number) => {
visited.push(`${node.id}:${depth}`)
return depth + 1
},
initialContext: 0
})
expect(visited).toEqual(['2:0', 'sub1:1', '1:0']) // DFS: last node first, then its children
})
it('should skip subgraphs when expandSubgraphs is false', () => {
const visited: string[] = []
const subNode = createMockNode('sub1')
const subgraph = createMockSubgraph('sub-uuid', [subNode])
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
traverseNodesDepthFirst(nodes, {
visitor: (node, context) => {
visited.push(String(node.id))
return context
},
initialContext: null,
expandSubgraphs: false
})
expect(visited).toEqual(['2', '1']) // DFS processes in LIFO order
expect(visited).not.toContain('sub1')
})
it('should handle deeply nested subgraphs', () => {
const visited: string[] = []
const deepNode = createMockNode('300')
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
const midNode = createMockNode('200', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
const topNode = createMockNode('100', {
isSubgraph: true,
subgraph: midSubgraph
})
traverseNodesDepthFirst([topNode], {
visitor: (node, path: string) => {
visited.push(`${node.id}:${path}`)
return path ? `${path}/${node.id}` : String(node.id)
},
initialContext: ''
})
expect(visited).toEqual(['100:', '200:100', '300:100/200'])
})
})
describe('collectFromNodes', () => {
it('should collect data from all nodes', () => {
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
const results = collectFromNodes(nodes, {
collector: (node) => `node-${node.id}`,
contextBuilder: (_node, context) => context,
initialContext: null
})
expect(results).toEqual(['node-3', 'node-2', 'node-1']) // DFS processes in LIFO order
})
it('should filter out null results', () => {
const nodes = [
createMockNode('1'),
createMockNode('2'),
createMockNode('3')
]
const results = collectFromNodes(nodes, {
collector: (node) => (Number(node.id) > 1 ? `node-${node.id}` : null),
contextBuilder: (_node, context) => context,
initialContext: null
})
expect(results).toEqual(['node-3', 'node-2']) // DFS processes in LIFO order, node-1 filtered out
})
it('should collect from subgraphs with context', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const results = collectFromNodes(nodes, {
collector: (node, prefix: string) => `${prefix}${node.id}`,
contextBuilder: (node, prefix: string) => `${prefix}${node.id}-`,
initialContext: 'node-',
expandSubgraphs: true
})
expect(results).toEqual([
'node-2',
'node-2-10', // Actually processes in original order within subgraph
'node-2-11',
'node-1'
])
})
it('should not expand subgraphs when expandSubgraphs is false', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const results = collectFromNodes(nodes, {
collector: (node) => String(node.id),
contextBuilder: (_node, context) => context,
initialContext: null,
expandSubgraphs: false
})
expect(results).toEqual(['2', '1']) // DFS processes in LIFO order
})
})
describe('getExecutionIdsForSelectedNodes', () => {
it('should return simple IDs for top-level nodes', () => {
const nodes = [
createMockNode('123'),
createMockNode('456'),
createMockNode('789')
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
})
it('should expand subgraph nodes to include all children', () => {
const subNodes = [createMockNode('10'), createMockNode('11')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph })
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
})
it('should handle deeply nested subgraphs correctly', () => {
const deepNodes = [createMockNode('30'), createMockNode('31')]
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
const midNode = createMockNode('20', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
const topNode = createMockNode('10', {
isSubgraph: true,
subgraph: midSubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
expect(executionIds).toEqual(['10', '10:20', '10:20:30', '10:20:31'])
})
it('should handle mixed selection of regular and subgraph nodes', () => {
const subNodes = [createMockNode('100'), createMockNode('101')]
const subgraph = createMockSubgraph('sub-uuid', subNodes)
const nodes = [
createMockNode('1'),
createMockNode('2', { isSubgraph: true, subgraph }),
createMockNode('3')
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual([
'3',
'2',
'2:100', // Subgraph children in original order
'2:101',
'1'
])
})
it('should handle empty selection', () => {
const executionIds = getExecutionIdsForSelectedNodes([])
expect(executionIds).toEqual([])
})
it('should handle subgraph with no children', () => {
const emptySubgraph = createMockSubgraph('empty-uuid', [])
const node = createMockNode('1', {
isSubgraph: true,
subgraph: emptySubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([node])
expect(executionIds).toEqual(['1'])
})
it('should handle nodes with very long execution paths', () => {
// Create a chain of 10 nested subgraphs
let currentSubgraph = createMockSubgraph('deep-10', [
createMockNode('10')
])
for (let i = 9; i >= 1; i--) {
const node = createMockNode(`${i}0`, {
isSubgraph: true,
subgraph: currentSubgraph
})
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
}
const topNode = createMockNode('1', {
isSubgraph: true,
subgraph: currentSubgraph
})
const executionIds = getExecutionIdsForSelectedNodes([topNode])
expect(executionIds).toHaveLength(11)
expect(executionIds[0]).toBe('1')
expect(executionIds[10]).toBe('1:10:20:30:40:50:60:70:80:90:10')
})
it('should handle duplicate node IDs in different subgraphs', () => {
// Create two subgraphs with nodes that have the same IDs
const subgraph1 = createMockSubgraph('sub1-uuid', [
createMockNode('100'),
createMockNode('101')
])
const subgraph2 = createMockSubgraph('sub2-uuid', [
createMockNode('100'), // Same ID as in subgraph1
createMockNode('101') // Same ID as in subgraph1
])
const nodes = [
createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
]
const executionIds = getExecutionIdsForSelectedNodes(nodes)
expect(executionIds).toEqual([
'2',
'2:100',
'2:101',
'1',
'1:100',
'1:101'
])
})
it('should handle subgraphs with many children efficiently', () => {
// Create a subgraph with 100 nodes
const manyNodes = []
for (let i = 0; i < 100; i++) {
manyNodes.push(createMockNode(`child-${i}`))
}
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
const node = createMockNode('parent', {
isSubgraph: true,
subgraph: bigSubgraph
})
const start = performance.now()
const executionIds = getExecutionIdsForSelectedNodes([node])
const duration = performance.now() - start
expect(executionIds).toHaveLength(101)
expect(executionIds[0]).toBe('parent')
expect(executionIds[100]).toBe('parent:child-99') // Due to backward iteration optimization
// Should complete quickly even with many nodes
expect(duration).toBeLessThan(50)
})
it('should handle selection of nodes at different depths', () => {
// Create a complex nested structure
const deepNode = createMockNode('300')
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
const midNode1 = createMockNode('201')
const midNode2 = createMockNode('202', {
isSubgraph: true,
subgraph: deepSubgraph
})
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
const topNode = createMockNode('100', {
isSubgraph: true,
subgraph: midSubgraph
})
// Select nodes at different nesting levels
const selectedNodes = [
createMockNode('1'), // Root level
topNode, // Contains subgraph
createMockNode('2') // Root level
]
const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
expect(executionIds).toContain('1')
expect(executionIds).toContain('2')
expect(executionIds).toContain('100')
expect(executionIds).toContain('100:201')
expect(executionIds).toContain('100:202')
expect(executionIds).toContain('100:202:300')
})
})
})
})

View File

@@ -0,0 +1,114 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it } from 'vitest'
import { filterOutputNodes, isOutputNode } from '@/utils/nodeFilterUtil'
describe('nodeFilterUtil', () => {
// Helper to create a mock node
const createMockNode = (
id: number,
isOutputNode: boolean = false
): LGraphNode => {
// Create a custom class with the nodeData static property
class MockNode extends LGraphNode {
static nodeData = isOutputNode ? { output_node: true } : {}
}
const node = new MockNode('')
node.id = id
return node
}
describe('filterOutputNodes', () => {
it('should return empty array when given empty array', () => {
const result = filterOutputNodes([])
expect(result).toEqual([])
})
it('should filter out non-output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should return all nodes if all are output nodes', () => {
const nodes = [
createMockNode(1, true),
createMockNode(2, true),
createMockNode(3, true)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(3)
expect(result).toEqual(nodes)
})
it('should return empty array if no output nodes', () => {
const nodes = [
createMockNode(1, false),
createMockNode(2, false),
createMockNode(3, false)
]
const result = filterOutputNodes(nodes)
expect(result).toHaveLength(0)
})
it('should handle nodes without nodeData', () => {
// Create a plain LGraphNode without custom constructor
const node = new LGraphNode('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
it('should handle nodes with undefined output_node', () => {
class MockNodeWithOtherData extends LGraphNode {
static nodeData = { someOtherProperty: true }
}
const node = new MockNodeWithOtherData('')
node.id = 1
const result = filterOutputNodes([node])
expect(result).toHaveLength(0)
})
})
describe('isOutputNode', () => {
it('should filter selected nodes to only output nodes', () => {
const selectedNodes = [
createMockNode(1, false),
createMockNode(2, true),
createMockNode(3, false),
createMockNode(4, true),
createMockNode(5, false)
]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(2)
expect(result.map((n) => n.id)).toEqual([2, 4])
})
it('should handle empty selection', () => {
const emptyNodes: LGraphNode[] = []
const result = emptyNodes.filter(isOutputNode)
expect(result).toEqual([])
})
it('should handle selection with no output nodes', () => {
const selectedNodes = [createMockNode(1, false), createMockNode(2, false)]
const result = selectedNodes.filter(isOutputNode)
expect(result).toHaveLength(0)
})
})
})

View File

@@ -1,3 +1,4 @@
import { LGraph } from '@comfyorg/litegraph'
import type { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it } from 'vitest'
@@ -21,7 +22,11 @@ describe('applyTextReplacements', () => {
} as LGraphNode
]
const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%')
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
// The expected result should have all invalid characters replaced with underscores
expect(result).toBe('file_name_with_invalid_chars_____control_chars__')
@@ -51,7 +56,11 @@ describe('applyTextReplacements', () => {
} as LGraphNode
]
const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%')
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
expect(result).toBe(expected)
}
})
@@ -66,7 +75,11 @@ describe('applyTextReplacements', () => {
} as LGraphNode
]
const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%')
const mockGraph = new LGraph()
for (const node of mockNodes) {
mockGraph.add(node)
}
const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%')
expect(result).toBe(validChars)
})
})

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
describe('typeGuardUtil', () => {
describe('isSubgraphIoNode', () => {
it('should identify SubgraphInputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphInputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should identify SubgraphOutputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphOutputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should not identify regular nodes as IO nodes', () => {
const node = {
constructor: { comfyClass: 'CLIPTextEncode' }
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without constructor', () => {
const node = {} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without comfyClass', () => {
const node = {
constructor: {}
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
})
})

View File

@@ -0,0 +1,82 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, test, vi } from 'vitest'
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
// Mock dependencies
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
unregisterWidget: vi.fn()
})
}))
vi.mock('@/utils/formatUtil', () => ({
generateUUID: () => 'test-uuid'
}))
describe('DOMWidget Y Position Preservation', () => {
test('BaseDOMWidgetImpl createCopyForNode preserves Y position', () => {
const mockNode = new LGraphNode('test-node')
const originalWidget = new ComponentWidgetImpl({
node: mockNode,
name: 'test-widget',
component: { template: '<div></div>' },
inputSpec: { name: 'test', type: 'string' },
options: {}
})
// Set a specific Y position
originalWidget.y = 66
const newNode = new LGraphNode('new-node')
const clonedWidget = originalWidget.createCopyForNode(newNode)
// Verify Y position is preserved
expect(clonedWidget.y).toBe(66)
expect(clonedWidget.node).toBe(newNode)
expect(clonedWidget.name).toBe('test-widget')
})
test('DOMWidgetImpl createCopyForNode preserves Y position', () => {
const mockNode = new LGraphNode('test-node')
const mockElement = document.createElement('div')
const originalWidget = new DOMWidgetImpl({
node: mockNode,
name: 'test-dom-widget',
type: 'test',
element: mockElement,
options: {}
})
// Set a specific Y position
originalWidget.y = 42
const newNode = new LGraphNode('new-node')
const clonedWidget = originalWidget.createCopyForNode(newNode)
// Verify Y position is preserved
expect(clonedWidget.y).toBe(42)
expect(clonedWidget.node).toBe(newNode)
expect(clonedWidget.element).toBe(mockElement)
expect(clonedWidget.name).toBe('test-dom-widget')
})
test('Y position defaults to 0 when not set', () => {
const mockNode = new LGraphNode('test-node')
const originalWidget = new ComponentWidgetImpl({
node: mockNode,
name: 'test-widget',
component: { template: '<div></div>' },
inputSpec: { name: 'test', type: 'string' },
options: {}
})
// Don't explicitly set Y (should be 0 by default)
const newNode = new LGraphNode('new-node')
const clonedWidget = originalWidget.createCopyForNode(newNode)
// Verify Y position is preserved (should be 0)
expect(clonedWidget.y).toBe(0)
})
})