mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 09:19:43 +00:00
[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:
129
tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts
Normal file
129
tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
187
tests-ui/tests/composables/useCoreCommands.test.ts
Normal file
187
tests-ui/tests/composables/useCoreCommands.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
832
tests-ui/tests/composables/useMinimap.test.ts
Normal file
832
tests-ui/tests/composables/useMinimap.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
184
tests-ui/tests/store/subgraphNavigationStore.test.ts
Normal file
184
tests-ui/tests/store/subgraphNavigationStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
254
tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts
Normal file
254
tests-ui/tests/store/subgraphNavigationStore.viewport.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal file
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
45
tests-ui/tests/utils/typeGuardUtil.test.ts
Normal file
45
tests-ui/tests/utils/typeGuardUtil.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
tests-ui/tests/widgets/domWidget.test.ts
Normal file
82
tests-ui/tests/widgets/domWidget.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user