mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 06:49:37 +00:00
Merge branch 'main' into manager/menu-items-migration
This commit is contained in:
332
tests-ui/tests/audioService.test.ts
Normal file
332
tests-ui/tests/audioService.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
type AudioRecordingError,
|
||||
useAudioService
|
||||
} from '@/services/audioService'
|
||||
|
||||
const mockRegister = vi.hoisted(() => vi.fn())
|
||||
const mockConnect = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
fetchApi: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('extendable-media-recorder', () => ({
|
||||
register: mockRegister
|
||||
}))
|
||||
|
||||
vi.mock('extendable-media-recorder-wav-encoder', () => ({
|
||||
connect: mockConnect
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mockApi
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => mockToastStore)
|
||||
}))
|
||||
|
||||
describe('useAudioService', () => {
|
||||
let service: ReturnType<typeof useAudioService>
|
||||
|
||||
const mockBlob = new Blob(['test audio data'], { type: 'audio/wav' })
|
||||
const mockUploadResponse = {
|
||||
name: 'test-audio-123.wav'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
mockConnect.mockResolvedValue('mock-encoder')
|
||||
mockRegister.mockResolvedValue(undefined)
|
||||
mockApi.fetchApi.mockResolvedValue({
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockUploadResponse)
|
||||
})
|
||||
|
||||
service = useAudioService()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize service with required methods', () => {
|
||||
expect(service).toHaveProperty('registerWavEncoder')
|
||||
expect(service).toHaveProperty('stopAllTracks')
|
||||
expect(service).toHaveProperty('convertBlobToFileAndSubmit')
|
||||
expect(typeof service.registerWavEncoder).toBe('function')
|
||||
expect(typeof service.stopAllTracks).toBe('function')
|
||||
expect(typeof service.convertBlobToFileAndSubmit).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerWavEncoder', () => {
|
||||
it('should register WAV encoder successfully on first call', async () => {
|
||||
await service.registerWavEncoder()
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegister).toHaveBeenCalledWith('mock-encoder')
|
||||
})
|
||||
|
||||
it('should not register again if already registered', async () => {
|
||||
await service.registerWavEncoder()
|
||||
|
||||
mockConnect.mockClear()
|
||||
mockRegister.mockClear()
|
||||
|
||||
await service.registerWavEncoder()
|
||||
|
||||
expect(mockConnect).not.toHaveBeenCalled()
|
||||
expect(mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle "already an encoder stored" error gracefully', async () => {
|
||||
const error = new Error(
|
||||
'There is already an encoder stored which handles exactly the same mime types.'
|
||||
)
|
||||
mockRegister.mockRejectedValueOnce(error)
|
||||
|
||||
await service.registerWavEncoder()
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledTimes(0)
|
||||
expect(mockRegister).toHaveBeenCalledTimes(0)
|
||||
expect(console.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopAllTracks', () => {
|
||||
it('should stop all tracks in a stream', () => {
|
||||
const mockTrack1 = { stop: vi.fn() }
|
||||
const mockTrack2 = { stop: vi.fn() }
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
|
||||
} as unknown as MediaStream
|
||||
|
||||
service.stopAllTracks(mockStream)
|
||||
|
||||
expect(mockStream.getTracks).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrack1.stop).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrack2.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle null stream gracefully', () => {
|
||||
expect(() => service.stopAllTracks(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle stream with no tracks', () => {
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([])
|
||||
} as unknown as MediaStream
|
||||
|
||||
expect(() => service.stopAllTracks(mockStream)).not.toThrow()
|
||||
expect(mockStream.getTracks).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle tracks that throw on stop', () => {
|
||||
const mockTrack1 = { stop: vi.fn() }
|
||||
const mockTrack2 = {
|
||||
stop: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Stop failed')
|
||||
})
|
||||
}
|
||||
const mockStream = {
|
||||
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
|
||||
} as unknown as MediaStream
|
||||
|
||||
expect(() => service.stopAllTracks(mockStream)).toThrow()
|
||||
expect(mockTrack1.stop).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrack2.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertBlobToFileAndSubmit', () => {
|
||||
it('should convert blob to file and upload successfully', async () => {
|
||||
const result = await service.convertBlobToFileAndSubmit(mockBlob)
|
||||
|
||||
expect(mockApi.fetchApi).toHaveBeenCalledWith('/upload/image', {
|
||||
method: 'POST',
|
||||
body: expect.any(FormData)
|
||||
})
|
||||
|
||||
expect(result).toBe('audio/test-audio-123.wav [temp]')
|
||||
})
|
||||
|
||||
it('should create file with correct name and type', async () => {
|
||||
const mockTimestamp = 1640995200000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp)
|
||||
|
||||
await service.convertBlobToFileAndSubmit(mockBlob)
|
||||
|
||||
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
|
||||
const uploadedFile = formDataCall.get('image') as File
|
||||
|
||||
expect(uploadedFile).toBeInstanceOf(File)
|
||||
expect(uploadedFile.name).toBe(`recording-${mockTimestamp}.wav`)
|
||||
expect(uploadedFile.type).toBe('audio/wav')
|
||||
})
|
||||
|
||||
it('should set correct form data fields', async () => {
|
||||
await service.convertBlobToFileAndSubmit(mockBlob)
|
||||
|
||||
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
|
||||
|
||||
expect(formDataCall.get('subfolder')).toBe('audio')
|
||||
expect(formDataCall.get('type')).toBe('temp')
|
||||
expect(formDataCall.get('image')).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('should handle blob with different type', async () => {
|
||||
const customBlob = new Blob(['test'], { type: 'audio/ogg' })
|
||||
|
||||
await service.convertBlobToFileAndSubmit(customBlob)
|
||||
|
||||
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
|
||||
const uploadedFile = formDataCall.get('image') as File
|
||||
|
||||
expect(uploadedFile.type).toBe('audio/ogg')
|
||||
})
|
||||
|
||||
it('should handle blob with no type', async () => {
|
||||
const customBlob = new Blob(['test'])
|
||||
|
||||
await service.convertBlobToFileAndSubmit(customBlob)
|
||||
|
||||
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
|
||||
const uploadedFile = formDataCall.get('image') as File
|
||||
|
||||
expect(uploadedFile.type).toBe('audio/wav') // Should default to audio/wav
|
||||
})
|
||||
|
||||
it('should handle upload failure with error status', async () => {
|
||||
mockApi.fetchApi.mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.convertBlobToFileAndSubmit(mockBlob)
|
||||
).rejects.toThrow(
|
||||
'Error uploading temp file: 500 - Internal Server Error'
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'Error uploading temp file: 500 - Internal Server Error'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const networkError = new Error('Network Error')
|
||||
mockApi.fetchApi.mockRejectedValueOnce(networkError)
|
||||
|
||||
await expect(
|
||||
service.convertBlobToFileAndSubmit(mockBlob)
|
||||
).rejects.toThrow('Network Error')
|
||||
})
|
||||
|
||||
it('should handle different status codes', async () => {
|
||||
const testCases = [
|
||||
{ status: 400, statusText: 'Bad Request' },
|
||||
{ status: 403, statusText: 'Forbidden' },
|
||||
{ status: 404, statusText: 'Not Found' },
|
||||
{ status: 413, statusText: 'Payload Too Large' }
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
mockApi.fetchApi.mockResolvedValueOnce(testCase)
|
||||
|
||||
await expect(
|
||||
service.convertBlobToFileAndSubmit(mockBlob)
|
||||
).rejects.toThrow(
|
||||
`Error uploading temp file: ${testCase.status} - ${testCase.statusText}`
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
`Error uploading temp file: ${testCase.status} - ${testCase.statusText}`
|
||||
)
|
||||
|
||||
mockToastStore.addAlert.mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle malformed response JSON', async () => {
|
||||
mockApi.fetchApi.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: () => Promise.reject(new Error('Invalid JSON'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.convertBlobToFileAndSubmit(mockBlob)
|
||||
).rejects.toThrow('Invalid JSON')
|
||||
})
|
||||
|
||||
it('should handle empty response', async () => {
|
||||
mockApi.fetchApi.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
|
||||
const result = await service.convertBlobToFileAndSubmit(mockBlob)
|
||||
|
||||
expect(result).toBe('audio/undefined [temp]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle AudioRecordingError interface correctly', () => {
|
||||
const error: AudioRecordingError = {
|
||||
type: 'permission',
|
||||
message: 'Microphone access denied',
|
||||
originalError: new Error('Permission denied')
|
||||
}
|
||||
|
||||
expect(error.type).toBe('permission')
|
||||
expect(error.message).toBe('Microphone access denied')
|
||||
expect(error.originalError).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('should support all error types', () => {
|
||||
const errorTypes = [
|
||||
'permission',
|
||||
'not_supported',
|
||||
'encoder',
|
||||
'recording',
|
||||
'unknown'
|
||||
] as const
|
||||
|
||||
errorTypes.forEach((type) => {
|
||||
const error: AudioRecordingError = {
|
||||
type,
|
||||
message: `Test error for ${type}`
|
||||
}
|
||||
|
||||
expect(error.type).toBe(type)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle very large blobs', async () => {
|
||||
const largeData = new Array(1000000).fill('a').join('')
|
||||
const largeBlob = new Blob([largeData], { type: 'audio/wav' })
|
||||
|
||||
const result = await service.convertBlobToFileAndSubmit(largeBlob)
|
||||
|
||||
expect(result).toBe('audio/test-audio-123.wav [temp]')
|
||||
expect(mockApi.fetchApi).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle empty blob', async () => {
|
||||
const emptyBlob = new Blob([], { type: 'audio/wav' })
|
||||
|
||||
const result = await service.convertBlobToFileAndSubmit(emptyBlob)
|
||||
|
||||
expect(result).toBe('audio/test-audio-123.wav [temp]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
126
tests-ui/tests/composables/graph/useCanvasInteractions.test.ts
Normal file
126
tests-ui/tests/composables/graph/useCanvasInteractions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
815
tests-ui/tests/composables/useMinimap.test.ts
Normal file
815
tests-ui/tests/composables/useMinimap.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
151
tests-ui/tests/domWidgetStore.test.ts
Normal file
151
tests-ui/tests/domWidgetStore.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
|
||||
// Mock DOM widget for testing
|
||||
const createMockDOMWidget = (id: string) => {
|
||||
const element = document.createElement('input')
|
||||
return {
|
||||
id,
|
||||
element,
|
||||
node: {
|
||||
id: 'node-1',
|
||||
title: 'Test Node',
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
} as any,
|
||||
name: 'test_widget',
|
||||
type: 'text',
|
||||
value: 'test',
|
||||
options: {},
|
||||
y: 0,
|
||||
margin: 10,
|
||||
isVisible: () => true,
|
||||
containerNode: undefined as any
|
||||
}
|
||||
}
|
||||
|
||||
describe('domWidgetStore', () => {
|
||||
let store: ReturnType<typeof useDomWidgetStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useDomWidgetStore()
|
||||
})
|
||||
|
||||
describe('widget registration', () => {
|
||||
it('should register a widget with default state', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
|
||||
store.registerWidget(widget)
|
||||
|
||||
expect(store.widgetStates.has('widget-1')).toBe(true)
|
||||
const state = store.widgetStates.get('widget-1')
|
||||
expect(state).toBeDefined()
|
||||
expect(state!.widget).toBe(widget)
|
||||
expect(state!.visible).toBe(true)
|
||||
expect(state!.active).toBe(true)
|
||||
expect(state!.readonly).toBe(false)
|
||||
expect(state!.zIndex).toBe(0)
|
||||
expect(state!.pos).toEqual([0, 0])
|
||||
expect(state!.size).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('should not register the same widget twice', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
|
||||
store.registerWidget(widget)
|
||||
store.registerWidget(widget)
|
||||
|
||||
// Should still only have one entry
|
||||
const states = Array.from(store.widgetStates.values())
|
||||
expect(states.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget unregistration', () => {
|
||||
it('should unregister a widget by id', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
|
||||
store.registerWidget(widget)
|
||||
expect(store.widgetStates.has('widget-1')).toBe(true)
|
||||
|
||||
store.unregisterWidget('widget-1')
|
||||
expect(store.widgetStates.has('widget-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle unregistering non-existent widget gracefully', () => {
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
store.unregisterWidget('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget state management', () => {
|
||||
it('should activate a widget', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
store.registerWidget(widget)
|
||||
|
||||
// Set to inactive first
|
||||
const state = store.widgetStates.get('widget-1')!
|
||||
state.active = false
|
||||
|
||||
store.activateWidget('widget-1')
|
||||
expect(state.active).toBe(true)
|
||||
})
|
||||
|
||||
it('should deactivate a widget', () => {
|
||||
const widget = createMockDOMWidget('widget-1')
|
||||
store.registerWidget(widget)
|
||||
|
||||
store.deactivateWidget('widget-1')
|
||||
const state = store.widgetStates.get('widget-1')
|
||||
expect(state!.active).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle activating non-existent widget gracefully', () => {
|
||||
expect(() => {
|
||||
store.activateWidget('non-existent')
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed states', () => {
|
||||
it('should separate active and inactive widget states', () => {
|
||||
const widget1 = createMockDOMWidget('widget-1')
|
||||
const widget2 = createMockDOMWidget('widget-2')
|
||||
|
||||
store.registerWidget(widget1)
|
||||
store.registerWidget(widget2)
|
||||
|
||||
// Deactivate widget2
|
||||
store.deactivateWidget('widget-2')
|
||||
|
||||
expect(store.activeWidgetStates.length).toBe(1)
|
||||
expect(store.activeWidgetStates[0].widget.id).toBe('widget-1')
|
||||
|
||||
expect(store.inactiveWidgetStates.length).toBe(1)
|
||||
expect(store.inactiveWidgetStates[0].widget.id).toBe('widget-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear functionality', () => {
|
||||
it('should clear all widget states', () => {
|
||||
const widget1 = createMockDOMWidget('widget-1')
|
||||
const widget2 = createMockDOMWidget('widget-2')
|
||||
|
||||
store.registerWidget(widget1)
|
||||
store.registerWidget(widget2)
|
||||
|
||||
expect(store.widgetStates.size).toBe(2)
|
||||
|
||||
store.clear()
|
||||
|
||||
expect(store.widgetStates.size).toBe(0)
|
||||
expect(store.activeWidgetStates.length).toBe(0)
|
||||
expect(store.inactiveWidgetStates.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
19
tests-ui/tests/maskeditor.test.ts
Normal file
19
tests-ui/tests/maskeditor.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskEditorLayerFilenames'
|
||||
|
||||
describe('imageLayerFilenamesIfApplicable', () => {
|
||||
// In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.)
|
||||
it('should support all past layer naming schemes to preserve backward compatibility', async () => {
|
||||
const dummyTimestamp = 1234567890
|
||||
const inputToSupport = `clipspace-painted-masked-${dummyTimestamp}.png`
|
||||
const expectedOutput = {
|
||||
maskedImage: `clipspace-mask-${dummyTimestamp}.png`,
|
||||
paint: `clipspace-paint-${dummyTimestamp}.png`,
|
||||
paintedImage: `clipspace-painted-${dummyTimestamp}.png`,
|
||||
paintedMaskedImage: inputToSupport
|
||||
}
|
||||
const actualOutput = imageLayerFilenamesIfApplicable(inputToSupport)
|
||||
expect(actualOutput).toEqual(expectedOutput)
|
||||
})
|
||||
})
|
||||
@@ -172,4 +172,58 @@ describe('dialogStore', () => {
|
||||
expect(store.dialogStack[0].title).toBe('Original Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESC key behavior with multiple dialogs', () => {
|
||||
it('should only allow the active dialog to close with ESC key', () => {
|
||||
const store = useDialogStore()
|
||||
|
||||
// Create dialogs with different priorities
|
||||
store.showDialog({
|
||||
key: 'dialog-1',
|
||||
component: MockComponent,
|
||||
priority: 1
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-2',
|
||||
component: MockComponent,
|
||||
priority: 2
|
||||
})
|
||||
|
||||
store.showDialog({
|
||||
key: 'dialog-3',
|
||||
component: MockComponent,
|
||||
priority: 3
|
||||
})
|
||||
|
||||
// Only the active dialog should be closable with ESC
|
||||
const activeDialog = store.dialogStack.find(
|
||||
(d) => d.key === store.activeKey
|
||||
)
|
||||
const inactiveDialogs = store.dialogStack.filter(
|
||||
(d) => d.key !== store.activeKey
|
||||
)
|
||||
|
||||
expect(activeDialog?.dialogComponentProps.closeOnEscape).toBe(true)
|
||||
inactiveDialogs.forEach((dialog) => {
|
||||
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
|
||||
})
|
||||
|
||||
// Close the active dialog
|
||||
store.closeDialog({ key: store.activeKey! })
|
||||
|
||||
// The new active dialog should now be closable with ESC
|
||||
const newActiveDialog = store.dialogStack.find(
|
||||
(d) => d.key === store.activeKey
|
||||
)
|
||||
const newInactiveDialogs = store.dialogStack.filter(
|
||||
(d) => d.key !== store.activeKey
|
||||
)
|
||||
|
||||
expect(newActiveDialog?.dialogComponentProps.closeOnEscape).toBe(true)
|
||||
newInactiveDialogs.forEach((dialog) => {
|
||||
expect(dialog.dialogComponentProps.closeOnEscape).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
nodeIdToNodeLocatorId: vi.fn(),
|
||||
nodeLocatorIdToNodeExecutionId: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Remove any previous global types
|
||||
declare global {
|
||||
// Empty interface to override any previous declarations
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
@@ -22,12 +33,16 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// Create a local mock instead of using global to avoid conflicts
|
||||
const mockApp = {
|
||||
graph: {
|
||||
getNodeById: vi.fn()
|
||||
// Mock the app import with proper implementation
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
revokePreviews: vi.fn(),
|
||||
nodePreviewImages: {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('executionStore - display_component handling', () => {
|
||||
function createDisplayComponentEvent(
|
||||
@@ -47,7 +62,7 @@ describe('executionStore - display_component handling', () => {
|
||||
|
||||
function handleDisplayComponentMessage(event: CustomEvent) {
|
||||
const { node_id, component } = event.detail
|
||||
const node = mockApp.graph.getNodeById(node_id)
|
||||
const node = vi.mocked(app.graph.getNodeById)(node_id)
|
||||
if (node && component === 'ChatHistoryWidget') {
|
||||
mockShowChatHistory(node)
|
||||
}
|
||||
@@ -60,23 +75,121 @@ describe('executionStore - display_component handling', () => {
|
||||
})
|
||||
|
||||
it('handles ChatHistoryWidget display_component messages', () => {
|
||||
const mockNode = { id: '123' }
|
||||
mockApp.graph.getNodeById.mockReturnValue(mockNode)
|
||||
const mockNode = { id: '123' } as any
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
const event = createDisplayComponentEvent('123')
|
||||
handleDisplayComponentMessage(event)
|
||||
|
||||
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123')
|
||||
expect(app.graph.getNodeById).toHaveBeenCalledWith('123')
|
||||
expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('does nothing if node is not found', () => {
|
||||
mockApp.graph.getNodeById.mockReturnValue(null)
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
|
||||
|
||||
const event = createDisplayComponentEvent('non-existent')
|
||||
handleDisplayComponentMessage(event)
|
||||
|
||||
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent')
|
||||
expect(app.graph.getNodeById).toHaveBeenCalledWith('non-existent')
|
||||
expect(mockShowChatHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Create the mock workflowStore instance
|
||||
const mockWorkflowStore = {
|
||||
nodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
nodeIdToNodeLocatorId: vi.fn(),
|
||||
nodeLocatorIdToNodeExecutionId: vi.fn()
|
||||
}
|
||||
|
||||
// Mock the useWorkflowStore function to return our mock
|
||||
vi.mocked(useWorkflowStore).mockReturnValue(mockWorkflowStore as any)
|
||||
|
||||
workflowStore = mockWorkflowStore as any
|
||||
store = useExecutionStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('executionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
// Mock subgraph structure
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
_nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
} as any
|
||||
|
||||
// Mock app.graph.getNodeById to return the mock node
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
const result = store.executionIdToNodeLocatorId('123:456')
|
||||
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should convert simple node ID to NodeLocatorId', () => {
|
||||
const result = store.executionIdToNodeLocatorId('123')
|
||||
|
||||
// For simple node IDs, it should return the ID as-is
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should handle numeric node IDs', () => {
|
||||
const result = store.executionIdToNodeLocatorId(123)
|
||||
|
||||
// For numeric IDs, it should convert to string and return as-is
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
// Mock app.graph.getNodeById to return null (node not found)
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
|
||||
|
||||
// This should throw an error as the node is not found
|
||||
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
|
||||
'Subgraph not found: 999'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
const mockExecutionId = '123:456'
|
||||
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
|
||||
mockExecutionId as any
|
||||
)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
|
||||
expect(workflowStore.nodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe(mockExecutionId)
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
|
||||
null
|
||||
)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId('invalid:format')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
308
tests-ui/tests/store/nodeDefStore.test.ts
Normal file
308
tests-ui/tests/store/nodeDefStore.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { type NodeDefFilter, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
describe('useNodeDefStore', () => {
|
||||
let store: ReturnType<typeof useNodeDefStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useNodeDefStore()
|
||||
})
|
||||
|
||||
const createMockNodeDef = (
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef => ({
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test',
|
||||
python_module: 'test_module',
|
||||
description: 'Test node',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('filter registry', () => {
|
||||
it('should register a new filter', () => {
|
||||
const filter: NodeDefFilter = {
|
||||
id: 'test.filter',
|
||||
name: 'Test Filter',
|
||||
predicate: () => true
|
||||
}
|
||||
|
||||
store.registerNodeDefFilter(filter)
|
||||
expect(store.nodeDefFilters).toContainEqual(filter)
|
||||
})
|
||||
|
||||
it('should unregister a filter by id', () => {
|
||||
const filter: NodeDefFilter = {
|
||||
id: 'test.filter',
|
||||
name: 'Test Filter',
|
||||
predicate: () => true
|
||||
}
|
||||
|
||||
store.registerNodeDefFilter(filter)
|
||||
store.unregisterNodeDefFilter('test.filter')
|
||||
expect(store.nodeDefFilters).not.toContainEqual(filter)
|
||||
})
|
||||
|
||||
it('should register core filters on initialization', () => {
|
||||
const deprecatedFilter = store.nodeDefFilters.find(
|
||||
(f) => f.id === 'core.deprecated'
|
||||
)
|
||||
const experimentalFilter = store.nodeDefFilters.find(
|
||||
(f) => f.id === 'core.experimental'
|
||||
)
|
||||
|
||||
expect(deprecatedFilter).toBeDefined()
|
||||
expect(experimentalFilter).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter application', () => {
|
||||
beforeEach(() => {
|
||||
// Clear existing filters for isolated tests
|
||||
store.nodeDefFilters.splice(0)
|
||||
})
|
||||
|
||||
it('should apply single filter to visible nodes', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
deprecated: false
|
||||
})
|
||||
const deprecatedNode = createMockNodeDef({
|
||||
name: 'deprecated',
|
||||
deprecated: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, deprecatedNode])
|
||||
|
||||
// Register filter that hides deprecated nodes
|
||||
store.registerNodeDefFilter({
|
||||
id: 'test.no-deprecated',
|
||||
name: 'Hide Deprecated',
|
||||
predicate: (node) => !node.deprecated
|
||||
})
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
expect(store.visibleNodeDefs[0].name).toBe('normal')
|
||||
})
|
||||
|
||||
it('should apply multiple filters with AND logic', () => {
|
||||
const node1 = createMockNodeDef({
|
||||
name: 'node1',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
})
|
||||
const node2 = createMockNodeDef({
|
||||
name: 'node2',
|
||||
deprecated: true,
|
||||
experimental: false
|
||||
})
|
||||
const node3 = createMockNodeDef({
|
||||
name: 'node3',
|
||||
deprecated: false,
|
||||
experimental: true
|
||||
})
|
||||
const node4 = createMockNodeDef({
|
||||
name: 'node4',
|
||||
deprecated: true,
|
||||
experimental: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([node1, node2, node3, node4])
|
||||
|
||||
// Register filters
|
||||
store.registerNodeDefFilter({
|
||||
id: 'test.no-deprecated',
|
||||
name: 'Hide Deprecated',
|
||||
predicate: (node) => !node.deprecated
|
||||
})
|
||||
|
||||
store.registerNodeDefFilter({
|
||||
id: 'test.no-experimental',
|
||||
name: 'Hide Experimental',
|
||||
predicate: (node) => !node.experimental
|
||||
})
|
||||
|
||||
// Only node1 should be visible (not deprecated AND not experimental)
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
expect(store.visibleNodeDefs[0].name).toBe('node1')
|
||||
})
|
||||
|
||||
it('should show all nodes when no filters are registered', () => {
|
||||
const nodes = [
|
||||
createMockNodeDef({ name: 'node1' }),
|
||||
createMockNodeDef({ name: 'node2' }),
|
||||
createMockNodeDef({ name: 'node3' })
|
||||
]
|
||||
|
||||
store.updateNodeDefs(nodes)
|
||||
expect(store.visibleNodeDefs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should update visibility when filter is removed', () => {
|
||||
const deprecatedNode = createMockNodeDef({
|
||||
name: 'deprecated',
|
||||
deprecated: true
|
||||
})
|
||||
store.updateNodeDefs([deprecatedNode])
|
||||
|
||||
const filter: NodeDefFilter = {
|
||||
id: 'test.no-deprecated',
|
||||
name: 'Hide Deprecated',
|
||||
predicate: (node) => !node.deprecated
|
||||
}
|
||||
|
||||
// Add filter - node should be hidden
|
||||
store.registerNodeDefFilter(filter)
|
||||
expect(store.visibleNodeDefs).toHaveLength(0)
|
||||
|
||||
// Remove filter - node should be visible
|
||||
store.unregisterNodeDefFilter('test.no-deprecated')
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('core filters behavior', () => {
|
||||
it('should hide deprecated nodes by default', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
deprecated: false
|
||||
})
|
||||
const deprecatedNode = createMockNodeDef({
|
||||
name: 'deprecated',
|
||||
deprecated: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, deprecatedNode])
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
expect(store.visibleNodeDefs[0].name).toBe('normal')
|
||||
})
|
||||
|
||||
it('should show deprecated nodes when showDeprecated is true', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
deprecated: false
|
||||
})
|
||||
const deprecatedNode = createMockNodeDef({
|
||||
name: 'deprecated',
|
||||
deprecated: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, deprecatedNode])
|
||||
store.showDeprecated = true
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should hide experimental nodes by default', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
experimental: false
|
||||
})
|
||||
const experimentalNode = createMockNodeDef({
|
||||
name: 'experimental',
|
||||
experimental: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, experimentalNode])
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
expect(store.visibleNodeDefs[0].name).toBe('normal')
|
||||
})
|
||||
|
||||
it('should show experimental nodes when showExperimental is true', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
experimental: false
|
||||
})
|
||||
const experimentalNode = createMockNodeDef({
|
||||
name: 'experimental',
|
||||
experimental: true
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, experimentalNode])
|
||||
store.showExperimental = true
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should hide subgraph nodes by default', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
category: 'conditioning',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
const subgraphNode = createMockNodeDef({
|
||||
name: 'MySubgraph',
|
||||
category: 'subgraph',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, subgraphNode])
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(1)
|
||||
expect(store.visibleNodeDefs[0].name).toBe('normal')
|
||||
})
|
||||
|
||||
it('should show non-subgraph nodes with subgraph category', () => {
|
||||
const normalNode = createMockNodeDef({
|
||||
name: 'normal',
|
||||
category: 'conditioning',
|
||||
python_module: 'custom_extension'
|
||||
})
|
||||
const fakeSubgraphNode = createMockNodeDef({
|
||||
name: 'FakeSubgraph',
|
||||
category: 'subgraph',
|
||||
python_module: 'custom_extension' // Different python_module
|
||||
})
|
||||
|
||||
store.updateNodeDefs([normalNode, fakeSubgraphNode])
|
||||
|
||||
expect(store.visibleNodeDefs).toHaveLength(2)
|
||||
expect(store.visibleNodeDefs.map((n) => n.name)).toEqual([
|
||||
'normal',
|
||||
'FakeSubgraph'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance', () => {
|
||||
it('should perform single traversal for multiple filters', () => {
|
||||
let filterCallCount = 0
|
||||
|
||||
// Register multiple filters that count their calls
|
||||
for (let i = 0; i < 5; i++) {
|
||||
store.registerNodeDefFilter({
|
||||
id: `test.counter-${i}`,
|
||||
name: `Counter ${i}`,
|
||||
predicate: () => {
|
||||
filterCallCount++
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const nodes = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockNodeDef({ name: `node${i}` })
|
||||
)
|
||||
store.updateNodeDefs(nodes)
|
||||
|
||||
// Force recomputation by accessing visibleNodeDefs
|
||||
expect(store.visibleNodeDefs).toBeDefined()
|
||||
|
||||
// Each node (10) should be checked by each filter (5 test + 2 core = 7 total)
|
||||
expect(filterCallCount).toBe(10 * 5)
|
||||
})
|
||||
})
|
||||
})
|
||||
113
tests-ui/tests/store/subgraphNavigationStore.test.ts
Normal file
113
tests-ui/tests/store/subgraphNavigationStore.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import type { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
subgraphs: new Map(),
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
canvas: {
|
||||
subgraph: null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
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 clear navigation stack when switching to a different workflow', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Mock first workflow
|
||||
const workflow1 = {
|
||||
path: 'workflow1.json',
|
||||
filename: 'workflow1.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
// Set the active workflow
|
||||
workflowStore.activeWorkflow = workflow1 as any
|
||||
|
||||
// Simulate being in a subgraph
|
||||
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
|
||||
|
||||
expect(navigationStore.exportState()).toHaveLength(2)
|
||||
|
||||
// Switch to a different workflow
|
||||
const workflow2 = {
|
||||
path: 'workflow2.json',
|
||||
filename: 'workflow2.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow = workflow2 as any
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
await nextTick()
|
||||
|
||||
// The navigation stack SHOULD be cleared because we switched workflows
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle null workflow gracefully', async () => {
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// First set an active workflow
|
||||
const mockWorkflow = {
|
||||
path: 'test-workflow.json',
|
||||
filename: 'test-workflow.json'
|
||||
} as ComfyWorkflow
|
||||
|
||||
workflowStore.activeWorkflow = mockWorkflow as any
|
||||
await nextTick()
|
||||
|
||||
// Add some items to the navigation stack
|
||||
navigationStore.restoreState(['subgraph-1'])
|
||||
expect(navigationStore.exportState()).toHaveLength(1)
|
||||
|
||||
// Set workflow to null
|
||||
workflowStore.activeWorkflow = null
|
||||
|
||||
// Wait for Vue's reactivity to process the change
|
||||
await nextTick()
|
||||
|
||||
// Stack should be cleared when workflow becomes null
|
||||
expect(navigationStore.exportState()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -41,6 +41,7 @@ describe('useSystemStatsStore', () => {
|
||||
embedded_python: false,
|
||||
comfyui_version: '1.0.0',
|
||||
pytorch_version: '2.0.0',
|
||||
required_frontend_version: '1.24.0',
|
||||
argv: [],
|
||||
ram_total: 16000000000,
|
||||
ram_free: 8000000000
|
||||
@@ -92,6 +93,32 @@ describe('useSystemStatsStore', () => {
|
||||
|
||||
expect(store.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle system stats updates', async () => {
|
||||
const updatedStats = {
|
||||
system: {
|
||||
os: 'Windows',
|
||||
python_version: '3.11.0',
|
||||
embedded_python: false,
|
||||
comfyui_version: '1.1.0',
|
||||
pytorch_version: '2.1.0',
|
||||
required_frontend_version: '1.25.0',
|
||||
argv: [],
|
||||
ram_total: 16000000000,
|
||||
ram_free: 7000000000
|
||||
},
|
||||
devices: []
|
||||
}
|
||||
|
||||
vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats)
|
||||
|
||||
await store.fetchSystemStats()
|
||||
|
||||
expect(store.systemStats).toEqual(updatedStats)
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(api.getSystemStats).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormFactor', () => {
|
||||
|
||||
321
tests-ui/tests/store/versionCompatibilityStore.test.ts
Normal file
321
tests-ui/tests/store/versionCompatibilityStore.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
default: {
|
||||
app_version: '1.24.0'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/systemStatsStore')
|
||||
|
||||
// Mock useStorage from VueUse
|
||||
const mockDismissalStorage = ref({} as Record<string, number>)
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useStorage: vi.fn(() => mockDismissalStorage)
|
||||
}))
|
||||
|
||||
describe('useVersionCompatibilityStore', () => {
|
||||
let store: ReturnType<typeof useVersionCompatibilityStore>
|
||||
let mockSystemStatsStore: any
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Clear the mock dismissal storage
|
||||
mockDismissalStorage.value = {}
|
||||
|
||||
mockSystemStatsStore = {
|
||||
systemStats: null,
|
||||
fetchSystemStats: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
|
||||
|
||||
store = useVersionCompatibilityStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('version compatibility detection', () => {
|
||||
it('should detect frontend is outdated when required version is higher', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(true)
|
||||
expect(store.isFrontendNewer).toBe(false)
|
||||
expect(store.hasVersionMismatch).toBe(true)
|
||||
})
|
||||
|
||||
it('should not warn when frontend is newer than backend', async () => {
|
||||
// Frontend: 1.24.0, Backend: 1.23.0, Required: 1.23.0
|
||||
// Frontend meets required version, no warning needed
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.23.0',
|
||||
required_frontend_version: '1.23.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(false)
|
||||
expect(store.isFrontendNewer).toBe(false)
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect mismatch when versions are compatible', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(false)
|
||||
expect(store.isFrontendNewer).toBe(false)
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle missing version information gracefully', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '',
|
||||
required_frontend_version: ''
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(false)
|
||||
expect(store.isFrontendNewer).toBe(false)
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
|
||||
it('should not detect mismatch when versions are not valid semver', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '080e6d4af809a46852d1c4b7ed85f06e8a3a72be', // git hash
|
||||
required_frontend_version: 'not-a-version' // invalid semver format
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(false)
|
||||
expect(store.isFrontendNewer).toBe(false)
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
|
||||
it('should not warn when frontend exceeds required version', async () => {
|
||||
// Frontend: 1.24.0 (from mock config)
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.22.0', // Backend is older
|
||||
required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.isFrontendOutdated).toBe(false) // Frontend 1.24.0 >= Required 1.23.0
|
||||
expect(store.isFrontendNewer).toBe(false) // Never warns about being newer
|
||||
expect(store.hasVersionMismatch).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('warning display logic', () => {
|
||||
it('should show warning when there is a version mismatch and not dismissed', async () => {
|
||||
// No dismissals in storage
|
||||
mockDismissalStorage.value = {}
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show warning when dismissed', async () => {
|
||||
const futureTime = Date.now() + 1000000
|
||||
// Set dismissal in reactive storage
|
||||
mockDismissalStorage.value = {
|
||||
'1.24.0-1.25.0-1.25.0': futureTime
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(false)
|
||||
})
|
||||
|
||||
it('should not show warning when no version mismatch', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('warning messages', () => {
|
||||
it('should generate outdated message when frontend is outdated', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.warningMessage).toEqual({
|
||||
type: 'outdated',
|
||||
frontendVersion: '1.24.0',
|
||||
requiredVersion: '1.25.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when no mismatch', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
|
||||
expect(store.warningMessage).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissal persistence', () => {
|
||||
it('should save dismissal to reactive storage with expiration', async () => {
|
||||
const mockNow = 1000000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(mockNow)
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.checkVersionCompatibility()
|
||||
store.dismissWarning()
|
||||
|
||||
// Check that the dismissal was added to reactive storage
|
||||
expect(mockDismissalStorage.value).toEqual({
|
||||
'1.24.0-1.25.0-1.25.0': mockNow + 7 * 24 * 60 * 60 * 1000
|
||||
})
|
||||
})
|
||||
|
||||
it('should check dismissal state from reactive storage', async () => {
|
||||
const futureTime = Date.now() + 1000000 // Still valid
|
||||
mockDismissalStorage.value = {
|
||||
'1.24.0-1.25.0-1.25.0': futureTime
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(false)
|
||||
})
|
||||
|
||||
it('should show warning if dismissal has expired', async () => {
|
||||
const pastTime = Date.now() - 1000 // Expired
|
||||
mockDismissalStorage.value = {
|
||||
'1.24.0-1.25.0-1.25.0': pastTime
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.25.0',
|
||||
required_frontend_version: '1.25.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(true)
|
||||
})
|
||||
|
||||
it('should show warning for different version combinations even if previous was dismissed', async () => {
|
||||
const futureTime = Date.now() + 1000000
|
||||
// Dismissed for different version combination (1.25.0) but current is 1.26.0
|
||||
mockDismissalStorage.value = {
|
||||
'1.24.0-1.25.0-1.25.0': futureTime // Different version was dismissed
|
||||
}
|
||||
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.26.0',
|
||||
required_frontend_version: '1.26.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(store.shouldShowWarning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should fetch system stats if not available', async () => {
|
||||
mockSystemStatsStore.systemStats = null
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch system stats if already available', async () => {
|
||||
mockSystemStatsStore.systemStats = {
|
||||
system: {
|
||||
comfyui_version: '1.24.0',
|
||||
required_frontend_version: '1.24.0'
|
||||
}
|
||||
}
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/stores/workflowStore'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({
|
||||
// Mock comfyApp globally for the store setup
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null // Start with canvas potentially undefined or null
|
||||
canvas: {} // Start with empty canvas object
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock isSubgraph
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isSubgraph: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('useWorkflowStore', () => {
|
||||
let store: ReturnType<typeof useWorkflowStore>
|
||||
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
|
||||
@@ -518,8 +525,13 @@ describe('useWorkflowStore', () => {
|
||||
{ name: 'Level 1 Subgraph' },
|
||||
{ name: 'Level 2 Subgraph' }
|
||||
]
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
} as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(obj): obj is Subgraph => obj === mockSubgraph
|
||||
)
|
||||
|
||||
// Act: Trigger the update
|
||||
store.updateActiveGraph()
|
||||
@@ -536,8 +548,13 @@ describe('useWorkflowStore', () => {
|
||||
name: 'Initial Subgraph',
|
||||
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
|
||||
isRootGraph: false
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
|
||||
} as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our initialSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(obj): obj is Subgraph => obj === initialSubgraph
|
||||
)
|
||||
|
||||
// Trigger initial update based on the *first* workflow opened in beforeEach
|
||||
store.updateActiveGraph()
|
||||
@@ -561,6 +578,11 @@ describe('useWorkflowStore', () => {
|
||||
// This ensures the watcher *does* cause a state change we can assert
|
||||
vi.mocked(comfyApp.canvas).subgraph = undefined
|
||||
|
||||
// Mock isSubgraph to return false for undefined
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(_obj): _obj is Subgraph => false
|
||||
)
|
||||
|
||||
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
|
||||
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
|
||||
|
||||
@@ -569,4 +591,131 @@ describe('useWorkflowStore', () => {
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeLocatorId conversions', () => {
|
||||
beforeEach(() => {
|
||||
// Setup mock graph structure with subgraphs
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
_nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
}
|
||||
|
||||
const mockRootGraph = {
|
||||
_nodes: [mockNode],
|
||||
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
|
||||
getNodeById: (id: string | number) => {
|
||||
if (String(id) === '123') return mockNode
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(comfyApp).graph = mockRootGraph as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
store.activeSubgraph = mockSubgraph as any
|
||||
})
|
||||
|
||||
describe('nodeIdToNodeLocatorId', () => {
|
||||
it('should convert node ID to NodeLocatorId for subgraph nodes', () => {
|
||||
const result = store.nodeIdToNodeLocatorId(456)
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should return simple node ID for root graph nodes', () => {
|
||||
store.activeSubgraph = undefined
|
||||
const result = store.nodeIdToNodeLocatorId(123)
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should use provided subgraph instead of active one', () => {
|
||||
const customSubgraph = {
|
||||
id: 'custom-uuid-1234-5678-90ab-cdef12345678'
|
||||
} as any
|
||||
const result = store.nodeIdToNodeLocatorId(789, customSubgraph)
|
||||
expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeExecutionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should return simple node ID for root level nodes', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for invalid execution IDs', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeId', () => {
|
||||
it('should extract node ID from NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe(456)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
|
||||
)
|
||||
expect(result).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('123')
|
||||
expect(result).toBe(123)
|
||||
|
||||
const stringResult = store.nodeLocatorIdToNodeId('node_1')
|
||||
expect(stringResult).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
// Need to mock isSubgraph to identify our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
|
||||
return obj === store.activeSubgraph
|
||||
})
|
||||
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe('123:456')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for unknown subgraph UUID', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
207
tests-ui/tests/types/nodeIdentification.test.ts
Normal file
207
tests-ui/tests/types/nodeIdentification.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import {
|
||||
type NodeLocatorId,
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
isNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
describe('nodeIdentification', () => {
|
||||
describe('NodeLocatorId', () => {
|
||||
const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const validNodeId = '123'
|
||||
const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId
|
||||
|
||||
describe('isNodeLocatorId', () => {
|
||||
it('should return true for valid NodeLocatorId', () => {
|
||||
expect(isNodeLocatorId(validNodeLocatorId)).toBe(true)
|
||||
expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true)
|
||||
expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true)
|
||||
// Simple node IDs (root graph)
|
||||
expect(isNodeLocatorId('123')).toBe(true)
|
||||
expect(isNodeLocatorId('node_1')).toBe(true)
|
||||
expect(isNodeLocatorId('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid formats', () => {
|
||||
expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part
|
||||
expect(isNodeLocatorId('not-a-uuid:123')).toBe(false)
|
||||
expect(isNodeLocatorId('')).toBe(false) // Empty string
|
||||
expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID
|
||||
expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID
|
||||
expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts
|
||||
expect(isNodeLocatorId(123)).toBe(false) // Not a string
|
||||
expect(isNodeLocatorId(null)).toBe(false)
|
||||
expect(isNodeLocatorId(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate UUID format correctly', () => {
|
||||
// Valid UUID formats
|
||||
expect(
|
||||
isNodeLocatorId('00000000-0000-0000-0000-000000000000:123')
|
||||
).toBe(true)
|
||||
expect(
|
||||
isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123')
|
||||
).toBe(true)
|
||||
|
||||
// Invalid UUID formats
|
||||
expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe(
|
||||
false
|
||||
) // Too short
|
||||
expect(
|
||||
isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123')
|
||||
).toBe(false) // Too long
|
||||
expect(
|
||||
isNodeLocatorId('00000000_0000_0000_0000_000000000000:123')
|
||||
).toBe(false) // Wrong separator
|
||||
expect(
|
||||
isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123')
|
||||
).toBe(false) // Invalid hex
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNodeLocatorId', () => {
|
||||
it('should parse valid NodeLocatorId', () => {
|
||||
const result = parseNodeLocatorId(validNodeLocatorId)
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: validUuid,
|
||||
localNodeId: 123
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const stringNodeId = `${validUuid}:node_1`
|
||||
const result = parseNodeLocatorId(stringNodeId)
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: validUuid,
|
||||
localNodeId: 'node_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = parseNodeLocatorId('123')
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: null,
|
||||
localNodeId: 123
|
||||
})
|
||||
|
||||
const stringResult = parseNodeLocatorId('node_1')
|
||||
expect(stringResult).toEqual({
|
||||
subgraphUuid: null,
|
||||
localNodeId: 'node_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null for invalid formats', () => {
|
||||
expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part
|
||||
expect(parseNodeLocatorId('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNodeLocatorId', () => {
|
||||
it('should create NodeLocatorId from components', () => {
|
||||
const result = createNodeLocatorId(validUuid, 123)
|
||||
expect(result).toBe(validNodeLocatorId)
|
||||
expect(isNodeLocatorId(result)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = createNodeLocatorId(validUuid, 'node_1')
|
||||
expect(result).toBe(`${validUuid}:node_1`)
|
||||
expect(isNodeLocatorId(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeExecutionId', () => {
|
||||
describe('isNodeExecutionId', () => {
|
||||
it('should return true for execution IDs', () => {
|
||||
expect(isNodeExecutionId('123:456')).toBe(true)
|
||||
expect(isNodeExecutionId('123:456:789')).toBe(true)
|
||||
expect(isNodeExecutionId('node_1:node_2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-execution IDs', () => {
|
||||
expect(isNodeExecutionId('123')).toBe(false)
|
||||
expect(isNodeExecutionId('node_1')).toBe(false)
|
||||
expect(isNodeExecutionId('')).toBe(false)
|
||||
expect(isNodeExecutionId(123)).toBe(false)
|
||||
expect(isNodeExecutionId(null)).toBe(false)
|
||||
expect(isNodeExecutionId(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNodeExecutionId', () => {
|
||||
it('should parse execution IDs correctly', () => {
|
||||
expect(parseNodeExecutionId('123:456')).toEqual([123, 456])
|
||||
expect(parseNodeExecutionId('123:456:789')).toEqual([123, 456, 789])
|
||||
expect(parseNodeExecutionId('node_1:node_2')).toEqual([
|
||||
'node_1',
|
||||
'node_2'
|
||||
])
|
||||
expect(parseNodeExecutionId('123:node_2:456')).toEqual([
|
||||
123,
|
||||
'node_2',
|
||||
456
|
||||
])
|
||||
})
|
||||
|
||||
it('should return null for non-execution IDs', () => {
|
||||
expect(parseNodeExecutionId('123')).toBeNull()
|
||||
expect(parseNodeExecutionId('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNodeExecutionId', () => {
|
||||
it('should create execution IDs from node arrays', () => {
|
||||
expect(createNodeExecutionId([123, 456])).toBe('123:456')
|
||||
expect(createNodeExecutionId([123, 456, 789])).toBe('123:456:789')
|
||||
expect(createNodeExecutionId(['node_1', 'node_2'])).toBe(
|
||||
'node_1:node_2'
|
||||
)
|
||||
expect(createNodeExecutionId([123, 'node_2', 456])).toBe(
|
||||
'123:node_2:456'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle single node ID', () => {
|
||||
const result = createNodeExecutionId([123])
|
||||
expect(result).toBe('123')
|
||||
// Single node IDs are not execution IDs
|
||||
expect(isNodeExecutionId(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(createNodeExecutionId([])).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration tests', () => {
|
||||
it('should round-trip NodeLocatorId correctly', () => {
|
||||
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const nodeId: NodeId = 123
|
||||
|
||||
const locatorId = createNodeLocatorId(uuid, nodeId)
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
|
||||
expect(parsed).toBeTruthy()
|
||||
expect(parsed!.subgraphUuid).toBe(uuid)
|
||||
expect(parsed!.localNodeId).toBe(nodeId)
|
||||
})
|
||||
|
||||
it('should round-trip NodeExecutionId correctly', () => {
|
||||
const nodeIds: NodeId[] = [123, 'node_2', 456]
|
||||
|
||||
const executionId = createNodeExecutionId(nodeIds)
|
||||
const parsed = parseNodeExecutionId(executionId)
|
||||
|
||||
expect(parsed).toEqual(nodeIds)
|
||||
})
|
||||
})
|
||||
})
|
||||
811
tests-ui/tests/utils/graphTraversalUtil.test.ts
Normal file
811
tests-ui/tests/utils/graphTraversalUtil.test.ts
Normal file
@@ -0,0 +1,811 @@
|
||||
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
collectAllNodes,
|
||||
findNodeInHierarchy,
|
||||
findSubgraphByUuid,
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getLocalNodeIdFromExecutionId,
|
||||
getNodeByExecutionId,
|
||||
getNodeByLocatorId,
|
||||
getRootGraph,
|
||||
getSubgraphPathFromExecutionId,
|
||||
mapAllNodes,
|
||||
mapSubgraphNodes,
|
||||
parseExecutionId,
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
visitGraphNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
// Mock node factory
|
||||
function createMockNode(
|
||||
id: string | number,
|
||||
options: {
|
||||
isSubgraph?: boolean
|
||||
subgraph?: Subgraph
|
||||
callback?: () => void
|
||||
} = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
id,
|
||||
isSubgraphNode: options.isSubgraph ? () => true : undefined,
|
||||
subgraph: options.subgraph,
|
||||
onExecutionStart: options.callback
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
// Mock graph factory
|
||||
function createMockGraph(nodes: LGraphNode[]): LGraph {
|
||||
return {
|
||||
_nodes: nodes,
|
||||
nodes: nodes,
|
||||
getNodeById: (id: string | number) =>
|
||||
nodes.find((n) => String(n.id) === String(id)) || null
|
||||
} as unknown as LGraph
|
||||
}
|
||||
|
||||
// Mock subgraph factory
|
||||
function createMockSubgraph(id: string, nodes: LGraphNode[]): Subgraph {
|
||||
return {
|
||||
id,
|
||||
_nodes: nodes,
|
||||
nodes: nodes,
|
||||
getNodeById: (nodeId: string | number) =>
|
||||
nodes.find((n) => String(n.id) === String(nodeId)) || null
|
||||
} as unknown as Subgraph
|
||||
}
|
||||
|
||||
describe('graphTraversalUtil', () => {
|
||||
describe('Pure utility functions', () => {
|
||||
describe('parseExecutionId', () => {
|
||||
it('should parse simple execution ID', () => {
|
||||
expect(parseExecutionId('123')).toEqual(['123'])
|
||||
})
|
||||
|
||||
it('should parse complex execution ID', () => {
|
||||
expect(parseExecutionId('123:456:789')).toEqual(['123', '456', '789'])
|
||||
})
|
||||
|
||||
it('should handle empty parts', () => {
|
||||
expect(parseExecutionId('123::789')).toEqual(['123', '789'])
|
||||
})
|
||||
|
||||
it('should return null for invalid input', () => {
|
||||
expect(parseExecutionId('')).toBeNull()
|
||||
expect(parseExecutionId(null as any)).toBeNull()
|
||||
expect(parseExecutionId(undefined as any)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLocalNodeIdFromExecutionId', () => {
|
||||
it('should extract local node ID from simple ID', () => {
|
||||
expect(getLocalNodeIdFromExecutionId('123')).toBe('123')
|
||||
})
|
||||
|
||||
it('should extract local node ID from complex ID', () => {
|
||||
expect(getLocalNodeIdFromExecutionId('123:456:789')).toBe('789')
|
||||
})
|
||||
|
||||
it('should return null for invalid input', () => {
|
||||
expect(getLocalNodeIdFromExecutionId('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSubgraphPathFromExecutionId', () => {
|
||||
it('should return empty array for root node', () => {
|
||||
expect(getSubgraphPathFromExecutionId('123')).toEqual([])
|
||||
})
|
||||
|
||||
it('should return subgraph path for nested node', () => {
|
||||
expect(getSubgraphPathFromExecutionId('123:456:789')).toEqual([
|
||||
'123',
|
||||
'456'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return empty array for invalid input', () => {
|
||||
expect(getSubgraphPathFromExecutionId('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('visitGraphNodes', () => {
|
||||
it('should visit all nodes in graph', () => {
|
||||
const visited: number[] = []
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
visitGraphNodes(graph, (node) => {
|
||||
visited.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(visited).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should handle empty graph', () => {
|
||||
const visited: number[] = []
|
||||
const graph = createMockGraph([])
|
||||
|
||||
visitGraphNodes(graph, (node) => {
|
||||
visited.push(node.id as number)
|
||||
})
|
||||
|
||||
expect(visited).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('traverseSubgraphPath', () => {
|
||||
it('should return start graph for empty path', () => {
|
||||
const graph = createMockGraph([])
|
||||
const result = traverseSubgraphPath(graph, [])
|
||||
expect(result).toBe(graph)
|
||||
})
|
||||
|
||||
it('should traverse single level', () => {
|
||||
const subgraph = createMockSubgraph('sub-uuid', [])
|
||||
const node = createMockNode('1', { isSubgraph: true, subgraph })
|
||||
const graph = createMockGraph([node])
|
||||
|
||||
const result = traverseSubgraphPath(graph, ['1'])
|
||||
expect(result).toBe(subgraph)
|
||||
})
|
||||
|
||||
it('should traverse multiple levels', () => {
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [])
|
||||
const midNode = createMockNode('2', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
const topNode = createMockNode('1', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
const graph = createMockGraph([topNode])
|
||||
|
||||
const result = traverseSubgraphPath(graph, ['1', '2'])
|
||||
expect(result).toBe(deepSubgraph)
|
||||
})
|
||||
|
||||
it('should return null for invalid path', () => {
|
||||
const graph = createMockGraph([createMockNode('1')])
|
||||
const result = traverseSubgraphPath(graph, ['999'])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Main functions', () => {
|
||||
describe('triggerCallbackOnAllNodes', () => {
|
||||
it('should trigger callbacks on all nodes in a flat graph', () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
const node1 = createMockNode(1, { callback: callback1 })
|
||||
const node2 = createMockNode(2, { callback: callback2 })
|
||||
const node3 = createMockNode(3) // No callback
|
||||
|
||||
const graph = createMockGraph([node1, node2, node3])
|
||||
|
||||
triggerCallbackOnAllNodes(graph, 'onExecutionStart')
|
||||
|
||||
expect(callback1).toHaveBeenCalledOnce()
|
||||
expect(callback2).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should trigger callbacks on nodes in subgraphs', () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
const callback3 = vi.fn()
|
||||
|
||||
// Create a subgraph with one node
|
||||
const subNode = createMockNode(100, { callback: callback3 })
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
|
||||
// Create main graph with two nodes, one being a subgraph
|
||||
const node1 = createMockNode(1, { callback: callback1 })
|
||||
const node2 = createMockNode(2, {
|
||||
isSubgraph: true,
|
||||
subgraph,
|
||||
callback: callback2
|
||||
})
|
||||
|
||||
const graph = createMockGraph([node1, node2])
|
||||
|
||||
triggerCallbackOnAllNodes(graph, 'onExecutionStart')
|
||||
|
||||
expect(callback1).toHaveBeenCalledOnce()
|
||||
expect(callback2).toHaveBeenCalledOnce()
|
||||
expect(callback3).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('should handle nested subgraphs', () => {
|
||||
const callbacks = [vi.fn(), vi.fn(), vi.fn(), vi.fn()]
|
||||
|
||||
// Create deeply nested structure
|
||||
const deepNode = createMockNode(300, { callback: callbacks[3] })
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode1 = createMockNode(200, { callback: callbacks[2] })
|
||||
const midNode2 = createMockNode(201, {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
|
||||
|
||||
const node1 = createMockNode(1, { callback: callbacks[0] })
|
||||
const node2 = createMockNode(2, {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph,
|
||||
callback: callbacks[1]
|
||||
})
|
||||
|
||||
const graph = createMockGraph([node1, node2])
|
||||
|
||||
triggerCallbackOnAllNodes(graph, 'onExecutionStart')
|
||||
|
||||
callbacks.forEach((cb) => expect(cb).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectAllNodes', () => {
|
||||
it('should collect all nodes from a flat graph', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const collected = collectAllNodes(graph)
|
||||
|
||||
expect(collected).toHaveLength(3)
|
||||
expect(collected.map((n) => n.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should collect nodes from 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 collected = collectAllNodes(graph)
|
||||
|
||||
expect(collected).toHaveLength(3)
|
||||
expect(collected.map((n) => n.id)).toContain(100)
|
||||
})
|
||||
|
||||
it('should filter nodes when filter function provided', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const collected = collectAllNodes(graph, (node) => Number(node.id) > 1)
|
||||
|
||||
expect(collected).toHaveLength(2)
|
||||
expect(collected.map((n) => n.id)).toEqual([2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
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)]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const found = findNodeInHierarchy(graph, 2)
|
||||
|
||||
expect(found).toBeTruthy()
|
||||
expect(found?.id).toBe(2)
|
||||
})
|
||||
|
||||
it('should find node in subgraph', () => {
|
||||
const subNode = createMockNode(100)
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
createMockNode(2, { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const found = findNodeInHierarchy(graph, 100)
|
||||
|
||||
expect(found).toBeTruthy()
|
||||
expect(found?.id).toBe(100)
|
||||
})
|
||||
|
||||
it('should return null for non-existent node', () => {
|
||||
const nodes = [createMockNode(1), createMockNode(2)]
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const found = findNodeInHierarchy(graph, 999)
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSubgraphByUuid', () => {
|
||||
it('should find subgraph by UUID', () => {
|
||||
const targetUuid = 'target-uuid'
|
||||
const subgraph = createMockSubgraph(targetUuid, [])
|
||||
|
||||
const nodes = [
|
||||
createMockNode(1),
|
||||
createMockNode(2, { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const found = findSubgraphByUuid(graph, targetUuid)
|
||||
|
||||
expect(found).toBe(subgraph)
|
||||
expect(found?.id).toBe(targetUuid)
|
||||
})
|
||||
|
||||
it('should find nested subgraph', () => {
|
||||
const targetUuid = 'deep-uuid'
|
||||
const deepSubgraph = createMockSubgraph(targetUuid, [])
|
||||
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [
|
||||
createMockNode(200, { isSubgraph: true, subgraph: deepSubgraph })
|
||||
])
|
||||
|
||||
const graph = createMockGraph([
|
||||
createMockNode(1, { isSubgraph: true, subgraph: midSubgraph })
|
||||
])
|
||||
|
||||
const found = findSubgraphByUuid(graph, targetUuid)
|
||||
|
||||
expect(found).toBe(deepSubgraph)
|
||||
expect(found?.id).toBe(targetUuid)
|
||||
})
|
||||
|
||||
it('should return null for non-existent UUID', () => {
|
||||
const subgraph = createMockSubgraph('some-uuid', [])
|
||||
const graph = createMockGraph([
|
||||
createMockNode(1, { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
const found = findSubgraphByUuid(graph, 'non-existent-uuid')
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeByExecutionId', () => {
|
||||
it('should find node in root graph', () => {
|
||||
const nodes = [createMockNode('123'), createMockNode('456')]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const found = getNodeByExecutionId(graph, '123')
|
||||
|
||||
expect(found).toBeTruthy()
|
||||
expect(found?.id).toBe('123')
|
||||
})
|
||||
|
||||
it('should find node in subgraph using execution path', () => {
|
||||
const targetNode = createMockNode('789')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [targetNode])
|
||||
|
||||
const subgraphNode = createMockNode('456', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
|
||||
const graph = createMockGraph([createMockNode('123'), subgraphNode])
|
||||
|
||||
const found = getNodeByExecutionId(graph, '456:789')
|
||||
|
||||
expect(found).toBe(targetNode)
|
||||
expect(found?.id).toBe('789')
|
||||
})
|
||||
|
||||
it('should handle deeply nested execution paths', () => {
|
||||
const targetNode = createMockNode('999')
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [targetNode])
|
||||
|
||||
const midNode = createMockNode('456', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
|
||||
const topNode = createMockNode('123', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
const graph = createMockGraph([topNode])
|
||||
|
||||
const found = getNodeByExecutionId(graph, '123:456:999')
|
||||
|
||||
expect(found).toBe(targetNode)
|
||||
expect(found?.id).toBe('999')
|
||||
})
|
||||
|
||||
it('should return null for invalid path', () => {
|
||||
const subgraph = createMockSubgraph('sub-uuid', [createMockNode('789')])
|
||||
const graph = createMockGraph([
|
||||
createMockNode('456', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
// Wrong path - node 123 doesn't exist
|
||||
const found = getNodeByExecutionId(graph, '123:789')
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid execution ID', () => {
|
||||
const graph = createMockGraph([createMockNode('123')])
|
||||
const found = getNodeByExecutionId(graph, '')
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeByLocatorId', () => {
|
||||
it('should find node in root graph', () => {
|
||||
const nodes = [createMockNode('123'), createMockNode('456')]
|
||||
|
||||
const graph = createMockGraph(nodes)
|
||||
const found = getNodeByLocatorId(graph, '123')
|
||||
|
||||
expect(found).toBeTruthy()
|
||||
expect(found?.id).toBe('123')
|
||||
})
|
||||
|
||||
it('should find node in subgraph using UUID format', () => {
|
||||
const targetUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const targetNode = createMockNode('789')
|
||||
const subgraph = createMockSubgraph(targetUuid, [targetNode])
|
||||
|
||||
const graph = createMockGraph([
|
||||
createMockNode('123'),
|
||||
createMockNode('456', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
const locatorId = `${targetUuid}:789`
|
||||
const found = getNodeByLocatorId(graph, locatorId)
|
||||
|
||||
expect(found).toBe(targetNode)
|
||||
expect(found?.id).toBe('789')
|
||||
})
|
||||
|
||||
it('should return null for invalid locator ID', () => {
|
||||
const graph = createMockGraph([createMockNode('123')])
|
||||
|
||||
const found = getNodeByLocatorId(graph, 'invalid:::format')
|
||||
expect(found).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when subgraph UUID not found', () => {
|
||||
const subgraph = createMockSubgraph('some-uuid', [
|
||||
createMockNode('789')
|
||||
])
|
||||
const graph = createMockGraph([
|
||||
createMockNode('456', { isSubgraph: true, subgraph })
|
||||
])
|
||||
|
||||
const locatorId = 'non-existent-uuid:789'
|
||||
const found = getNodeByLocatorId(graph, locatorId)
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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