Merge branch 'manager/menu-items-migration' into manager/compatibility

This commit is contained in:
Jin Yi
2025-07-31 13:35:43 +09:00
committed by GitHub
184 changed files with 15899 additions and 2233 deletions

View File

@@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
// Mock i18n module
vi.mock('@/i18n', () => ({
t: (key: string, fallback: string) =>
key === 'g.nodesRunning' ? 'nodes running' : fallback
}))
// Mock the execution store
const executionStore = reactive({
isIdle: true,
executionProgress: 0,
executingNode: null as any,
executingNodeProgress: 0
executingNodeProgress: 0,
nodeProgressStates: {} as any,
activePrompt: null as any
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => {
executionStore.executionProgress = 0
executionStore.executingNode = null as any
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activePrompt = null
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
@@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => {
expect(document.title).toBe('[30%]ComfyUI')
})
it('shows node execution title when executing a node', async () => {
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNodeProgress = 0.5
executionStore.executingNode = { type: 'Foo' }
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activePrompt = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
})
it('shows multiple nodes running when multiple nodes are executing', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.nodeProgressStates = {
'1': {
state: 'running',
value: 5,
max: 10,
node: '1',
prompt_id: 'test'
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
})
})

View File

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

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { app } from '@/scripts/app'
import * as settingStore from '@/stores/settingStore'
// Mock the app and canvas
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: null as HTMLCanvasElement | null
}
}
}))
// Mock the setting store
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))
describe('useCanvasInteractions', () => {
let mockCanvas: HTMLCanvasElement
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
let canvasInteractions: ReturnType<typeof useCanvasInteractions>
beforeEach(() => {
// Clear mocks
vi.clearAllMocks()
// Create mock canvas element
mockCanvas = document.createElement('canvas')
mockCanvas.dispatchEvent = vi.fn()
app.canvas!.canvas = mockCanvas
// Mock setting store
mockSettingStore = { get: vi.fn() }
vi.mocked(settingStore.useSettingStore).mockReturnValue(
mockSettingStore as any
)
canvasInteractions = useCanvasInteractions()
})
describe('handleWheel', () => {
it('should check navigation mode from settings', () => {
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockSettingStore.get).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode'
)
})
it('should not forward regular wheel events in standard mode', () => {
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy mode', () => {
mockSettingStore.get.mockReturnValue('legacy')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
})
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
expect(() => {
canvasInteractions.handleWheel(wheelEvent)
}).not.toThrow()
})
})
describe('forwardEventToCanvas', () => {
it('should dispatch event to canvas element', () => {
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true
})
canvasInteractions.forwardEventToCanvas(wheelEvent)
expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
expect.any(WheelEvent)
)
})
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
expect(() => {
canvasInteractions.forwardEventToCanvas(wheelEvent)
}).not.toThrow()
})
})
})

View File

@@ -603,7 +603,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.25/Run')
expect(price).toBe('$0.4/Run')
})
it('should return range when widgets are missing', () => {
@@ -771,14 +771,14 @@ describe('useNodePricing', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV1')
expect(widgetNames).toEqual(['num_images'])
expect(widgetNames).toEqual(['num_images', 'turbo'])
})
it('should return correct widget names for IdeogramV2', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV2')
expect(widgetNames).toEqual(['num_images'])
expect(widgetNames).toEqual(['num_images', 'turbo'])
})
it('should return correct widget names for IdeogramV3', () => {
@@ -832,7 +832,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06 x num_images/Run')
expect(price).toBe('$0.02-0.06 x num_images/Run')
})
it('should fall back to static display when num_images widget is missing for IdeogramV2', () => {
@@ -840,7 +840,7 @@ describe('useNodePricing', () => {
const node = createMockNode('IdeogramV2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08 x num_images/Run')
expect(price).toBe('$0.05-0.08 x num_images/Run')
})
it('should handle edge case when num_images value is 1 for IdeogramV1', () => {
@@ -850,7 +850,7 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run') // 0.06 * 1
expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default)
})
})
@@ -1022,5 +1022,517 @@ describe('useNodePricing', () => {
getRelevantWidgetNames('RecraftGenerateColorFromImageNode')
).toEqual(['n'])
})
it('should include relevant widget names for new nodes', () => {
const { getRelevantWidgetNames } = useNodePricing()
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'quad',
'style',
'texture',
'texture_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'quad',
'style',
'texture',
'texture_quality'
])
})
})
describe('New API nodes pricing', () => {
describe('RunwayML nodes', () => {
it('should return static price for RunwayTextToImageNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayTextToImageNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 10 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run') // 0.05 * 10
})
it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [])
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', () => {
it('should return base price for Rodin3D_Regular', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Regular')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return addon price for Rodin3D_Detail', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Detail')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
it('should return addon price for Rodin3D_Smooth', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Rodin3D_Smooth')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.4/Run')
})
})
describe('Tripo nodes', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ 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.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: '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.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: '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.45/Run') // any style, quad, no texture, detailed
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ 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.10/Run') // none style, no quad, no texture
})
it('should return static price for TripoRefineNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
it('should return texture-based pricing for TripoTextureNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const standardNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'standard' }
])
const detailedNode = createMockNode('TripoTextureNode', [
{ name: 'texture_quality', value: 'detailed' }
])
expect(getNodeDisplayPrice(standardNode)).toBe('$0.1/Run')
expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run')
})
it('should handle various Tripo parameter combinations', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test different parameter combinations
const testCases = [
{ 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(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ 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', () => {
it('should return specific pricing for supported Gemini models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{
model: 'gemini-2.5-pro-preview-05-06',
expected: '$0.00016/$0.0006 per 1K tokens'
},
{
model: 'gemini-2.5-flash-preview-04-17',
expected: '$0.00125/$0.01 per 1K tokens'
},
{ model: 'unknown-gemini-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('GeminiNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return per-second pricing for Gemini Veo models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [
{ name: 'model', value: 'veo-2.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.5/second')
})
it('should return fallback for GeminiNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('GeminiNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return token-based pricing for OpenAIChatNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: 'unknown-model' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
it('should return correct pricing for all exposed OpenAI models', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ model: 'o4-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o1-pro', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'o3', expected: '$0.01/$0.04 per 1K tokens' },
{ model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' },
{ model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' },
{ model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' },
{ model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle model ordering correctly (specific before general)', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test that more specific patterns are matched before general ones
const testCases = [
{
model: 'gpt-4.1-nano-test',
expected: '$0.0001/$0.0004 per 1K tokens'
},
{
model: 'gpt-4.1-mini-test',
expected: '$0.0004/$0.0016 per 1K tokens'
},
{ model: 'gpt-4.1-test', expected: '$0.002/$0.008 per 1K tokens' },
{ model: 'o1-pro-test', expected: '$0.15/$0.60 per 1K tokens' },
{ model: 'o1-test', expected: '$0.015/$0.06 per 1K tokens' },
{ model: 'o3-mini-test', expected: '$0.0011/$0.0044 per 1K tokens' },
{ model: 'unknown-model', expected: 'Token-based' }
]
testCases.forEach(({ model, expected }) => {
const node = createMockNode('OpenAIChatNode', [
{ name: 'model', value: model }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return fallback for OpenAIChatNode without model widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIChatNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('Token-based')
})
})
describe('Additional RunwayML edge cases', () => {
it('should handle edge cases for RunwayML duration-based pricing', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Test edge cases
const testCases = [
{ duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration
{ duration: 1, expected: '$0.05/Run' },
{ duration: 30, expected: '$1.50/Run' }
]
testCases.forEach(({ duration, expected }) => {
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: duration }
])
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should handle invalid duration values gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RunwayImageToVideoNodeGen3a', [
{ name: 'duration', value: 'invalid-string' }
])
// When Number('invalid-string') returns NaN, it falls back to 5 seconds
expect(getNodeDisplayPrice(node)).toBe('$0.25/Run')
})
it('should handle missing duration widget gracefully', () => {
const { getNodeDisplayPrice } = useNodePricing()
const nodes = [
'RunwayImageToVideoNodeGen3a',
'RunwayImageToVideoNodeGen4',
'RunwayFirstLastFrameNode'
]
nodes.forEach((nodeType) => {
const node = createMockNode(nodeType, [])
expect(getNodeDisplayPrice(node)).toBe('$0.05/second')
})
})
})
describe('Complete Rodin node coverage', () => {
it('should return correct pricing for all Rodin variants', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Detail', expected: '$0.4/Run' },
{ nodeType: 'Rodin3D_Smooth', expected: '$0.4/Run' }
]
testCases.forEach(({ nodeType, expected }) => {
const node = createMockNode(nodeType)
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
})
describe('Comprehensive Tripo edge case testing', () => {
it('should handle TripoImageToModelNode with various parameter combinations', () => {
const { getNodeDisplayPrice } = useNodePricing()
const testCases = [
{ 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(
({ 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', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [])
const price = getNodeDisplayPrice(node)
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', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
it('should handle missing model version widget', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'texture_quality', value: 'detailed' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
)
})
})
})
})

View File

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

View File

@@ -0,0 +1,234 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning'
import { useToastStore } from '@/stores/toastStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
// Mock globals
//@ts-expect-error Define global for the test
global.__COMFYUI_FRONTEND_VERSION__ = '1.0.0'
// Mock config first - this needs to be before any imports
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
// Mock app
vi.mock('@/scripts/app', () => ({
app: {
ui: {
settings: {
dispatchChange: vi.fn()
}
}
}
}))
// Mock api
vi.mock('@/scripts/api', () => ({
api: {
getSettings: vi.fn(() => Promise.resolve({})),
storeSetting: vi.fn(() => Promise.resolve(undefined))
}
}))
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: any) => {
if (key === 'g.versionMismatchWarning')
return 'Version Compatibility Warning'
if (key === 'g.versionMismatchWarningMessage' && params) {
return `${params.warning}: ${params.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.`
}
if (key === 'g.frontendOutdated' && params) {
return `Frontend version ${params.frontendVersion} is outdated. Backend requires ${params.requiredVersion} or higher.`
}
if (key === 'g.frontendNewer' && params) {
return `Frontend version ${params.frontendVersion} may not be compatible with backend version ${params.backendVersion}.`
}
return key
}
}),
createI18n: vi.fn(() => ({
global: {
locale: { value: 'en' },
t: vi.fn()
}
}))
}))
// Mock lifecycle hooks to track their calls
const mockOnMounted = vi.fn()
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue')>()
return {
...actual,
onMounted: (fn: () => void) => {
mockOnMounted()
fn()
}
}
})
describe('useFrontendVersionMismatchWarning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should not show warning when there is no version mismatch', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
// Mock no version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(false)
useFrontendVersionMismatchWarning()
expect(addAlertSpy).not.toHaveBeenCalled()
})
it('should show warning immediately when immediate option is true and there is a mismatch', async () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning')
// Mock version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
useFrontendVersionMismatchWarning({ immediate: true })
// For immediate: true, the watcher should fire immediately in onMounted
await nextTick()
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining('Version Compatibility Warning')
)
expect(addAlertSpy).toHaveBeenCalledWith(
expect.stringContaining('Frontend version 1.0.0 is outdated')
)
// Should automatically dismiss the warning
expect(dismissWarningSpy).toHaveBeenCalled()
})
it('should not show warning immediately when immediate option is false', async () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
// Mock version mismatch
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const result = useFrontendVersionMismatchWarning({ immediate: false })
await nextTick()
// Should not show automatically
expect(addAlertSpy).not.toHaveBeenCalled()
// But should show when called manually
result.showWarning()
expect(addAlertSpy).toHaveBeenCalledOnce()
})
it('should call showWarning method manually', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const { showWarning } = useFrontendVersionMismatchWarning()
showWarning()
expect(addAlertSpy).toHaveBeenCalledOnce()
expect(dismissWarningSpy).toHaveBeenCalled()
})
it('should expose store methods and computed values', () => {
const versionStore = useVersionCompatibilityStore()
const mockDismissWarning = vi.fn()
vi.spyOn(versionStore, 'dismissWarning').mockImplementation(
mockDismissWarning
)
vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true)
vi.spyOn(versionStore, 'hasVersionMismatch', 'get').mockReturnValue(true)
const result = useFrontendVersionMismatchWarning()
expect(result.shouldShowWarning.value).toBe(true)
expect(result.hasVersionMismatch.value).toBe(true)
void result.dismissWarning()
expect(mockDismissWarning).toHaveBeenCalled()
})
it('should register onMounted hook', () => {
useFrontendVersionMismatchWarning()
expect(mockOnMounted).toHaveBeenCalledOnce()
})
it('should not show warning when warningMessage is null', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null)
const { showWarning } = useFrontendVersionMismatchWarning()
showWarning()
expect(addAlertSpy).not.toHaveBeenCalled()
})
it('should only show warning once even if called multiple times', () => {
const toastStore = useToastStore()
const versionStore = useVersionCompatibilityStore()
const addAlertSpy = vi.spyOn(toastStore, 'addAlert')
vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({
type: 'outdated',
frontendVersion: '1.0.0',
requiredVersion: '2.0.0'
})
const { showWarning } = useFrontendVersionMismatchWarning()
// Call showWarning multiple times
showWarning()
showWarning()
showWarning()
// Should only have been called once
expect(addAlertSpy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,815 @@
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()
}
}))
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)
expect(mockContext2D.fillRect).not.toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
})
describe('mouse interactions', () => {
it('should handle mouse down and start dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle mouse move while dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should not move when not dragging', async () => {
const minimap = await createAndInitializeMinimap()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle mouse up to stop dragging', async () => {
const minimap = await createAndInitializeMinimap()
const mouseDownEvent = new MouseEvent('mousedown', {
clientX: 150,
clientY: 150
})
minimap.handleMouseDown(mouseDownEvent)
minimap.handleMouseUp()
mockCanvas.setDirty.mockClear()
const mouseMoveEvent = new MouseEvent('mousemove', {
clientX: 200,
clientY: 200
})
minimap.handleMouseMove(mouseMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('wheel interactions', () => {
it('should handle wheel zoom in', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(1.1)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle wheel zoom out', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(preventDefault).toHaveBeenCalled()
expect(mockCanvas.ds.scale).toBeCloseTo(0.9)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should respect zoom limits', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.ds.scale = 0.1
const wheelEvent = new WheelEvent('wheel', {
deltaY: 100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockCanvas.ds.scale).toBe(0.1)
})
it('should update container rect if needed', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
clientX: 150,
clientY: 150
})
const preventDefault = vi.fn()
Object.defineProperty(wheelEvent, 'preventDefault', {
value: preventDefault
})
minimap.handleWheel(wheelEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
})
})
describe('viewport updates', () => {
it('should update viewport transform correctly', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
await nextTick()
const viewportStyles = minimap.viewportStyles.value
expect(viewportStyles).toBeDefined()
expect(viewportStyles.transform).toMatch(
/translate\(-?\d+(\.\d+)?px, -?\d+(\.\d+)?px\)/
)
expect(viewportStyles.width).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.height).toMatch(/\d+(\.\d+)?px/)
expect(viewportStyles.border).toBe('2px solid #FFF')
})
it('should handle canvas dimension updates', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
mockCanvas.canvas.clientWidth = 1200
mockCanvas.canvas.clientHeight = 900
const resizeHandler = (window.addEventListener as any).mock.calls.find(
(call: any) => call[0] === 'resize'
)?.[1]
if (resizeHandler) {
resizeHandler()
}
await nextTick()
expect(minimap.viewportStyles.value).toBeDefined()
})
})
describe('graph change handling', () => {
it('should handle node addition', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const newNode = {
id: 'node3',
pos: [300, 200],
size: [100, 100],
constructor: { color: '#666' }
}
mockGraph._nodes.push(newNode)
if (mockGraph.onNodeAdded) {
mockGraph.onNodeAdded(newNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle node removal', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const removedNode = mockGraph._nodes[0]
mockGraph._nodes.splice(0, 1)
if (mockGraph.onNodeRemoved) {
mockGraph.onNodeRemoved(removedNode)
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
it('should handle connection changes', async () => {
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
if (mockGraph.onConnectionChange) {
mockGraph.onConnectionChange(mockGraph._nodes[0])
}
await new Promise((resolve) => setTimeout(resolve, 600))
})
})
describe('container styles', () => {
it('should provide correct container styles', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('250px')
expect(styles.height).toBe('200px')
expect(styles.backgroundColor).toBe('#15161C')
expect(styles.border).toBe('1px solid #333')
expect(styles.borderRadius).toBe('8px')
})
})
describe('edge cases', () => {
it('should handle missing node outputs', async () => {
mockGraph._nodes[0].outputs = null
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle invalid link references', async () => {
mockGraph.links.link1.target_id = 'invalid-node'
mockGraph.getNodeById.mockReturnValue(null)
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await expect(minimap.init()).resolves.not.toThrow()
expect(minimap.initialized.value).toBe(true)
})
it('should handle high DPI displays', async () => {
window.devicePixelRatio = 2
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
expect(minimap.initialized.value).toBe(true)
})
it('should handle nodes without color', async () => {
mockGraph._nodes[0].color = undefined
const minimap = useMinimap()
minimap.containerRef.value = mockContainerElement
minimap.canvasRef.value = mockCanvasElement
await minimap.init()
const renderMinimap = (minimap as any).renderMinimap
if (renderMinimap) {
renderMinimap()
}
expect(mockContext2D.fillRect).toHaveBeenCalled()
expect(mockContext2D.fillStyle).toBeDefined()
})
})
describe('setMinimapRef', () => {
it('should set minimap reference', () => {
const minimap = useMinimap()
const ref = { value: 'test-ref' }
minimap.setMinimapRef(ref)
expect(() => minimap.setMinimapRef(ref)).not.toThrow()
})
})
})

View File

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