chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -1,210 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
describe('API Feature Flags', () => {
let mockWebSocket: any
const wsEventHandlers: { [key: string]: (event: any) => void } = {}
beforeEach(() => {
// Use fake timers
vi.useFakeTimers()
// Mock WebSocket
mockWebSocket = {
readyState: 1, // WebSocket.OPEN
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(
(event: string, handler: (event: any) => void) => {
wsEventHandlers[event] = handler
}
),
removeEventListener: vi.fn()
}
// Mock WebSocket constructor
vi.stubGlobal('WebSocket', function (this: WebSocket) {
Object.assign(this, mockWebSocket)
})
// Reset API state
api.serverFeatureFlags = {}
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('Feature flags negotiation', () => {
it('should send client feature flags as first message on connection', async () => {
// Initialize API connection
const initPromise = api.init()
// Simulate connection open
wsEventHandlers['open'](new Event('open'))
// Check that feature flags were sent as first message
expect(mockWebSocket.send).toHaveBeenCalledTimes(1)
const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0])
expect(sentMessage).toEqual({
type: 'feature_flags',
data: {
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
}
})
// Simulate server response with status message
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
// Simulate server feature flags response
wsEventHandlers['message']({
data: JSON.stringify({
type: 'feature_flags',
data: {
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
api_version: '1.0.0',
max_upload_size: 104857600,
capabilities: ['isolated_nodes', 'dynamic_models']
}
})
})
await initPromise
// Check that server features were stored
expect(api.serverFeatureFlags).toEqual({
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
api_version: '1.0.0',
max_upload_size: 104857600,
capabilities: ['isolated_nodes', 'dynamic_models']
})
})
it('should handle server without feature flags support', async () => {
// Initialize API connection
const initPromise = api.init()
// Simulate connection open
wsEventHandlers['open'](new Event('open'))
// Clear the send mock to reset
mockWebSocket.send.mockClear()
// Simulate server response with status but no feature flags
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
// Simulate some other message (not feature flags)
wsEventHandlers['message']({
data: JSON.stringify({
type: 'execution_start',
data: {}
})
})
await initPromise
// Server features should remain empty
expect(api.serverFeatureFlags).toEqual({})
})
})
describe('Feature checking methods', () => {
beforeEach(() => {
// Set up some test features
api.serverFeatureFlags = {
supports_preview_metadata: true,
async_execution: false,
capabilities: ['isolated_nodes', 'dynamic_models']
}
})
it('should check if server supports a boolean feature', () => {
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
expect(api.serverSupportsFeature('async_execution')).toBe(false)
expect(api.serverSupportsFeature('non_existent_feature')).toBe(false)
})
it('should get server feature value', () => {
expect(api.getServerFeature('supports_preview_metadata')).toBe(true)
expect(api.getServerFeature('capabilities')).toEqual([
'isolated_nodes',
'dynamic_models'
])
expect(api.getServerFeature('non_existent_feature')).toBeUndefined()
})
})
describe('Client feature flags configuration', () => {
it('should use mocked client feature flags', () => {
// Verify mocked flags are returned
const clientFlags = api.getClientFeatureFlags()
expect(clientFlags).toEqual({
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
})
})
it('should return a copy of client feature flags', () => {
// Temporarily restore the real implementation for this test
vi.mocked(api.getClientFeatureFlags).mockRestore()
// Verify that modifications to returned object don't affect original
const clientFlags1 = api.getClientFeatureFlags()
const clientFlags2 = api.getClientFeatureFlags()
// Should be different objects
expect(clientFlags1).not.toBe(clientFlags2)
// But with same content
expect(clientFlags1).toEqual(clientFlags2)
// Modifying one should not affect the other
clientFlags1.test_flag = true
expect(api.getClientFeatureFlags()).not.toHaveProperty('test_flag')
})
})
describe('Integration with preview messages', () => {
it('should affect preview message handling based on feature support', () => {
// Test with metadata support
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Test without metadata support
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
})
})

View File

@@ -1,171 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
// Mock global fetch
vi.stubGlobal('fetch', vi.fn())
describe('api.fetchApi', () => {
beforeEach(() => {
vi.resetAllMocks()
// Reset api state
api.user = 'test-user'
})
describe('header handling', () => {
it('should add Comfy-User header with plain object headers', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test', {
headers: {}
})
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/test'),
expect.objectContaining({
headers: {
'Comfy-User': 'test-user'
}
})
)
})
it('should add Comfy-User header with Headers instance', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
const headers = new Headers()
await api.fetchApi('/test', { headers })
expect(mockFetch).toHaveBeenCalled()
const callHeaders = mockFetch.mock.calls[0][1]?.headers
expect(callHeaders).toEqual(headers)
})
it('should add Comfy-User header with array headers', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
const headers: [string, string][] = []
await api.fetchApi('/test', { headers })
expect(mockFetch).toHaveBeenCalled()
const callHeaders = mockFetch.mock.calls[0][1]?.headers
expect(callHeaders).toContainEqual(['Comfy-User', 'test-user'])
})
it('should preserve existing headers when adding Comfy-User', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test', {
headers: {
'Content-Type': 'application/json',
'X-Custom': 'value'
}
})
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/test'),
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
'X-Custom': 'value',
'Comfy-User': 'test-user'
}
})
)
})
it('should not allow developer-specified headers to be overridden by options', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test', {
headers: {
'Comfy-User': 'fennec-girl'
}
})
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/test'),
expect.objectContaining({
headers: {
'Comfy-User': 'test-user'
}
})
)
})
})
describe('default options', () => {
it('should set cache to no-cache by default', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
cache: 'no-cache'
})
)
})
it('should include required headers even when no headers option is provided', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test')
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'Comfy-User': 'test-user'
})
})
)
})
it('should not override existing cache option', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test', { cache: 'force-cache' })
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
cache: 'force-cache'
})
)
})
})
describe('URL construction', () => {
it('should use apiURL for route construction', async () => {
const mockFetch = vi
.mocked(global.fetch)
.mockResolvedValue(new Response())
await api.fetchApi('/test/route')
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/test/route'),
expect.any(Object)
)
})
})
})

View File

@@ -1,30 +0,0 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
vi.mock('axios')
describe('getFolderPaths', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('returns legacy API response when available', async () => {
const mockResponse = { checkpoints: ['/test/checkpoints'] }
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockResponse })
const result = await api.getFolderPaths()
expect(result).toEqual(mockResponse)
})
it('returns empty object when legacy API unavailable (dynamic discovery)', async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error())
const result = await api.getFolderPaths()
// With dynamic discovery, we don't pre-generate directories when API is unavailable
expect(result).toEqual({})
})
})

View File

@@ -1,76 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,
validateComfyNodeDef
} from '@/schemas/nodeDefSchema'
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
input: {
required: {
ckpt_name: [['model1.safetensors', 'model2.ckpt'], {}]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: '',
python_module: 'nodes',
category: 'loaders',
output_node: false,
experimental: false,
deprecated: false
}
describe('validateNodeDef', () => {
it('Should accept a valid node definition', async () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
describe.each([
[{ ckpt_name: ['foo', { default: 1 }] }, ['foo', { default: 1 }]],
// Extra input spec should be preserved
[{ ckpt_name: ['foo', { bar: 1 }] }, ['foo', { bar: 1 }]],
[{ ckpt_name: ['INT', { bar: 1 }] }, ['INT', { bar: 1 }]],
[{ ckpt_name: [[1, 2, 3], { bar: 1 }] }, [[1, 2, 3], { bar: 1 }]]
])(
'validateComfyNodeDef with various input spec formats',
(inputSpec, expected) => {
it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
expect(
// @ts-expect-error fixme ts strict error
validateComfyNodeDef({
...EXAMPLE_NODE_DEF,
input: {
required: inputSpec
}
}).input.required.ckpt_name
).toEqual(expected)
})
}
)
describe.each([
[{ ckpt_name: { 'model1.safetensors': 'foo' } }],
[{ ckpt_name: ['*', ''] }],
[{ ckpt_name: ['foo', { default: 1 }, { default: 2 }] }],
// Should reject incorrect default value type.
[{ ckpt_name: ['INT', { default: '124' }] }]
])(
'validateComfyNodeDef rejects with various input spec formats',
(inputSpec) => {
it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
expect(
validateComfyNodeDef({
...EXAMPLE_NODE_DEF,
input: {
required: inputSpec
}
})
).toBeNull()
})
}
)
})

View File

@@ -1,332 +0,0 @@
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('@/platform/updates/common/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]')
})
})
})

View File

@@ -1,199 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
let mockIsCloud = false
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud
}
}))
// Global stubs
const createObjectURLSpy = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
const revokeObjectURLSpy = vi
.spyOn(URL, 'revokeObjectURL')
.mockImplementation(() => {})
describe('downloadUtil', () => {
let mockLink: HTMLAnchorElement
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
mockIsCloud = false
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
createObjectURLSpy.mockClear().mockReturnValue('blob:mock-url')
revokeObjectURLSpy.mockClear().mockImplementation(() => {})
// Create a mock anchor element
mockLink = {
href: '',
download: '',
click: vi.fn(),
style: { display: '' }
} as unknown as HTMLAnchorElement
// Spy on DOM methods
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink)
vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('downloadFile', () => {
it('should create and trigger download with basic URL', () => {
const testUrl = 'https://example.com/image.png'
downloadFile(testUrl)
expect(document.createElement).toHaveBeenCalledWith('a')
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('download.png') // Default filename
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
expect(mockLink.click).toHaveBeenCalled()
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should use custom filename when provided', () => {
const testUrl = 'https://example.com/image.png'
const customFilename = 'my-custom-image.png'
downloadFile(testUrl, customFilename)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe(customFilename)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should extract filename from URL query parameters', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg&other=param'
downloadFile(testUrl)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('extracted-image.jpg')
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should use default filename when URL has no filename parameter', () => {
const testUrl = 'https://example.com/api/file?other=param'
downloadFile(testUrl)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('download.png')
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should handle invalid URLs gracefully', () => {
const invalidUrl = 'not-a-valid-url'
downloadFile(invalidUrl)
expect(mockLink.href).toBe(invalidUrl)
expect(mockLink.download).toBe('download.png')
expect(mockLink.click).toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should prefer custom filename over extracted filename', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg'
const customFilename = 'custom-override.png'
downloadFile(testUrl, customFilename)
expect(mockLink.download).toBe(customFilename)
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should handle URLs with empty filename parameter', () => {
const testUrl = 'https://example.com/api/file?filename='
downloadFile(testUrl)
expect(mockLink.download).toBe('download.png')
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should handle relative URLs by using window.location.origin', () => {
const relativeUrl = '/api/file?filename=relative-image.png'
downloadFile(relativeUrl)
expect(mockLink.href).toBe(relativeUrl)
expect(mockLink.download).toBe('relative-image.png')
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('should clean up DOM elements after download', () => {
const testUrl = 'https://example.com/image.png'
downloadFile(testUrl)
// Verify the element was added and then removed
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
expect(fetchMock).not.toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
})
it('streams downloads via blob when running in cloud', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(blobFn).toHaveBeenCalled()
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
expect(mockLink.click).toHaveBeenCalled()
})
it('logs an error when cloud fetch fails', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
fetchMock.mockResolvedValue({
ok: false,
status: 404,
blob: vi.fn()
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
await Promise.resolve()
expect(consoleSpy).toHaveBeenCalled()
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
})

View File

@@ -1,46 +0,0 @@
import { describe, expect, test } from 'vitest'
import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
creditsToCents,
creditsToUsd,
formatCredits,
formatCreditsFromCents,
formatCreditsFromUsd,
formatUsd,
formatUsdFromCents,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
describe('comfyCredits helpers', () => {
test('exposes the fixed conversion rate', () => {
expect(CREDITS_PER_USD).toBe(211)
expect(COMFY_CREDIT_RATE_CENTS).toBeCloseTo(2.11) // credits per cent
})
test('converts between USD and cents', () => {
expect(usdToCents(1.23)).toBe(123)
expect(formatUsdFromCents({ cents: 123, locale: 'en-US' })).toBe('1.23')
})
test('converts cents to credits and back', () => {
expect(centsToCredits(100)).toBe(211) // 100 cents = 211 credits
expect(creditsToCents(211)).toBe(100) // 211 credits = 100 cents
})
test('converts USD to credits and back', () => {
expect(usdToCredits(1)).toBe(211) // 1 USD = 211 credits
expect(creditsToUsd(211)).toBe(1) // 211 credits = 1 USD
})
test('formats credits and USD values using en-US locale', () => {
const locale = 'en-US'
expect(formatCredits({ value: 1234.567, locale })).toBe('1,234.57')
expect(formatCreditsFromCents({ cents: 100, locale })).toBe('211.00')
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
})

View File

@@ -1,248 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
adjustColor,
hexToRgb,
hsbToRgb,
parseToRgb,
rgbToHex
} from '@/utils/colorUtil'
interface ColorTestCase {
hex: string
rgb: string
rgba: string
hsl: string
hsla: string
lightExpected: string
transparentExpected: string
lightTransparentExpected: string
}
type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
vi.mock('es-toolkit/compat', () => ({
memoize: (fn: any) => fn
}))
const targetOpacity = 0.5
const targetLightness = 0.5
const assertColorVariationsMatch = (variations: string[], adjustment: any) => {
for (let i = 0; i < variations.length - 1; i++) {
expect(adjustColor(variations[i], adjustment)).toBe(
adjustColor(variations[i + 1], adjustment)
)
}
}
const colors: Record<string, ColorTestCase> = {
green: {
hex: '#073642',
rgb: 'rgb(7, 54, 66)',
rgba: 'rgba(7, 54, 66, 1)',
hsl: 'hsl(192, 80.8%, 14.3%)',
hsla: 'hsla(192, 80.8%, 14.3%, 1)',
lightExpected: 'hsla(192, 80.8%, 64.3%, 1)',
transparentExpected: 'hsla(192, 80.8%, 14.3%, 0.5)',
lightTransparentExpected: 'hsla(192, 80.8%, 64.3%, 0.5)'
},
blue: {
hex: '#00008B',
rgb: 'rgb(0,0,139)',
rgba: 'rgba(0,0,139,1)',
hsl: 'hsl(240,100%,27.3%)',
hsla: 'hsl(240,100%,27.3%,1)',
lightExpected: 'hsla(240, 100%, 77.3%, 1)',
transparentExpected: 'hsla(240, 100%, 27.3%, 0.5)',
lightTransparentExpected: 'hsla(240, 100%, 77.3%, 0.5)'
}
}
const formats: ColorFormat[] = ['hex', 'rgb', 'rgba', 'hsl', 'hsla']
describe('colorUtil conversions', () => {
describe('hexToRgb / rgbToHex', () => {
it('converts 6-digit hex to RGB', () => {
expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts 3-digit hex to RGB', () => {
expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts RGB to lowercase #hex and clamps values', () => {
expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000')
expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00')
expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff')
// out-of-range should clamp
expect(rgbToHex({ r: -10, g: 300, b: 16 })).toBe('#00ff10')
})
it('round-trips #hex -> rgb -> #hex', () => {
const hex = '#123abc'
expect(rgbToHex(hexToRgb(hex))).toBe('#123abc')
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses rgb()/rgba()', () => {
expect(parseToRgb('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 })
expect(parseToRgb('rgba(255,0,0,0.5)')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses hsl()/hsla()', () => {
expect(parseToRgb('hsl(0, 100%, 50%)')).toEqual({ r: 255, g: 0, b: 0 })
const green = parseToRgb('hsla(120, 100%, 50%, 0.7)')
expect(green.r).toBe(0)
expect(green.g).toBe(255)
expect(green.b).toBe(0)
})
})
describe('hsbToRgb', () => {
it('converts HSB to primary RGB colors', () => {
expect(hsbToRgb({ h: 0, s: 100, b: 100 })).toEqual({ r: 255, g: 0, b: 0 })
expect(hsbToRgb({ h: 120, s: 100, b: 100 })).toEqual({
r: 0,
g: 255,
b: 0
})
expect(hsbToRgb({ h: 240, s: 100, b: 100 })).toEqual({
r: 0,
g: 0,
b: 255
})
})
it('handles non-100 brightness and clamps/normalizes input', () => {
const rgb = hsbToRgb({ h: 360, s: 150, b: 50 })
expect(rgbToHex(rgb)).toBe('#7f0000')
})
})
})
describe('colorUtil - adjustColor', () => {
const runAdjustColorTests = (
color: ColorTestCase,
format: ColorFormat
): void => {
it('converts lightness', () => {
const result = adjustColor(color[format], { lightness: targetLightness })
expect(result).toBe(color.lightExpected)
})
it('applies opacity', () => {
const result = adjustColor(color[format], { opacity: targetOpacity })
expect(result).toBe(color.transparentExpected)
})
it('applies lightness and opacity jointly', () => {
const result = adjustColor(color[format], {
lightness: targetLightness,
opacity: targetOpacity
})
expect(result).toBe(color.lightTransparentExpected)
})
}
describe.each(Object.entries(colors))('%s color', (_colorName, color) => {
describe.each(formats)('%s format', (format) => {
runAdjustColorTests(color, format as ColorFormat)
})
})
it('returns the original value for invalid color formats', () => {
const invalidColors = [
'cmky(100, 50, 50, 0.5)',
'rgb(300, -10, 256)',
'xyz(255, 255, 255)',
'hsl(100, 50, 50%)',
'hsl(100, 50%, 50)',
'#GGGGGG',
'#3333'
]
invalidColors.forEach((color) => {
const result = adjustColor(color, {
lightness: targetLightness,
opacity: targetOpacity
})
expect(result).toBe(color)
})
})
it('returns the original value for null or undefined inputs', () => {
// @ts-expect-error fixme ts strict error
expect(adjustColor(null, { opacity: targetOpacity })).toBe(null)
// @ts-expect-error fixme ts strict error
expect(adjustColor(undefined, { opacity: targetOpacity })).toBe(undefined)
})
describe('handles input variations', () => {
it('handles spaces in rgb input', () => {
const variations = [
'rgb(0, 0, 0)',
'rgb(0,0,0)',
'rgb(0, 0,0)',
'rgb(0,0, 0)'
]
assertColorVariationsMatch(variations, { lightness: 0.5 })
})
it('handles spaces in hsl input', () => {
const variations = [
'hsl(0, 0%, 0%)',
'hsl(0,0%,0%)',
'hsl(0, 0%,0%)',
'hsl(0,0%, 0%)'
]
assertColorVariationsMatch(variations, { lightness: 0.5 })
})
it('handles different decimal places in rgba input', () => {
const variations = [
'rgba(0, 0, 0, 0.5)',
'rgba(0, 0, 0, 0.50)',
'rgba(0, 0, 0, 0.500)'
]
assertColorVariationsMatch(variations, { opacity: 0.5 })
})
it('handles different decimal places in hsla input', () => {
const variations = [
'hsla(0, 0%, 0%, 0.5)',
'hsla(0, 0%, 0%, 0.50)',
'hsla(0, 0%, 0%, 0.500)'
]
assertColorVariationsMatch(variations, { opacity: 0.5 })
})
})
describe('clamps values correctly', () => {
it('clamps lightness to 0 and 100', () => {
expect(adjustColor('hsl(0, 100%, 50%)', { lightness: -1 })).toBe(
'hsla(0, 100%, 0%, 1)'
)
expect(adjustColor('hsl(0, 100%, 50%)', { lightness: 1.5 })).toBe(
'hsla(0, 100%, 100%, 1)'
)
})
it('clamps opacity to 0 and 1', () => {
expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: -0.5 })).toBe(
'hsla(0, 0%, 0%, 0)'
)
expect(adjustColor('rgba(0, 0, 0, 0.5)', { opacity: 1.5 })).toBe(
'hsla(0, 0%, 0%, 1)'
)
})
})
})

View File

@@ -1,208 +0,0 @@
import fs from 'fs'
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'tests-ui/workflows'
describe('parseComfyWorkflow', () => {
it('parses valid workflow', async () => {
for await (const file of fs.readdirSync(WORKFLOW_DIR)) {
if (file.endsWith('.json')) {
const data = fs.readFileSync(`${WORKFLOW_DIR}/${file}`, 'utf-8')
await expect(
validateComfyWorkflow(JSON.parse(data))
).resolves.not.toBeNull()
}
}
})
it('workflow.nodes', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes = undefined
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes = null
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes = []
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.version', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.version = undefined
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.version = '1.0.1' // Invalid format (string)
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
// 2018-2024 schema: 0.4
workflow.version = 0.4
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.extra', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.extra = undefined
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = null
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = {}
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.extra = { foo: 'bar' } // Should accept extra fields.
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
it('workflow.nodes.pos', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].pos = [1, 2, 3]
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes[0].pos = [1, 2]
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
// Should automatically transform the legacy format object to array.
workflow.nodes[0].pos = { '0': 3, '1': 4 }
let validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
workflow.nodes[0].pos = { 0: 3, 1: 4 }
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4])
// Should accept the legacy bugged format object.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/710
workflow.nodes[0].pos = {
'0': 600,
'1': 340,
'2': 0,
'3': 0,
'4': 0,
'5': 0,
'6': 0,
'7': 0,
'8': 0,
'9': 0
}
validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].pos).toEqual([600, 340])
})
it('workflow.nodes.widget_values', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].widgets_values = ['foo', 'bar']
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
workflow.nodes[0].widgets_values = 'foo'
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
workflow.nodes[0].widgets_values = undefined
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
// The object format of widgets_values is used by VHS nodes to perform
// dynamic widgets display.
workflow.nodes[0].widgets_values = { foo: 'bar' }
const validatedWorkflow = await validateComfyWorkflow(workflow)
// @ts-expect-error fixme ts strict error
expect(validatedWorkflow.nodes[0].widgets_values).toEqual({ foo: 'bar' })
})
it('workflow.links', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.links = [
[
1, // Link id
'100:1', // Node id of source node
'12', // Output slot# of source node
'100:2', // Node id of destination node
15, // Input slot# of destination node
'INT' // Data type
]
]
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
describe('workflow.nodes.properties.aux_id', () => {
const validAuxIds = [
'valid/valid',
'valid-username-with-dash/valid_github-repo-name-with-underscore'
]
it.each(validAuxIds)('valid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidAuxIds = [
'invalid spaces in username/repo',
'invalid-chars-name-$/repo',
'github-name/invalid spaces in repo',
'not-both-names-with-slash'
]
it.each(invalidAuxIds)('invalid aux_id: %s', async (aux_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.aux_id = aux_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
describe('workflow.nodes.properties.cnr_id', () => {
const validCnrIds = ['valid', 'valid-with-dash', 'valid_with_underscores']
it.each(validCnrIds)('valid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidCnrIds = ['invalid cnr-id', 'invalid^cnr-id', 'invalid cnr id']
it.each(invalidCnrIds)('invalid cnr_id: %s', async (cnr_id) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.cnr_id = cnr_id
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
describe('workflow.nodes.properties.ver', () => {
const validVersionStrings = [
// Semver
'0.1.0',
'0.1.0-alpha',
'0.1.0-alpha.1',
'1.3.321',
// Git hash
'080e6d4af809a46852d1c4b7ed85f06e8a3a72be',
// Special case
'unknown',
// Git describe
'v0.3.9-7-g1419dee',
'v0.3.9-7-g1419dee-dirty'
]
it.each(validVersionStrings)('valid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.not.toBeNull()
})
const invalidVersionStrings = [
// Semver
'0.1-alpha',
'0. 1.0',
'0.0.0.0',
// Git hash
'080e6d4af809a46852d1c4b7ed85f06e8a3a72be-invalid'
]
it.each(invalidVersionStrings)('invalid version: %s', async (ver) => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
workflow.nodes[0].properties.ver = ver
await expect(validateComfyWorkflow(workflow)).resolves.toBeNull()
})
})
})

View File

@@ -1,103 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
return {
isLoggedIn: computed(() => mockData.isLoggedIn)
}
}
}))
vi.mock('@/utils/envUtil')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))
}))
function createWrapper() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
}
}
}
}
})
return mount(TopMenuSection, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
},
directives: {
tooltip: () => {}
}
}
})
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('authentication state', () => {
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
describe('when user is not logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = false
})
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
vi.mocked(isElectron).mockReturnValue(true)
const wrapper = createWrapper()
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const wrapper = createWrapper()
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
})
})
})
})
})

View File

@@ -1,80 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock ShortcutsList component
vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
default: {
name: 'ShortcutsList',
props: ['commands', 'subcategories', 'columns'],
template:
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
}
}))
// Mock command store
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials'
} as ComfyCommandImpl,
{
id: 'Other.Command',
label: 'Other Command',
category: 'view-controls',
function: vi.fn(),
icon: 'pi pi-test',
tooltip: 'Test tooltip',
menubarLabel: 'Other Command',
keybinding: null
} as ComfyCommandImpl
]
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
commands: mockCommands
})
}))
describe('EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should render ShortcutsList with essentials commands', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
expect(shortcutsList.exists()).toBe(true)
})
it('should categorize commands into subcategories', () => {
const wrapper = mount(EssentialsPanel)
const shortcutsList = wrapper.findComponent(ShortcutsList)
const subcategories = shortcutsList.props('subcategories')
expect(subcategories).toHaveProperty('workflow')
expect(subcategories).toHaveProperty('node')
expect(subcategories).toHaveProperty('queue')
expect(subcategories.workflow).toContain(mockCommands[0])
expect(subcategories.node).toContain(mockCommands[1])
expect(subcategories.queue).toContain(mockCommands[2])
})
})

View File

@@ -1,164 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
import type { ComfyCommandImpl } from '@/stores/commandStore'
// Mock vue-i18n
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
'shortcuts.subcategories.workflow': 'Workflow',
'shortcuts.subcategories.node': 'Node',
'shortcuts.subcategories.queue': 'Queue',
'shortcuts.subcategories.view': 'View',
'shortcuts.subcategories.panelControls': 'Panel Controls',
'commands.Workflow_New.label': 'New Blank Workflow'
}
return translations[key] || key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
describe('ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',
label: 'New Workflow',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'n']
}
}
} as ComfyCommandImpl,
{
id: 'Node.Add',
label: 'Add Node',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Shift', 'a']
}
}
} as ComfyCommandImpl,
{
id: 'Queue.Clear',
label: 'Clear Queue',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Control', 'Shift', 'c']
}
}
} as ComfyCommandImpl
]
const mockSubcategories = {
workflow: [mockCommands[0]],
node: [mockCommands[1]],
queue: [mockCommands[2]]
}
it('should render shortcuts organized by subcategories', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check that subcategories are rendered
expect(wrapper.text()).toContain('Workflow')
expect(wrapper.text()).toContain('Node')
expect(wrapper.text()).toContain('Queue')
// Check that commands are rendered
expect(wrapper.text()).toContain('New Blank Workflow')
})
it('should format keyboard shortcuts correctly', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: mockSubcategories
}
})
// Check for formatted keys
expect(wrapper.text()).toContain('Ctrl')
expect(wrapper.text()).toContain('n')
expect(wrapper.text()).toContain('Shift')
expect(wrapper.text()).toContain('a')
expect(wrapper.text()).toContain('c')
})
it('should filter out commands without keybindings', () => {
const commandsWithoutKeybinding: ComfyCommandImpl[] = [
...mockCommands,
{
id: 'No.Keybinding',
label: 'No Keybinding',
category: 'essentials',
keybinding: null
} as ComfyCommandImpl
]
const wrapper = mount(ShortcutsList, {
props: {
commands: commandsWithoutKeybinding,
subcategories: {
...mockSubcategories,
other: [commandsWithoutKeybinding[3]]
}
}
})
expect(wrapper.text()).not.toContain('No Keybinding')
})
it('should handle special key formatting', () => {
const specialKeyCommand: ComfyCommandImpl = {
id: 'Special.Keys',
label: 'Special Keys',
category: 'essentials',
keybinding: {
combo: {
getKeySequences: () => ['Meta', 'ArrowUp', 'Enter', 'Escape', ' ']
}
}
} as ComfyCommandImpl
const wrapper = mount(ShortcutsList, {
props: {
commands: [specialKeyCommand],
subcategories: {
special: [specialKeyCommand]
}
}
})
const text = wrapper.text()
expect(text).toContain('Cmd') // Meta -> Cmd
expect(text).toContain('↑') // ArrowUp -> ↑
expect(text).toContain('↵') // Enter -> ↵
expect(text).toContain('Esc') // Escape -> Esc
expect(text).toContain('Space') // ' ' -> Space
})
it('should use fallback subcategory titles', () => {
const wrapper = mount(ShortcutsList, {
props: {
commands: mockCommands,
subcategories: {
unknown: [mockCommands[0]]
}
}
})
expect(wrapper.text()).toContain('unknown')
})
})

View File

@@ -1,219 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
// Mock xterm and related modules
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(() => ({
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => {
// Return a disposable
return {
dispose: vi.fn()
}
}),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn(),
loadAddon: vi.fn()
})),
IDisposable: vi.fn()
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn().mockImplementation(() => ({
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
}))
}))
const mockTerminal = {
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => ({
dispose: vi.fn()
})),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: mockTerminal,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => false),
electronAPI: vi.fn(() => null)
}))
// Mock clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
serverStart: {
copySelectionTooltip: 'Copy selection',
copyAllTooltip: 'Copy all'
}
}
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>',
props: ['icon', 'severity', 'size']
}
}
}
})
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
})
it('copies selected text when selection exists', async () => {
const selectedText = 'selected text'
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
it('copies all text when no selection exists', async () => {
const allText = 'all terminal content'
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
it('does not copy when no text available', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
})

View File

@@ -1,199 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock the stores
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
const createMockNode = (type: string, version?: string): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: 'comfy-core', ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
refetchSystemStats: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock store state
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.refetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
})
const mountComponent = (props = {}) => {
return mount(MissingCoreNodesMessage, {
global: {
plugins: [PrimeVue],
components: { Message },
mocks: {
$t: (key: string, params?: { version?: string }) => {
const translations: Record<string, string> = {
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
'loadWorkflowWarning.outdatedVersionGeneric':
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
}
return translations[key] || key
}
}
},
props: {
missingCoreNodes: {},
...props
}
})
}
it('does not render when there are no missing core nodes', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
it('renders message when there are missing core nodes', async () => {
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.findComponent(Message).exists()).toBe(true)
})
it('displays current ComfyUI version when available', async () => {
// Set systemStats directly (store auto-fetches with useAsyncState)
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for component to render
await nextTick()
// No need to check if fetchSystemStats was called since useAsyncState auto-fetches
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
)
})
it('displays generic message when version is unavailable', async () => {
// No systemStats set - version unavailable
mockSystemStatsStore.systemStats = null
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for the async operations to complete
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
)
})
it('groups nodes by version and displays them', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('NodeA', '1.2.0'),
createMockNode('NodeB', '1.2.0')
],
'1.3.0': [createMockNode('NodeC', '1.3.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
expect(text).toContain('Requires ComfyUI 1.3.0:')
expect(text).toContain('NodeC')
expect(text).toContain('Requires ComfyUI 1.2.0:')
expect(text).toContain('NodeA, NodeB')
})
it('sorts versions in descending order', async () => {
const missingCoreNodes = {
'1.1.0': [createMockNode('Node1', '1.1.0')],
'1.3.0': [createMockNode('Node3', '1.3.0')],
'1.2.0': [createMockNode('Node2', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
const version13Index = text.indexOf('1.3.0')
const version12Index = text.indexOf('1.2.0')
const version11Index = text.indexOf('1.1.0')
expect(version13Index).toBeLessThan(version12Index)
expect(version12Index).toBeLessThan(version11Index)
})
it('removes duplicate node names within the same version', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('UniqueNode', '1.2.0')
]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
// Should only appear once in the sorted list
expect(text).toContain('DuplicateNode, UniqueNode')
// Count occurrences of 'DuplicateNode' - should be only 1
const matches = text.match(/DuplicateNode/g) || []
expect(matches.length).toBe(1)
})
it('handles nodes with missing version info', async () => {
const missingCoreNodes = {
'': [createMockNode('NoVersionNode')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
expect(wrapper.text()).toContain('NoVersionNode')
})
})

View File

@@ -1,48 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const mountOption = (
props?: Partial<{ credits: number; description: string; selected: boolean }>
) =>
mount(CreditTopUpOption, {
props: {
credits: 1000,
description: '~100 videos*',
selected: false,
...props
},
global: {
plugins: [i18n]
}
})
describe('CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
})
it('emits select event when clicked', async () => {
const wrapper = mountOption()
await wrapper.find('div').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
})
})

View File

@@ -1,468 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
// Mock getConflictMessage utility
vi.mock('@/utils/conflictMessageUtil', () => ({
getConflictMessage: vi.fn((conflict) => {
return `${conflict.type}: ${conflict.current_value} vs ${conflict.required_value}`
})
}))
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description': 'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions': 'Import Failed Extensions'
}
return translations[key] || key
})
}))
}))
// Mock data for conflict detection
const mockConflictData = ref<ConflictDetectionResult[]>([])
// Mock useConflictDetection composable
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => ({
conflictedPackages: computed(() => mockConflictData.value)
})
})
)
describe('NodeConflictDialogContent', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Reset mock data
mockConflictData.value = []
})
const createWrapper = (props = {}) => {
return mount(NodeConflictDialogContent, {
props,
global: {
plugins: [pinia],
components: {
Button
},
stubs: {
ContentDivider: true
},
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description':
'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions':
'Import Failed Extensions'
}
return translations[key] || key
})
}
}
})
}
const mockConflictResults: ConflictDetectionResult[] = [
{
package_id: 'Package1',
package_name: 'Test Package 1',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
},
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
}
]
},
{
package_id: 'Package2',
package_name: 'Test Package 2',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
},
{
package_id: 'Package3',
package_name: 'Test Package 3',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'ModuleNotFoundError: No module named "example"'
}
]
}
]
describe('rendering', () => {
it('should render without conflicts', () => {
// Set empty conflict data
mockConflictData.value = []
const wrapper = createWrapper()
// When there are no conflicts, the conflict sections should not be rendered
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
expect(wrapper.find('[class*="Import Failed Extensions"]').exists()).toBe(
false
)
})
it('should render with conflict data from composable', () => {
// Set conflict data
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Should show 3 total conflicts (2 from Package1 + 1 from Package2, excluding import_failed)
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('Conflicts')
// Should show 3 extensions at risk (all packages)
expect(wrapper.text()).toContain('Extensions at Risk')
// Should show import failed section
expect(wrapper.text()).toContain('Import Failed Extensions')
expect(wrapper.text()).toContain('1') // 1 import failed package
})
it('should show description when showAfterWhatsNew is true', () => {
const wrapper = createWrapper({
showAfterWhatsNew: true
})
expect(wrapper.text()).toContain('Some extensions are not compatible')
expect(wrapper.text()).toContain('Additional info about conflicts')
})
it('should not show description when showAfterWhatsNew is false', () => {
const wrapper = createWrapper({
showAfterWhatsNew: false
})
expect(wrapper.text()).not.toContain('Some extensions are not compatible')
expect(wrapper.text()).not.toContain('Additional info about conflicts')
})
it('should separate import_failed conflicts into separate section', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Import Failed Extensions section should show 1 package
const importFailedSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[0]
expect(importFailedSection.text()).toContain('1')
expect(importFailedSection.text()).toContain('Import Failed Extensions')
// Conflicts section should show 3 conflicts (excluding import_failed)
const conflictsSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[1]
expect(conflictsSection.text()).toContain('3')
expect(conflictsSection.text()).toContain('Conflicts')
})
})
describe('panel interactions', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should toggle import failed panel', async () => {
const wrapper = createWrapper()
// Find import failed panel header (first one)
const importFailedHeader = wrapper.find(
'[data-testid="conflict-dialog-panel-toggle"]'
)
// Initially collapsed
expect(
wrapper.find('[data-testid="conflict-dialog-panel-expanded"]').exists()
).toBe(false)
// Click to expand import failed panel
await importFailedHeader.trigger('click')
// Should be expanded now and show package name
const expandedContent = wrapper.find(
'[data-testid="conflict-dialog-panel-expanded"]'
)
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 3')
// Should show chevron-down icon when expanded
const chevronButton = wrapper.findComponent(Button)
expect(chevronButton.find('i').classes()).toContain('pi-chevron-down')
})
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
// Click to expand conflicts panel
await conflictsHeader.trigger('click')
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems.length).toBeGreaterThan(0)
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header (third one)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.findAll(
'[data-testid="conflict-dialog-panel-expanded"]'
)[0]
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 1')
expect(expandedContent.text()).toContain('Test Package 2')
expect(expandedContent.text()).toContain('Test Package 3')
})
it('should collapse other panels when opening one', async () => {
const wrapper = createWrapper()
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[0]
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
// Open import failed panel first
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(true)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader.trigger('click')
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(true)
})
})
describe('conflict display', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should display individual conflict details excluding import_failed', async () => {
const wrapper = createWrapper()
// Expand conflicts panel (second header)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
it('should display import failed packages separately', async () => {
const wrapper = createWrapper()
// Expand import failed panel (first header)
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[0]
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})
it('should display all package names in extensions list', async () => {
const wrapper = createWrapper()
// Expand extensions panel (third header)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
await extensionsHeader.trigger('click')
// Should display all package names
expect(wrapper.text()).toContain('Test Package 1')
expect(wrapper.text()).toContain('Test Package 2')
expect(wrapper.text()).toContain('Test Package 3')
})
})
describe('empty states', () => {
it('should handle empty conflicts gracefully', () => {
mockConflictData.value = []
const wrapper = createWrapper()
// When there are no conflicts, none of the sections should be visible
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
// Import failed section should not be visible when there are no import failures
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
it('should handle conflicts without import_failed', () => {
// Only set packages without import_failed conflicts
mockConflictData.value = [mockConflictResults[0], mockConflictResults[1]]
const wrapper = createWrapper()
expect(wrapper.text()).toContain('3') // conflicts count
expect(wrapper.text()).toContain('2') // extensions count
// Import failed section should not be visible
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
})
describe('scrolling behavior', () => {
it('should apply scrollbar styles to all expandable lists', async () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Test all three panels
const headers = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
for (let i = 0; i < headers.length; i++) {
await headers[i].trigger('click')
// Check for scrollable container with proper classes
const scrollableContainer = wrapper.find(
'[class*="max-h-"][class*="overflow-y-auto"][class*="scrollbar-hide"]'
)
expect(scrollableContainer.exists()).toBe(true)
// Close the panel for next iteration
await headers[i].trigger('click')
}
})
})
describe('accessibility', () => {
it('should have proper button roles and labels', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const buttons = wrapper.findAllComponents(Button)
expect(buttons.length).toBe(3) // 3 chevron buttons
// Check chevron buttons have icons
buttons.forEach((button) => {
expect(
button
.find('i')
.classes()
.some((c) => /pi-chevron-(right|down)/.test(c))
).toBe(true)
})
})
it('should have clickable panel headers', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const headers = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
expect(headers).toHaveLength(3) // import failed, conflicts and extensions headers
headers.forEach((header) => {
expect(header.element.tagName).toBe('DIV')
})
})
})
describe('es-toolkit optimization', () => {
it('should efficiently filter conflicts using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that import_failed conflicts are filtered out from main conflicts
const vm = wrapper.vm as any
expect(vm.allConflictDetails).toHaveLength(3) // Should not include import_failed
expect(
vm.allConflictDetails.every((c: any) => c.type !== 'import_failed')
).toBe(true)
})
it('should efficiently extract import failed packages using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that only import_failed packages are extracted
const vm = wrapper.vm as any
expect(vm.importFailedConflicts).toHaveLength(1)
expect(vm.importFailedConflicts[0]).toBe('Test Package 3')
})
})
})

View File

@@ -1,211 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import type {
MergedNodePack,
RegistryPack
} from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
d: vi.fn(() => '2024. 1. 1.'),
t: vi.fn((key: string) => key)
})),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key: string) => key),
te: vi.fn(() => true)
}
}))
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackInstalled: vi.fn(() => false),
isPackEnabled: vi.fn(() => true),
isPackInstalling: vi.fn(() => false),
installedPacksIds: []
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: { light_theme: true }
}))
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await import('vue')
return {
whenever: vi.fn(),
useStorage: vi.fn((_key, defaultValue) => {
return ref(defaultValue)
}),
createSharedComposable: vi.fn((fn) => fn)
}
})
vi.mock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn(() => ({
systemStats: {
system: { os: 'Darwin' },
devices: [{ type: 'mps', name: 'Metal' }]
}
}))
}))
describe('PackCard', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
})
const createWrapper = (props: {
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}) => {
const wrapper = mount(PackCard, {
props,
global: {
plugins: [pinia],
components: {
Card,
ProgressSpinner
},
stubs: {
PackBanner: true,
PackVersionBadge: true,
PackCardFooter: true
},
mocks: {
$t: vi.fn((key: string) => key)
}
}
})
return wrapper
}
const mockNodePack: RegistryPack = {
id: 'test-package',
name: 'Test Package',
description: 'Test package description',
author: 'Test Author',
latest_version: {
createdAt: '2024-01-01T00:00:00Z'
}
} as RegistryPack
describe('basic rendering', () => {
it('should render package card with basic information', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('.p-card').exists()).toBe(true)
expect(wrapper.text()).toContain('Test Package')
expect(wrapper.text()).toContain('Test package description')
expect(wrapper.text()).toContain('Test Author')
})
it('should render date correctly', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('2024. 1. 1.')
})
it('should apply selected class when isSelected is true', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: true
})
expect(wrapper.find('.selected-card').exists()).toBe(true)
})
it('should not apply selected class when isSelected is false', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: false
})
expect(wrapper.find('.selected-card').exists()).toBe(false)
})
})
describe('component behavior', () => {
it('should render without errors', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.p-card').exists()).toBe(true)
})
})
describe('package information display', () => {
it('should display package name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Package')
})
it('should display package description', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test package description')
})
it('should display author name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Author')
})
it('should handle missing description', () => {
const packWithoutDescription = { ...mockNodePack, description: undefined }
const wrapper = createWrapper({ nodePack: packWithoutDescription })
expect(wrapper.find('p').exists()).toBe(false)
})
it('should handle missing author', () => {
const packWithoutAuthor = { ...mockNodePack, author: undefined }
const wrapper = createWrapper({ nodePack: packWithoutAuthor })
// Should still render without errors
expect(wrapper.exists()).toBe(true)
})
})
describe('component structure', () => {
it('should render PackBanner component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-banner-stub').exists()).toBe(true)
})
it('should render PackVersionBadge component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-version-badge-stub').exists()).toBe(true)
})
it('should render PackCardFooter component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-card-footer-stub').exists()).toBe(true)
})
})
})

View File

@@ -1,486 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock modules
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/stores/dialogStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/commandStore')
vi.mock('@/workbench/extensions/manager/services/comfyManagerService')
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
conflictedPackages: { value: [] },
runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined)
}))
})
)
// Mock useEventListener to capture the event handler
let reconnectHandler: (() => void) | null = null
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useEventListener: vi.fn(
(_target: any, event: string, handler: any, _options: any) => {
if (event === 'reconnected') {
reconnectHandler = handler
}
}
)
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
// Helper function to mount component with required setup
const mountComponent = (options: { captureError?: boolean } = {}) => {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
progressCountOf: 'of'
},
contextMenu: {
Collapse: 'Collapse',
Expand: 'Expand'
},
manager: {
clickToFinishSetup: 'Click',
applyChanges: 'Apply Changes',
toFinishSetup: 'to finish setup',
restartingBackend: 'Restarting backend to apply changes...',
extensionsSuccessfullyInstalled:
'Extension(s) successfully installed and are ready to use!',
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
installingDependencies: 'Installing dependencies...'
}
}
}
})
const config: any = {
global: {
plugins: [pinia, PrimeVue, i18n]
}
}
// Add error handler for tests that expect errors
if (options.captureError) {
config.global.config = {
errorHandler: () => {
// Suppress error in test
}
}
}
return mount(ManagerProgressFooter, config)
}
describe('ManagerProgressFooter', () => {
const mockTaskLogs: TaskLog[] = []
const mockComfyManagerStore = {
taskLogs: mockTaskLogs,
allTasksDone: true,
isProcessingTasks: false,
succeededTasksIds: [] as string[],
failedTasksIds: [] as string[],
taskHistory: {} as Record<string, any>,
taskQueue: null,
resetTaskState: vi.fn(),
clearLogs: vi.fn(),
setStale: vi.fn(),
// Add other required properties
isLoading: { value: false },
error: { value: null },
statusMessage: { value: 'DONE' },
installedPacks: {},
installedPacksIds: new Set(),
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn(),
refreshInstalledList: vi.fn(),
installPack: vi.fn(),
uninstallPack: vi.fn(),
updatePack: vi.fn(),
updateAllPacks: vi.fn(),
disablePack: vi.fn(),
enablePack: vi.fn()
}
const mockDialogStore = {
closeDialog: vi.fn(),
// Add other required properties
dialogStack: { value: [] },
showDialog: vi.fn(),
$id: 'dialog',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockSettingStore = {
get: vi.fn().mockReturnValue(false),
set: vi.fn(),
// Add other required properties
settingValues: { value: {} },
settingsById: { value: {} },
exists: vi.fn(),
getDefaultValue: vi.fn(),
loadSettingValues: vi.fn(),
updateValue: vi.fn(),
$id: 'setting',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockProgressDialogStore = {
isExpanded: false,
toggle: vi.fn(),
collapse: vi.fn(),
expand: vi.fn()
}
const mockCommandStore = {
execute: vi.fn().mockResolvedValue(undefined)
}
const mockComfyManagerService = {
rebootComfyUI: vi.fn().mockResolvedValue(null)
}
beforeEach(() => {
vi.clearAllMocks()
// Create new pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
// Reset task logs
mockTaskLogs.length = 0
mockComfyManagerStore.taskLogs = mockTaskLogs
// Reset event handler
reconnectHandler = null
vi.mocked(useComfyManagerStore).mockReturnValue(
mockComfyManagerStore as any
)
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
mockProgressDialogStore as any
)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.mocked(useComfyManagerService).mockReturnValue(
mockComfyManagerService as any
)
})
describe('State 1: Queue Running', () => {
it('should display loading spinner and progress counter when queue is running', async () => {
// Setup queue running state
mockComfyManagerStore.isProcessingTasks = true
mockComfyManagerStore.succeededTasksIds = ['1', '2']
mockComfyManagerStore.failedTasksIds = []
mockComfyManagerStore.taskHistory = {
'1': { taskName: 'Installing pack1' },
'2': { taskName: 'Installing pack2' },
'3': { taskName: 'Installing pack3' }
}
mockTaskLogs.push(
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
)
const wrapper = mountComponent()
// Check loading spinner exists (DotSpinner component)
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check current task name is displayed
expect(wrapper.text()).toContain('Installing pack3')
// Check progress counter (completed: 2 of 3)
expect(wrapper.text()).toMatch(/2.*of.*3/)
// Check expand/collapse button exists
const expandButton = wrapper.find('[aria-label="Expand"]')
expect(expandButton.exists()).toBe(true)
// Check Apply Changes button is NOT shown
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('should toggle expansion when expand button is clicked', async () => {
mockComfyManagerStore.isProcessingTasks = true
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
const wrapper = mountComponent()
const expandButton = wrapper.find('[aria-label="Expand"]')
await expandButton.trigger('click')
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
})
})
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
it('should display check mark and Apply Changes button when all tasks are done', async () => {
// Setup tasks completed state
mockComfyManagerStore.isProcessingTasks = false
mockTaskLogs.push(
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
)
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Check check mark emoji
expect(wrapper.text()).toContain('✅')
// Check restart message
expect(wrapper.text()).toContain(
'To apply changes, please restart ComfyUI'
)
expect(wrapper.text()).toContain('Apply Changes')
// Check Apply Changes button exists
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// Check no progress counter
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
})
})
describe('State 3: Restarting', () => {
it('should display restarting message and spinner during restart', async () => {
// Setup completed state first
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Click Apply Changes to trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for state update
await nextTick()
// Check restarting message
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
// Check loading spinner during restart
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check Apply Changes button is hidden
expect(wrapper.text()).not.toContain('Apply Changes')
})
})
describe('State 4: Restart Completed', () => {
it('should display success message and auto-close after 3 seconds', async () => {
vi.useFakeTimers()
// Setup completed state
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for restart completed state
await nextTick()
// Check success message
expect(wrapper.text()).toContain('🎉')
expect(wrapper.text()).toContain(
'Extension(s) successfully installed and are ready to use!'
)
// Check dialog closes after 3 seconds
vi.advanceTimersByTime(3000)
await nextTick()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Common Features', () => {
it('should always display close button', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.exists()).toBe(true)
})
it('should close dialog when close button is clicked', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
})
})
describe('Toast Management', () => {
it('should suppress reconnection toasts during restart', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Check toast setting was disabled
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
})
it('should restore toast settings after restart completes', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for settings restoration
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false // Restored to original
)
})
})
describe('Error Handling', () => {
it('should restore state and close dialog on restart error', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
// Mock restart to throw error
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
new Error('Restart failed')
)
const wrapper = mountComponent({ captureError: true })
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// The component throws the error but Vue Test Utils catches it
// We need to check if the error handling logic was executed
await applyButton!.trigger('click').catch(() => {
// Error is expected, ignore it
})
// Wait for error handling
await nextTick()
// Check dialog was closed on error
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
// Check toast settings were restored
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
// Check that the error handler was called
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
})
})
})

View File

@@ -1,159 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
// Mock functions
const mockExecute = vi.fn()
const mockGetCommand = vi.fn().mockReturnValue({
keybinding: {
combo: {
getKeySequences: () => ['Ctrl', '+']
}
}
})
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
const mockSetAppZoom = vi.fn()
const mockSettingGet = vi.fn().mockReturnValue(true)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
// Mock dependencies
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
useMinimap: () => ({
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute,
getCommand: mockGetCommand,
formatKeySequence: mockFormatKeySequence
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
appScalePercentage: 100,
setAppZoomFromPercentage: mockSetAppZoom
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockSettingGet
})
}))
const createWrapper = (props = {}) => {
return mount(ZoomControlsModal, {
props: {
visible: true,
...props
},
global: {
plugins: [i18n],
stubs: {
Button: false,
InputNumber: false
}
}
})
}
describe('ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('should execute zoom in command when zoom in button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomInButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomIn')
)
expect(zoomInButton).toBeDefined()
await zoomInButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
})
it('should execute zoom out command when zoom out button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const zoomOutButton = buttons.find((btn) =>
btn.text().includes('graphCanvasMenu.zoomOut')
)
expect(zoomOutButton).toBeDefined()
await zoomOutButton!.trigger('mousedown')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
})
it('should execute fit view command when fit view button is clicked', async () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
const fitViewButton = buttons.find((btn) =>
btn.text().includes('zoomControls.zoomToFit')
)
expect(fitViewButton).toBeDefined()
await fitViewButton!.trigger('click')
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
})
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
const wrapper = createWrapper()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Emit the input event with PrimeVue's InputNumberInputEvent structure
await inputNumber.vm.$emit('input', { value: 150 })
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
})
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
const wrapper = createWrapper()
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
expect(inputNumber.exists()).toBe(true)
// Test out of range values
await inputNumber.vm.$emit('input', { value: 0 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
await inputNumber.vm.$emit('input', { value: 1001 })
expect(mockSetAppZoom).not.toHaveBeenCalled()
})
it('should display keyboard shortcuts for commands', () => {
const wrapper = createWrapper()
const buttons = wrapper.findAll('.cursor-pointer')
expect(buttons.length).toBeGreaterThan(0)
// Each command button should show the keyboard shortcut
expect(mockFormatKeySequence).toHaveBeenCalled()
})
it('should not be visible when visible prop is false', () => {
const wrapper = createWrapper({ visible: false })
expect(wrapper.find('.absolute').exists()).toBe(false)
})
})

View File

@@ -1,93 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
emits: ['cancel', 'delete', 'menu', 'view', 'details-enter', 'details-leave'],
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('JobGroupsList hover behavior', () => {
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
jobItem.vm.$emit('details-enter', job.id)
vi.advanceTimersByTime(199)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
vi.advanceTimersByTime(149)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
})

View File

@@ -1,193 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
import type {
JobErrorDialogService,
UseJobErrorReportingOptions
} from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {}
): ExecutionErrorWsMessage => ({
prompt_id: 'prompt',
timestamp: 100,
node_id: 'node-1',
node_type: 'KSampler',
executed: [],
exception_message: 'default failure',
exception_type: 'RuntimeError',
traceback: ['Trace line'],
current_inputs: {},
current_outputs: {},
...overrides
})
const createTaskWithMessages = (
messages: Array<[string, unknown]> | undefined = []
): TaskItemImpl =>
({
status: {
status_str: 'error',
completed: false,
messages
}
}) as TaskItemImpl
describe('extractExecutionError', () => {
it('returns null when task has no execution error messages', () => {
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: undefined
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: {
status_str: 'error',
completed: false,
messages: {} as unknown as Array<[string, unknown]>
}
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
] as Array<[string, unknown]>)
)
).toBeNull()
})
it('returns detail and message for execution_error entries', () => {
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
['execution_error', detail]
] as Array<[string, unknown]>)
)
expect(result).toEqual({
detail,
message: 'Kaboom'
})
})
it('falls back to an empty message when the tuple lacks detail', () => {
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
])
)
expect(result).toEqual({ detail: undefined, message: '' })
})
})
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
let showErrorDialog: JobErrorDialogService['showErrorDialog']
let dialog: JobErrorDialogService
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
beforeEach(() => {
taskState = ref<TaskItemImpl | null>(null)
taskForJob = computed(() => taskState.value)
copyToClipboard = vi.fn()
showExecutionErrorDialog = vi.fn()
showErrorDialog = vi.fn()
dialog = {
showExecutionErrorDialog,
showErrorDialog
}
composable = jobErrorReporting.useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a computed message that reflects the current task error', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'First failure' })
]
])
expect(composable.errorMessageValue.value).toBe('First failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Second failure' })
]
])
expect(composable.errorMessageValue.value).toBe('Second failure')
})
it('only calls the copy handler when a message exists', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
]
])
composable.copyErrorMessage()
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
vi.mocked(copyToClipboard).mockClear()
taskState.value = createTaskWithMessages([])
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('prefers the detailed execution dialog when detail is available', () => {
const detail = createExecutionErrorMessage({
exception_message: 'Detailed failure'
})
taskState.value = createTaskWithMessages([['execution_error', detail]])
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('shows a fallback dialog when only a message is available', () => {
const message = 'Queue job error'
taskState.value = createTaskWithMessages([])
const valueSpy = vi
.spyOn(composable.errorMessageValue, 'value', 'get')
.mockReturnValue(message)
expect(composable.errorMessageValue.value).toBe(message)
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
valueSpy.mockRestore()
})
it('does nothing when no error could be extracted', () => {
taskState.value = createTaskWithMessages([])
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -1,100 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/composables/graph/useSelectionState')
vi.mock('@/stores/workspace/nodeHelpStore')
const baseNode = {
nodePath: 'NodeA',
display_name: 'Node A',
description: '',
inputs: {},
outputs: []
}
describe('NodeHelpPage', () => {
const selection = ref<any | null>(null)
let openHelp: ReturnType<typeof vi.fn>
const mountPage = () =>
mount(NodeHelpPage, {
props: { node: baseNode as any },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ProgressSpinner: true,
Button: true
}
}
})
beforeEach(() => {
vi.resetAllMocks()
selection.value = null
openHelp = vi.fn()
vi.mocked(useSelectionState).mockReturnValue({
nodeDef: computed(() => selection.value)
} as any)
vi.mocked(useNodeHelpStore).mockReturnValue({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: true,
currentHelpNode: { nodePath: 'NodeA' },
openHelp,
closeHelp: vi.fn()
} as any)
})
test('opens help for a newly selected node while help is open', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
wrapper.unmount()
})
test('does not reopen help when the same node stays selected', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeA' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
test('does not react to selection when help is closed', async () => {
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: false,
currentHelpNode: null,
openHelp,
closeHelp: vi.fn()
} as any)
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@@ -1,204 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } 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,
nodeProgressStates: {} as any,
activePrompt: null as any
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
}))
// Mock the setting store
const settingStore = reactive({
get: vi.fn(() => 'Enabled')
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
// Mock the workflow store
const workflowStore = reactive({
activeWorkflow: null as any
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))
// Mock the workspace store
const workspaceStore = reactive({
shiftDown: false
})
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => workspaceStore
}))
describe('useBrowserTabTitle', () => {
beforeEach(() => {
// reset execution store
executionStore.isIdle = true
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')
workflowStore.activeWorkflow = null
workspaceStore.shiftDown = false
// reset document title
document.title = ''
})
it('sets default title when idle and no workflow', () => {
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
scope.stop()
})
it('adds asterisk for unsaved workflow', async () => {
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
scope.stop()
})
it('hides asterisk when autosave is enabled', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
it('hides asterisk while Shift key is held', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'off'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workspaceStore.shiftDown = true
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
// Fails when run together with other tests. Suspect to be caused by leaked
// state from previous tests.
it.skip('disables workflow title when menu disabled', async () => {
;(settingStore.get as any).mockReturnValue('Disabled')
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: false,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
scope.stop()
})
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activePrompt = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
scope.stop()
})
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' }
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
scope.stop()
})
})

View File

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

View File

@@ -1,105 +0,0 @@
import { describe, expect, it } from 'vitest'
/**
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
*/
function encodeClipboardData(data: string): string {
return btoa(
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
)
}
/**
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
*/
function decodeClipboardData(base64: string): string {
const binaryString = atob(base64)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
it('should handle ASCII-only strings', () => {
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Chinese characters in localized_name', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Japanese characters', () => {
const original = '{"localized_name":"画像を読み込む"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Korean characters', () => {
const original = '{"localized_name":"이미지 불러오기"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle mixed ASCII and Unicode characters', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle emoji characters', () => {
const original = '{"title":"Test Node 🎨🖼️"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle empty string', () => {
const original = ''
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle complex node data with multiple Unicode fields', () => {
const original = JSON.stringify({
nodes: [
{
id: 1,
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
}
],
groups: [{ title: '预处理组 🔧' }],
links: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
it('should produce valid base64 output', () => {
const original = '{"localized_name":"中文测试"}'
const encoded = encodeClipboardData(original)
// Base64 should only contain valid characters
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
})
it('should fail with plain btoa for non-Latin1 characters', () => {
const original = '{"localized_name":"图像"}'
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
})
})

View File

@@ -1,354 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
// Create a mock canvas context for transform testing
function createMockCanvasContext() {
return {
canvas: {
width: 1280,
height: 720,
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 1280,
height: 720,
right: 1280,
bottom: 720,
x: 0,
y: 0
})
},
ds: {
offset: [0, 0],
scale: 1
}
}
}
describe('useTransformState', () => {
const transformState = useTransformState()
beforeEach(() => {
transformState.syncWithCanvas({
ds: { offset: [0, 0] }
} as unknown as LGraphCanvas)
})
describe('initial state', () => {
it('should initialize with default camera values', () => {
const { camera } = transformState
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should generate correct initial transform style', () => {
const { transformStyle } = transformState
expect(transformStyle.value).toEqual({
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
})
})
})
describe('syncWithCanvas', () => {
it('should sync camera state with canvas transform', () => {
const { syncWithCanvas, camera } = transformState
const mockCanvas = createMockCanvasContext()
// Set mock canvas transform
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
syncWithCanvas(mockCanvas as any)
expect(camera.x).toBe(100)
expect(camera.y).toBe(50)
expect(camera.z).toBe(2)
})
it('should handle null canvas gracefully', () => {
const { syncWithCanvas, camera } = transformState
syncWithCanvas(null as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should handle canvas without ds property', () => {
const { syncWithCanvas, camera } = transformState
const canvasWithoutDs = { canvas: {} }
syncWithCanvas(canvasWithoutDs as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should update transform style after sync', () => {
const { syncWithCanvas, transformStyle } = transformState
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [150, 75]
mockCanvas.ds.scale = 0.5
syncWithCanvas(mockCanvas as any)
expect(transformStyle.value).toEqual({
transform: 'scale(0.5) translate(150px, 75px)',
transformOrigin: '0 0'
})
})
})
describe('coordinate conversions', () => {
beforeEach(() => {
// Set up a known transform state
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
describe('canvasToScreen', () => {
it('should convert canvas coordinates to screen coordinates', () => {
const { canvasToScreen } = transformState
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
describe('screenToCanvas', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
const screenPoint = { x: 220, y: 140 }
const canvasPoint = screenToCanvas(screenPoint)
// canvas = screen / scale - offset
// x: 220 / 2 - 100 = 10
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
it('should be inverse of canvasToScreen', () => {
const { canvasToScreen, screenToCanvas } = transformState
const originalPoint = { x: 25, y: 35 }
const screenPoint = canvasToScreen(originalPoint)
const backToCanvas = screenToCanvas(screenPoint)
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
})
})
})
describe('getNodeScreenBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState
const nodePos: [number, number] = [10, 20]
const nodeSize: [number, number] = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
})
describe('isNodeInViewport', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
transformState.syncWithCanvas(mockCanvas as any)
})
const viewport = { width: 1000, height: 600 }
it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
})
it('should return false for nodes completely outside viewport', () => {
const { isNodeInViewport } = transformState
// Node far to the right
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
// Node far to the left
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
// Node far below
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
// Node far above
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
})
it('should return true for nodes partially in viewport with margin', () => {
const { isNodeInViewport } = transformState
// Node slightly outside but within margin
const nodePos: [number, number] = [-50, -50]
const nodeSize: [number, number] = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
})
it('should return false for tiny nodes (size culling)', () => {
const { isNodeInViewport } = transformState
// Node is in viewport but too small
const nodePos: [number, number] = [100, 100]
const nodeSize: [number, number] = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
})
it('should adjust margin based on zoom level', () => {
const { isNodeInViewport, syncWithCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Test with very low zoom
mockCanvas.ds.scale = 0.05
syncWithCanvas(mockCanvas as any)
// Node at edge should still be visible due to increased margin
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
// Test with high zoom
mockCanvas.ds.scale = 4
syncWithCanvas(mockCanvas as any)
// Margin should be tighter
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
})
})
describe('getViewportBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate viewport bounds in canvas coordinates', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0.2)
// With 20% margin:
// marginX = 1000 * 0.2 = 200
// marginY = 600 * 0.2 = 120
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})
})
describe('edge cases', () => {
it('should handle extreme zoom levels', () => {
const { syncWithCanvas, canvasToScreen } = transformState
const mockCanvas = createMockCanvasContext()
// Very small zoom
mockCanvas.ds.scale = 0.001
syncWithCanvas(mockCanvas as any)
const point1 = canvasToScreen({ x: 1000, y: 1000 })
expect(point1.x).toBeCloseTo(1)
expect(point1.y).toBeCloseTo(1)
// Very large zoom
mockCanvas.ds.scale = 100
syncWithCanvas(mockCanvas as any)
const point2 = canvasToScreen({ x: 1, y: 1 })
expect(point2.x).toBe(100)
expect(point2.y).toBe(100)
})
it('should handle zero scale in screenToCanvas', () => {
const { syncWithCanvas, screenToCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Scale of 0 gets converted to 1 by || operator
mockCanvas.ds.scale = 0
syncWithCanvas(mockCanvas as any)
// Should use scale of 1 due to camera.z || 1 in implementation
const result = screenToCanvas({ x: 100, y: 100 })
expect(result.x).toBe(100) // (100 - 0) / 1
expect(result.y).toBe(100) // (100 - 0) / 1
})
})
})

View File

@@ -1,55 +0,0 @@
import { describe, expect, it } from 'vitest'
import { useChainCallback } from '@/composables/functional/useChainCallback'
describe('useChainCallback', () => {
it('preserves "this" context in original callback and chained callbacks', () => {
class TestClass {
value = 'test'
constructor() {
this.method = useChainCallback(this.method, function (this: TestClass) {
expect(this.value).toBe('test')
})
}
method() {
expect(this.value).toBe('test')
}
}
const instance = new TestClass()
instance.method()
})
it('handles undefined original callback', () => {
const context = { value: 'test' }
const chainedFn = useChainCallback(
undefined,
function (this: typeof context) {
expect(this.value).toBe('test')
}
)
chainedFn.call(context)
})
it('passes arguments to all callbacks', () => {
const originalCalls: number[] = []
const chainedCalls: number[] = []
const original = function (this: unknown, num: number) {
originalCalls.push(num)
}
const chained = function (this: unknown, num: number) {
chainedCalls.push(num)
}
const chainedFn = useChainCallback(original, chained)
chainedFn(42)
expect(originalCalls).toEqual([42])
expect(chainedCalls).toEqual([42])
})
})

View File

@@ -1,271 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as any)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
const typedItem = item as { isNode?: boolean }
return typedItem?.isNode !== false
})
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
)
})
describe('Selection Detection', () => {
test('should return false when nothing selected', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(false)
})
test('should return true when items selected', () => {
// Update the mock data before creating the composable
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
// Update the mock data before creating the composable
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
expect(selectedNodes.value[0]).toEqual(graphNode)
})
})
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
// Update the mock data before creating the composable
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isBypassed).toBe(true)
})
test('should detect pinned/collapsed states', () => {
// Update the mock data before creating the composable
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(true)
expect(isBypassed).toBe(false)
})
test('should provide non-reactive state computation', () => {
// Update the mock data before creating the composable
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(false)
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
mockSelectedItems.value = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
})
})

View File

@@ -1,171 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
describe('useTransformSettling', () => {
let element: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
element = document.createElement('div')
document.body.appendChild(element)
})
afterEach(() => {
vi.useRealTimers()
document.body.removeChild(element)
})
it('should track wheel events and settle after delay', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 200
})
// Initially not transforming
expect(isTransforming.value).toBe(false)
// Dispatch wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Should be transforming
expect(isTransforming.value).toBe(true)
// Advance time but not past settle delay
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(true)
// Advance past settle delay
vi.advanceTimersByTime(150)
expect(isTransforming.value).toBe(false)
})
it('should reset settle timer on subsequent wheel events', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 300
})
// First wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Advance time partially
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Another wheel event should reset the timer
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Advance 200ms more - should still be transforming
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Need another 100ms to settle (300ms total from last event)
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(false)
})
it('should not track pan events', async () => {
const { isTransforming } = useTransformSettling(element)
// Pointer events should not trigger transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(false)
})
it('should work with ref target', async () => {
const targetRef = ref<HTMLElement | null>(null)
const { isTransforming } = useTransformSettling(targetRef, {
settleDelay: 200
})
// No target yet
expect(isTransforming.value).toBe(false)
// Set target
targetRef.value = element
await nextTick()
// Now events should work
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should use capture phase for events', async () => {
const captureHandler = vi.fn()
const bubbleHandler = vi.fn()
// Add handlers to verify capture phase
element.addEventListener('wheel', captureHandler, true)
element.addEventListener('wheel', bubbleHandler, false)
const { isTransforming } = useTransformSettling(element)
// Create child element
const child = document.createElement('div')
element.appendChild(child)
// Dispatch event on child
child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Capture handler should be called before bubble handler
expect(captureHandler).toHaveBeenCalled()
expect(isTransforming.value).toBe(true)
element.removeEventListener('wheel', captureHandler, true)
element.removeEventListener('wheel', bubbleHandler, false)
})
it('should clean up event listeners when component unmounts', async () => {
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
// Create a test component
const TestComponent = {
setup() {
const { isTransforming } = useTransformSettling(element)
return { isTransforming }
},
template: '<div>{{ isTransforming }}</div>'
}
const wrapper = mount(TestComponent)
await nextTick()
// Unmount component
wrapper.unmount()
// Should have removed wheel event listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ capture: true })
)
})
it('should use passive listeners when specified', async () => {
const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
useTransformSettling(element, {
passive: true
})
// Check that passive option was used for wheel event
expect(addEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ passive: true, capture: true })
)
})
})

View File

@@ -1,512 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
let mockMaskCanvas: any
let mockRgbCanvas: any
let mockMaskCtx: any
let mockRgbCtx: any
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap for test environment
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
height: number
constructor(width = 100, height = 100) {
this.width = width
this.height = height
}
close() {}
} as any
}
describe('useCanvasHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
let rafCallCount = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
if (rafCallCount++ < 100) {
setTimeout(() => cb(0), 0)
}
return rafCallCount
}
)
vi.stubGlobal('alert', () => {})
const createMockImageData = () => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
}
mockMaskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockRgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockMaskCanvas = {
width: 100,
height: 100
}
mockRgbCanvas = {
width: 100,
height: 100
}
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
})
describe('initialization', () => {
it('should initialize with default values', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should save initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('saveState', () => {
it('should save a new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(true)
})
it('should clear redo states when saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should respect maxStates limit', () => {
const history = useCanvasHistory(3)
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.saveState()
expect(history.canUndo.value).toBe(true)
let undoCount = 0
while (history.canUndo.value && undoCount < 10) {
history.undo()
undoCount++
}
expect(undoCount).toBe(2)
})
it('should call saveInitialState if not initialized', () => {
const history = useCanvasHistory()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('undo', () => {
it('should undo to previous state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(true)
})
it('should show alert when no undo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.undo()
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
})
it('should undo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(false)
})
it('should not undo beyond first state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.undo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('redo', () => {
it('should redo to next state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.redo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canRedo.value).toBe(false)
expect(history.canUndo.value).toBe(true)
})
it('should show alert when no redo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.redo()
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
})
it('should redo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
history.undo()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
history.redo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('clearStates', () => {
it('should clear all states', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.clearStates()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should allow saving initial state after clear', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.clearStates()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
})
describe('canUndo computed', () => {
it('should be false with no states', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
})
it('should be false with only initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(history.canUndo.value).toBe(false)
})
it('should be true after saving a state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(true)
})
it('should be false after undoing to first state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(false)
})
})
describe('canRedo computed', () => {
it('should be false with no undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should be true after undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
})
it('should be false after redo to last state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should be false after saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
})
describe('restoreState', () => {
it('should not restore if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.undo()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('edge cases', () => {
it('should handle rapid state saves', async () => {
const history = useCanvasHistory()
history.saveInitialState()
for (let i = 0; i < 10; i++) {
history.saveState()
await nextTick()
}
expect(history.canUndo.value).toBe(true)
})
it('should handle maxStates of 1', () => {
const history = useCanvasHistory(1)
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(false)
})
it('should handle undo/redo cycling', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
history.redo()
history.undo()
history.redo()
history.undo()
expect(history.canRedo.value).toBe(true)
expect(history.canUndo.value).toBe(true)
})
it('should handle zero-sized canvas', () => {
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
const history = useCanvasHistory()
history.saveInitialState()
expect(window.requestAnimationFrame).toHaveBeenCalled()
})
})
})

View File

@@ -1,336 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
rgbCtx: null as any,
canvasBackground: null as any,
maskColor: { r: 0, g: 0, b: 0 },
maskBlendMode: MaskBlendMode.Black,
maskOpacity: 0.8
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
function createMockImage(width: number, height: number): HTMLImageElement {
return {
width,
height
} as HTMLImageElement
}
describe('useCanvasManager', () => {
let mockImageData: ImageData
beforeEach(() => {
vi.clearAllMocks()
mockImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
mockStore.imgCtx = {
drawImage: vi.fn()
}
mockStore.maskCtx = {
drawImage: vi.fn(),
getImageData: vi.fn(() => mockImageData),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over',
fillStyle: ''
}
mockStore.rgbCtx = {
drawImage: vi.fn()
}
mockStore.imgCanvas = {
width: 0,
height: 0
}
mockStore.maskCanvas = {
width: 0,
height: 0,
style: {
mixBlendMode: '',
opacity: ''
}
}
mockStore.rgbCanvas = {
width: 0,
height: 0
}
mockStore.canvasBackground = {
style: {
backgroundColor: ''
}
}
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskOpacity = 0.8
})
describe('invalidateCanvas', () => {
it('should set canvas dimensions', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCanvas.width).toBe(512)
expect(mockStore.imgCanvas.height).toBe(512)
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should draw original image', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
origImage,
0,
0,
512,
512
)
})
it('should draw paint image when provided', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
const paintImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, paintImage)
expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith(
paintImage,
0,
0,
512,
512
)
})
it('should not draw paint image when null', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
})
it('should prepare mask', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.drawImage).toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should throw error when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
})
it('should throw error when context missing', async () => {
const manager = useCanvasManager()
mockStore.imgCtx = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
})
})
describe('updateMaskColor', () => {
it('should update mask color for black blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskColor = { r: 0, g: 0, b: 0 }
await manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
expect(mockStore.maskCanvas.style.opacity).toBe('0.8')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(0,0,0,1)'
)
})
it('should update mask color for white blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.White
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(255,255,255,1)'
)
})
it('should update mask color for negative blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Negative
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference')
expect(mockStore.maskCanvas.style.opacity).toBe('1')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(255,255,255,1)'
)
})
it('should update all pixels with mask color', async () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 128, g: 64, b: 32 }
mockStore.maskCanvas.width = 100
mockStore.maskCanvas.height = 100
await manager.updateMaskColor()
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(128)
expect(mockImageData.data[i + 1]).toBe(64)
expect(mockImageData.data[i + 2]).toBe(32)
}
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockImageData,
0,
0
)
})
it('should return early when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null
await manager.updateMaskColor()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', async () => {
const manager = useCanvasManager()
mockStore.maskCtx = null
await manager.updateMaskColor()
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
})
it('should handle different opacity values', async () => {
const manager = useCanvasManager()
mockStore.maskOpacity = 0.5
await manager.updateMaskColor()
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
})
})
describe('prepareMask', () => {
it('should invert mask alpha', async () => {
const manager = useCanvasManager()
for (let i = 0; i < mockImageData.data.length; i += 4) {
mockImageData.data[i + 3] = 128
}
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i + 3]).toBe(127)
}
})
it('should apply mask color to all pixels', async () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 100, g: 150, b: 200 }
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(100)
expect(mockImageData.data[i + 1]).toBe(150)
expect(mockImageData.data[i + 2]).toBe(200)
}
})
it('should set composite operation', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over')
})
})
})

View File

@@ -1,480 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
const mockCanvasHistory = {
saveState: vi.fn()
}
const mockStore = {
maskCtx: null as any,
imgCtx: null as any,
maskCanvas: null as any,
imgCanvas: null as any,
rgbCtx: null as any,
rgbCanvas: null as any,
maskColor: { r: 255, g: 255, b: 255 },
paintBucketTolerance: 10,
fillOpacity: 100,
colorSelectTolerance: 30,
colorComparisonMethod: ColorComparisonMethod.Simple,
selectionOpacity: 100,
applyWholeImage: false,
maskBoundary: false,
maskTolerance: 10,
canvasHistory: mockCanvasHistory
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
describe('useCanvasTools', () => {
let mockMaskImageData: ImageData
let mockImgImageData: ImageData
beforeEach(() => {
vi.clearAllMocks()
mockMaskImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
mockImgImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
mockImgImageData.data[i] = 255
mockImgImageData.data[i + 1] = 0
mockImgImageData.data[i + 2] = 0
mockImgImageData.data[i + 3] = 255
}
mockStore.maskCtx = {
getImageData: vi.fn(() => mockMaskImageData),
putImageData: vi.fn(),
clearRect: vi.fn()
}
mockStore.imgCtx = {
getImageData: vi.fn(() => mockImgImageData)
}
mockStore.rgbCtx = {
clearRect: vi.fn()
}
mockStore.maskCanvas = {
width: 100,
height: 100
}
mockStore.imgCanvas = {
width: 100,
height: 100
}
mockStore.rgbCanvas = {
width: 100,
height: 100
}
mockStore.maskColor = { r: 255, g: 255, b: 255 }
mockStore.paintBucketTolerance = 10
mockStore.fillOpacity = 100
mockStore.colorSelectTolerance = 30
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
mockStore.selectionOpacity = 100
mockStore.applyWholeImage = false
mockStore.maskBoundary = false
mockStore.maskTolerance = 10
})
describe('paintBucketFill', () => {
it('should fill empty area with mask color', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(255)
})
it('should erase filled area', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 255
}
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(0)
})
it('should respect tolerance', () => {
mockStore.paintBucketTolerance = 0
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 0
}
const centerIndex = (50 * 100 + 50) * 4
mockMaskImageData.data[centerIndex + 3] = 10
const tools = useCanvasTools()
tools.paintBucketFill({ x: 51, y: 50 })
expect(mockMaskImageData.data[centerIndex + 3]).toBe(10)
})
it('should return early when point out of bounds', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply fill opacity', () => {
mockStore.fillOpacity = 50
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(127)
})
it('should apply mask color', () => {
mockStore.maskColor = { r: 128, g: 64, b: 32 }
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index]).toBe(128)
expect(mockMaskImageData.data[index + 1]).toBe(64)
expect(mockMaskImageData.data[index + 2]).toBe(32)
})
})
describe('colorSelectFill', () => {
it('should select pixels by color with flood fill', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should select pixels in whole image when applyWholeImage is true', async () => {
mockStore.applyWholeImage = true
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect color tolerance', async () => {
mockStore.colorSelectTolerance = 0
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
mockImgImageData.data[i] = 200
}
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(255)
})
it('should return early when point out of bounds', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', async () => {
mockStore.imgCanvas = null
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply selection opacity', async () => {
mockStore.selectionOpacity = 50
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(127)
})
it('should use HSL color comparison method', async () => {
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should use LAB color comparison method', async () => {
mockStore.colorComparisonMethod = ColorComparisonMethod.LAB
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect mask boundary', async () => {
mockStore.maskBoundary = true
mockStore.maskTolerance = 0
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 255
}
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should update last color select point', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 30, y: 40 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
describe('invertMask', () => {
it('should invert mask alpha values', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i] = 255
mockMaskImageData.data[i + 1] = 255
mockMaskImageData.data[i + 2] = 255
mockMaskImageData.data[i + 3] = 128
}
const tools = useCanvasTools()
tools.invertMask()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
expect(mockMaskImageData.data[i + 3]).toBe(127)
}
})
it('should preserve mask color for empty pixels', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 0
}
const firstPixelIndex = 100
mockMaskImageData.data[firstPixelIndex * 4] = 128
mockMaskImageData.data[firstPixelIndex * 4 + 1] = 64
mockMaskImageData.data[firstPixelIndex * 4 + 2] = 32
mockMaskImageData.data[firstPixelIndex * 4 + 3] = 255
const tools = useCanvasTools()
tools.invertMask()
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
if (i !== firstPixelIndex * 4) {
expect(mockMaskImageData.data[i]).toBe(128)
expect(mockMaskImageData.data[i + 1]).toBe(64)
expect(mockMaskImageData.data[i + 2]).toBe(32)
}
}
})
it('should return early when canvas missing', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.invertMask()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', () => {
mockStore.maskCtx = null
const tools = useCanvasTools()
tools.invertMask()
expect(mockCanvasHistory.saveState).not.toHaveBeenCalled()
})
})
describe('clearMask', () => {
it('should clear mask canvas', () => {
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should handle missing mask canvas', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should handle missing rgb canvas', () => {
mockStore.rgbCanvas = null
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
})
describe('clearLastColorSelectPoint', () => {
it('should clear last color select point', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
tools.clearLastColorSelectPoint()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle small canvas', () => {
mockStore.maskCanvas.width = 1
mockStore.maskCanvas.height = 1
mockMaskImageData = {
data: new Uint8ClampedArray(1 * 1 * 4),
width: 1,
height: 1
} as ImageData
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
const tools = useCanvasTools()
tools.paintBucketFill({ x: 0, y: 0 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle fractional coordinates', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50.7, y: 50.3 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle maximum tolerance', () => {
mockStore.paintBucketTolerance = 255
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle zero opacity', () => {
mockStore.fillOpacity = 0
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(0)
})
})
})

View File

@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
const mockCanvasManager = {
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
updateMaskColor: vi.fn().mockResolvedValue(undefined)
}
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
image: null as any
}
const mockDataStore = {
inputData: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
useCanvasManager: vi.fn(() => mockCanvasManager)
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: (fn: any) => fn
}))
describe('useImageLoader', () => {
let mockBaseImage: HTMLImageElement
let mockMaskImage: HTMLImageElement
let mockPaintImage: HTMLImageElement
beforeEach(() => {
vi.clearAllMocks()
mockBaseImage = {
width: 512,
height: 512
} as HTMLImageElement
mockMaskImage = {
width: 512,
height: 512
} as HTMLImageElement
mockPaintImage = {
width: 512,
height: 512
} as HTMLImageElement
mockStore.imgCtx = {
clearRect: vi.fn()
}
mockStore.maskCtx = {
clearRect: vi.fn()
}
mockStore.imgCanvas = {
width: 0,
height: 0
}
mockStore.maskCanvas = {
width: 0,
height: 0
}
mockStore.rgbCanvas = {
width: 0,
height: 0
}
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
maskLayer: { image: mockMaskImage },
paintLayer: { image: mockPaintImage }
}
})
describe('loadImages', () => {
it('should load images successfully', async () => {
const loader = useImageLoader()
const result = await loader.loadImages()
expect(result).toBe(mockBaseImage)
expect(mockStore.image).toBe(mockBaseImage)
})
it('should set canvas dimensions', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should clear canvas contexts', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
})
it('should call canvasManager methods', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
mockBaseImage,
mockMaskImage,
mockPaintImage
)
expect(mockCanvasManager.updateMaskColor).toHaveBeenCalled()
})
it('should handle missing paintLayer', async () => {
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
maskLayer: { image: mockMaskImage },
paintLayer: null
}
const loader = useImageLoader()
await loader.loadImages()
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
mockBaseImage,
mockMaskImage,
null
)
})
it('should throw error when no input data', async () => {
mockDataStore.inputData = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'No input data available in dataStore'
)
})
it('should throw error when canvas elements missing', async () => {
mockStore.imgCanvas = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'Canvas elements or contexts not available'
)
})
it('should throw error when contexts missing', async () => {
mockStore.imgCtx = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'Canvas elements or contexts not available'
)
})
it('should handle different image dimensions', async () => {
mockBaseImage.width = 1024
mockBaseImage.height = 768
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(1024)
expect(mockStore.maskCanvas.height).toBe(768)
expect(mockStore.rgbCanvas.width).toBe(1024)
expect(mockStore.rgbCanvas.height).toBe(768)
})
})
})

View File

@@ -1,57 +0,0 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from '../../litegraph/subgraph/fixtures/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
mockNode.badges = [getCreditsBadge('$0.05/Run')]
function getBadgeText(node: LGraphNode): string {
const badge = node.badges[0]
return (typeof badge === 'function' ? badge() : badge).text
}
describe('subgraph pricing', () => {
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {
const { subgraphNode } = subgraphWithNode
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(0)
}
)
subgraphTest(
'should return the price of a single contained API node',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
}
)
subgraphTest(
'should return the number of api nodes if more than one exists',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
for (let i = 0; i < 5; i++) subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,378 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: () => ({
t: vi.fn((key) => key)
})
}
})
type NodePack = components['schemas']['Node']
describe('usePacksSelection', () => {
let managerStore: ReturnType<typeof useComfyManagerStore>
let mockIsPackInstalled: (packName: string | undefined) => boolean
const createMockPack = (id: string): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: 'NodeStatusActive'
})
beforeEach(() => {
vi.clearAllMocks()
const pinia = createPinia()
setActivePinia(pinia)
managerStore = useComfyManagerStore()
// Mock the isPackInstalled method
mockIsPackInstalled = vi.fn()
managerStore.isPackInstalled = mockIsPackInstalled
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('installedPacks', () => {
it('should filter and return only installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1' || id === 'pack3'
})
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(2)
expect(installedPacks.value[0].id).toBe('pack1')
expect(installedPacks.value[1].id).toBe('pack3')
expect(mockIsPackInstalled).toHaveBeenCalledTimes(3)
})
it('should return empty array when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(0)
})
it('should update when nodePacks ref changes', () => {
const nodePacks = ref<NodePack[]>([createMockPack('pack1')])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
// Add more packs
nodePacks.value = [
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
]
expect(installedPacks.value).toHaveLength(3)
})
})
describe('notInstalledPacks', () => {
it('should filter and return only not installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1'
})
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
expect(notInstalledPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value[1].id).toBe('pack3')
})
it('should return all packs when none are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
})
})
describe('isAllInstalled', () => {
it('should return true when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
it('should return false when not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
})
describe('isNoneInstalled', () => {
it('should return true when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
it('should return false when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
})
describe('isMixed', () => {
it('should return true when some but not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1' || id === 'pack2'
})
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(true)
})
it('should return false when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
})
describe('selectionState', () => {
it('should return "all-installed" when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('all-installed')
})
it('should return "none-installed" when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
})
it('should return "mixed" when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('mixed')
})
it('should update when installation status changes', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
// Change mock to simulate installation
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
// Force reactivity update
nodePacks.value = [...nodePacks.value]
expect(selectionState.value).toBe('all-installed')
})
})
describe('edge cases', () => {
it('should handle packs with undefined ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack2')
const { installedPacks, notInstalledPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
expect(installedPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value).toHaveLength(1)
})
it('should handle dynamic changes to pack installation status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const installationStatus: Record<string, boolean> = {
pack1: false,
pack2: false
}
vi.mocked(mockIsPackInstalled).mockImplementation(
(id) => (id && installationStatus[id]) || false
)
const { installedPacks, notInstalledPacks, selectionState } =
usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
expect(installedPacks.value).toHaveLength(0)
expect(notInstalledPacks.value).toHaveLength(2)
// Simulate installing pack1
installationStatus.pack1 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('mixed')
expect(installedPacks.value).toHaveLength(1)
expect(notInstalledPacks.value).toHaveLength(1)
// Simulate installing pack2
installationStatus.pack2 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('all-installed')
expect(installedPacks.value).toHaveLength(2)
expect(notInstalledPacks.value).toHaveLength(0)
})
})
})

View File

@@ -1,384 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
type NodePack = components['schemas']['Node']
type NodeStatus = components['schemas']['NodeStatus']
type NodeVersionStatus = components['schemas']['NodeVersionStatus']
describe('usePacksStatus', () => {
let conflictDetectionStore: ReturnType<typeof useConflictDetectionStore>
const createMockPack = (
id: string,
status?: NodeStatus | NodeVersionStatus
): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: (status || 'NodeStatusActive') as NodeStatus
})
const createMockConflict = (
packageId: string,
type: 'import_failed' | 'banned' | 'pending' = 'import_failed'
): ConflictDetectionResult => ({
package_id: packageId,
package_name: `Pack ${packageId}`,
has_conflict: true,
conflicts: [
{
type,
current_value: 'current',
required_value: 'required'
}
],
is_compatible: false
})
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
conflictDetectionStore = useConflictDetectionStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasImportFailed', () => {
it('should return true when at least one pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
// Set up mock conflicts
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed'),
createMockConflict('pack3', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should return false when no pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
// Set up mock conflicts with no import_failed
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'pending'),
createMockConflict('pack2', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should return false when no conflicts exist', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle packs without ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should update when conflicts change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
// Add import_failed conflict
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
expect(hasImportFailed.value).toBe(true)
})
})
describe('overallStatus', () => {
it('should prioritize banned status over all others', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should prioritize version banned over deleted and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusBanned')
})
it('should prioritize deleted status appropriately', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusDeleted')
})
it('should prioritize version deleted over flagged and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusFlagged'),
createMockPack('pack2', 'NodeVersionStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusDeleted')
})
it('should prioritize flagged status over pending and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusPending'),
createMockPack('pack2', 'NodeVersionStatusFlagged'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusFlagged')
})
it('should prioritize pending status over active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusActive'),
createMockPack('pack2', 'NodeVersionStatusPending'),
createMockPack('pack3', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusPending')
})
it('should return NodeStatusActive when all packs are active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should return NodeStatusActive as default when all packs have no status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Since createMockPack sets status to 'NodeStatusActive' by default
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should handle empty pack array', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should update when pack statuses change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
// Change one pack to banned
nodePacks.value = [
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
describe('integration with import failures', () => {
it('should return NodeVersionStatusActive when import failures exist (handled separately)', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// When import failed exists, it returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should return NodeVersionStatusActive when import failures exist even with banned status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// Import failed takes priority and returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
})
describe('edge cases', () => {
it('should handle multiple conflicts per package', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
{
package_id: 'pack1',
package_name: 'Pack pack1',
has_conflict: true,
conflicts: [
{
type: 'pending',
current_value: 'current1',
required_value: 'required1'
},
{
type: 'import_failed',
current_value: 'current2',
required_value: 'required2'
}
],
is_compatible: false
}
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should handle packs with no conflicts in store', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle mixed status types correctly', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeStatusDeleted'),
createMockPack('pack4', 'NodeVersionStatusDeleted'),
createMockPack('pack5', 'NodeVersionStatusFlagged'),
createMockPack('pack6', 'NodeVersionStatusPending'),
createMockPack('pack7', 'NodeStatusActive'),
createMockPack('pack8', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Should return the highest priority status (NodeStatusBanned)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should be reactive to nodePacks changes', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
// Add packs
nodePacks.value = [
createMockPack('pack1', 'NodeStatusDeleted'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusDeleted')
// Add a higher priority status
nodePacks.value.push(createMockPack('pack3', 'NodeStatusBanned'))
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
})

View File

@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: new Map(),
modelLoadingByNodeType: new Map(),
modelErrorByNodeType: new Map(),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -1,245 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockModelAssetsByNodeType = new Map<string, AssetItem[]>()
const mockModelLoadingByNodeType = new Map<string, boolean>()
const mockModelErrorByNodeType = new Map<string, Error | null>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: mockModelAssetsByNodeType,
modelLoadingByNodeType: mockModelLoadingByNodeType,
modelErrorByNodeType: mockModelErrorByNodeType,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelAssetsByNodeType.clear()
mockModelLoadingByNodeType.clear()
mockModelErrorByNodeType.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (): Promise<AssetItem[]> => {
return []
}
)
})
const createMockAsset = (
id: string,
name: string,
filename: string,
previewUrl?: string
): AssetItem => ({
id,
name,
size: 1024,
tags: ['models', 'checkpoints'],
created_at: '2025-01-01T00:00:00Z',
preview_url: previewUrl,
user_metadata: {
filename
}
})
it('fetches assets and transforms to dropdown items', async () => {
const mockAssets: AssetItem[] = [
createMockAsset(
'asset-1',
'Beautiful Model',
'models/beautiful_model.safetensors',
'/api/preview/asset-1'
),
createMockAsset('asset-2', 'Model B', 'model_b.safetensors', '/preview/2')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading } =
useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
expect(dropdownItems.value).toHaveLength(2)
const item = dropdownItems.value[0]
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.mediaSrc).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {
const mockError = new Error('Network error')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelErrorByNodeType.set(_nodeType, mockError)
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { assets, error, isLoading } = useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(error.value).toBe(mockError)
expect(assets.value).toEqual([])
})
it('returns empty for unknown node type', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('UnknownNodeType')
const { category, assets } = useAssetWidgetData(nodeType)
await nextTick()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
})
describe('MaybeRefOrGetter parameter support', () => {
it('accepts plain string value', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const { category, assets, isLoading } = useAssetWidgetData(
'CheckpointLoaderSimple'
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('accepts getter function', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('LoraLoader')
const { category, assets, isLoading } = useAssetWidgetData(
() => nodeType.value
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
expect(category.value).toBe('loras')
expect(assets.value).toEqual(mockAssets)
})
it('accepts ref (backward compatibility)', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeTypeRef = ref('CheckpointLoaderSimple')
const { category, assets, isLoading } = useAssetWidgetData(nodeTypeRef)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('handles undefined node type gracefully', async () => {
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(undefined)
await nextTick()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
})

View File

@@ -1,236 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCachedRequest } from '@/composables/useCachedRequest'
describe('useCachedRequest', () => {
let mockRequestFn: (
params: any,
signal?: AbortSignal
) => Promise<unknown | null>
let abortSpy: () => void
beforeEach(() => {
vi.clearAllMocks()
// Create a spy for the AbortController.abort method
abortSpy = vi.fn()
// Mock AbortController
vi.stubGlobal(
'AbortController',
class MockAbortController {
signal = { aborted: false }
abort = abortSpy
}
)
// Create a mock request function that returns different results based on params
mockRequestFn = vi.fn(async (params: any) => {
// Simulate a request that takes some time
await new Promise((resolve) => setTimeout(resolve, 8))
if (params === null) return null
// Return a result based on the params
return { data: `Result for ${JSON.stringify(params)}` }
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should cache results and not repeat calls with the same params', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// First call should make the request
const result1 = await cachedRequest.call({ id: 1 })
expect(result1).toEqual({ data: 'Result for {"id":1}' })
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Second call with the same params should use the cache
const result2 = await cachedRequest.call({ id: 1 })
expect(result2).toEqual({ data: 'Result for {"id":1}' })
expect(mockRequestFn).toHaveBeenCalledTimes(1) // Still only called once
// Call with different params should make a new request
const result3 = await cachedRequest.call({ id: 2 })
expect(result3).toEqual({ data: 'Result for {"id":2}' })
expect(mockRequestFn).toHaveBeenCalledTimes(2)
})
it('should deduplicate in-flight requests with the same params', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Start two requests with the same params simultaneously
const promise1 = cachedRequest.call({ id: 1 })
const promise2 = cachedRequest.call({ id: 1 })
// Wait for both to complete
const [result1, result2] = await Promise.all([promise1, promise2])
// Both should have the same result
expect(result1).toEqual({ data: 'Result for {"id":1}' })
expect(result2).toEqual({ data: 'Result for {"id":1}' })
// But the request function should only be called once
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should not repeat requests that throw errors', async () => {
// Create a mock function that throws an error
const errorMockFn = vi.fn(async () => {
throw new Error('Test error')
})
const cachedRequest = useCachedRequest(errorMockFn)
// Make a request that will throw
const result = await cachedRequest.call({ id: 1 })
// The result should be null
expect(result).toBeNull()
expect(errorMockFn).toHaveBeenCalledTimes(1)
// Make the same request again
const result2 = await cachedRequest.call({ id: 1 })
expect(result2).toBeNull()
// Verify error result is cached and not called again
expect(errorMockFn).toHaveBeenCalledTimes(1)
})
it('should evict least recently used entries when cache exceeds maxSize', async () => {
// Create a cached request with a small max size
const cachedRequest = useCachedRequest(mockRequestFn, { maxSize: 2 })
// Make 3 different requests to exceed the cache size
await cachedRequest.call({ id: 1 })
await cachedRequest.call({ id: 2 })
await cachedRequest.call({ id: 3 })
await cachedRequest.call({ id: 4 })
expect(mockRequestFn).toHaveBeenCalledTimes(4)
// Request id:1 again - it should have been evicted
await cachedRequest.call({ id: 1 })
expect(mockRequestFn).toHaveBeenCalledTimes(5)
// Request least recently used entries
await cachedRequest.call({ id: 1 })
await cachedRequest.call({ id: 4 })
expect(mockRequestFn).toHaveBeenCalledTimes(5) // No new calls
})
it('should not repeat calls with same params in different order', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// First call with params in one order
await cachedRequest.call({ a: 1, b: 2 })
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Params in different order should still share cache key
await cachedRequest.call({ b: 2, a: 1 })
// Verify request function not called again (cache hit)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should use custom cache key function if provided', async () => {
// Create a cache key function that sorts object keys
const cacheKeyFn = (params: any) => {
if (typeof params !== 'object' || params === null) return String(params)
return JSON.stringify(
Object.keys(params)
.sort()
.reduce((acc, key) => ({ ...acc, [key]: params[key] }), {})
)
}
const cachedRequest = useCachedRequest(mockRequestFn, { cacheKeyFn })
// First call with params in one order
const result1 = await cachedRequest.call({ a: 1, b: 2 })
expect(result1).toEqual({ data: 'Result for {"a":1,"b":2}' })
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Second call with same params in different order should use cache
const result2 = await cachedRequest.call({ b: 2, a: 1 })
expect(result2).toEqual({ data: 'Result for {"a":1,"b":2}' })
expect(mockRequestFn).toHaveBeenCalledTimes(1) // Still only called once
})
it('should abort requests when cancel is called', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Start a request but don't await it
const promise = cachedRequest.call({ id: 1 })
// Cancel all requests
cachedRequest.cancel()
// The abort method should have been called
expect(abortSpy).toHaveBeenCalled()
// The promise should still resolve (our mock doesn't actually abort)
const result = await promise
expect(result).toEqual({ data: 'Result for {"id":1}' })
})
it('should clear the cache when clear is called', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Make a request to populate the cache
await cachedRequest.call({ id: 1 })
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Clear the cache
cachedRequest.clear()
// Make the same request again
await cachedRequest.call({ id: 1 })
// The request function should be called again
expect(mockRequestFn).toHaveBeenCalledTimes(2)
})
it('should handle null results correctly', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Make a request that returns null
const result = await cachedRequest.call(null)
expect(result).toBeNull()
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Make the same request again
const result2 = await cachedRequest.call(null)
expect(result2).toBeNull()
// Verify null result is treated as any other result (doesn't cause infinite cache miss)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should handle string params correctly', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Make requests with string params
await cachedRequest.call('string-param')
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Verify cache hit
await cachedRequest.call('string-param')
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should handle number params correctly', async () => {
const cachedRequest = useCachedRequest(mockRequestFn)
// Make request with number param
await cachedRequest.call(123)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
// Verify cache hit
await cachedRequest.call(123)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,289 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
type MockTask = {
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
executionEndTimestamp?: number
previewOutput?: {
isImage: boolean
urlWithTimestamp: string
}
}
vi.mock('@/stores/queueStore', () => {
const state = reactive({
runningTasks: [] as MockTask[],
historyTasks: [] as MockTask[]
})
return {
useQueueStore: () => state
}
})
vi.mock('@/stores/executionStore', () => {
const state = reactive({
isIdle: true
})
return {
useExecutionStore: () => state
}
})
describe('useCompletionSummary', () => {
const queueStore = () =>
useQueueStore() as {
runningTasks: MockTask[]
historyTasks: MockTask[]
}
const executionStore = () => useExecutionStore() as { isIdle: boolean }
const resetState = () => {
queueStore().runningTasks = []
queueStore().historyTasks = []
executionStore().isIdle = true
}
const createTask = (
options: {
state?: MockTask['displayStatus']
ts?: number
previewUrl?: string
isImage?: boolean
} = {}
): MockTask => {
const {
state = 'Completed',
ts = Date.now(),
previewUrl,
isImage = true
} = options
const task: MockTask = {
displayStatus: state,
executionEndTimestamp: ts
}
if (previewUrl) {
task.previewOutput = {
isImage,
urlWithTimestamp: previewUrl
}
}
return task
}
const runBatch = async (options: {
start: number
finish: number
tasks: MockTask[]
}) => {
const { start, finish, tasks } = options
vi.setSystemTime(start)
executionStore().isIdle = false
await nextTick()
vi.setSystemTime(finish)
queueStore().historyTasks = tasks
executionStore().isIdle = true
await nextTick()
}
beforeEach(() => {
resetState()
vi.useFakeTimers()
vi.setSystemTime(0)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
resetState()
})
it('summarizes the most recent batch and auto clears after the dismiss delay', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 1_000
const finish = 2_000
const tasks = [
createTask({ ts: start - 100, previewUrl: 'ignored-old' }),
createTask({ ts: start + 10, previewUrl: 'img-1' }),
createTask({ ts: start + 20, previewUrl: 'img-2' }),
createTask({ ts: start + 30, previewUrl: 'img-3' }),
createTask({ ts: start + 40, previewUrl: 'img-4' }),
createTask({ state: 'Failed', ts: start + 50 })
]
await runBatch({ start, finish, tasks })
expect(summary.value).toEqual({
mode: 'mixed',
completedCount: 4,
failedCount: 1,
thumbnailUrls: ['img-1', 'img-2', 'img-3']
})
vi.advanceTimersByTime(6000)
await nextTick()
expect(summary.value).toBeNull()
})
it('reports allFailed when every task in the batch failed', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 10_000
const finish = 10_200
await runBatch({
start,
finish,
tasks: [
createTask({ state: 'Failed', ts: start + 25 }),
createTask({ state: 'Failed', ts: start + 50 })
]
})
expect(summary.value).toEqual({
mode: 'allFailed',
completedCount: 0,
failedCount: 2,
thumbnailUrls: []
})
})
it('treats cancelled tasks as failures and skips non-image previews', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 15_000
const finish = 15_200
await runBatch({
start,
finish,
tasks: [
createTask({ ts: start + 25, previewUrl: 'img-1' }),
createTask({
state: 'Cancelled',
ts: start + 50,
previewUrl: 'thumb-ignore',
isImage: false
})
]
})
expect(summary.value).toEqual({
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: ['img-1']
})
})
it('clearSummary dismisses the banner immediately and still tracks future batches', async () => {
const { summary, clearSummary } = useCompletionSummary()
await nextTick()
await runBatch({
start: 5_000,
finish: 5_100,
tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })]
})
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-1']
})
clearSummary()
expect(summary.value).toBeNull()
await runBatch({
start: 6_000,
finish: 6_150,
tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })]
})
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-2']
})
})
it('ignores batches that have no finished tasks after the active period started', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 20_000
const finish = 20_500
await runBatch({
start,
finish,
tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })]
})
expect(summary.value).toBeNull()
})
it('derives the active period from running tasks when execution is already idle', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 25_000
vi.setSystemTime(start)
queueStore().runningTasks = [
createTask({ state: 'Running', ts: start + 1 })
]
await nextTick()
const finish = start + 150
vi.setSystemTime(finish)
queueStore().historyTasks = [
createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' })
]
queueStore().runningTasks = []
await nextTick()
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-running-trigger']
})
})
it('does not emit a summary when every finished task is still running or pending', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 30_000
const finish = 30_300
await runBatch({
start,
finish,
tasks: [
createTask({ state: 'Running', ts: start + 20 }),
createTask({ state: 'Pending', ts: start + 40 })
]
})
expect(summary.value).toBeNull()
})
})

View File

@@ -1,176 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useConflictAcknowledgment', () => {
beforeEach(() => {
// Set up Pinia for each test
setActivePinia(createPinia())
// Clear localStorage before each test
localStorage.clear()
// Reset modules to ensure fresh state
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
describe('initial state loading', () => {
it('should load empty state when localStorage is empty', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: false,
red_dot_dismissed: false,
warning_banner_dismissed: false
})
})
it('should load existing state from localStorage', async () => {
// Pre-populate localStorage with JSON values (as useStorage expects)
localStorage.setItem('Comfy.ConflictModalDismissed', JSON.stringify(true))
localStorage.setItem(
'Comfy.ConflictRedDotDismissed',
JSON.stringify(true)
)
localStorage.setItem(
'Comfy.ConflictWarningBannerDismissed',
JSON.stringify(true)
)
// Need to import the module after localStorage is set
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: true,
red_dot_dismissed: true,
warning_banner_dismissed: true
})
})
})
describe('dismissal functions', () => {
it('should mark conflicts as seen with unified function', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
})
it('should dismiss red dot notification', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissRedDotNotification, acknowledgmentState } =
useConflictAcknowledgment()
dismissRedDotNotification()
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
})
it('should dismiss warning banner', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissWarningBanner, acknowledgmentState } =
useConflictAcknowledgment()
dismissWarningBanner()
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
it('should mark all conflicts as seen', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
})
describe('computed properties', () => {
it('should calculate shouldShowConflictModal correctly', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowConflictModal, markConflictsAsSeen } =
useConflictAcknowledgment()
expect(shouldShowConflictModal.value).toBe(true)
markConflictsAsSeen()
expect(shouldShowConflictModal.value).toBe(false)
})
it('should calculate shouldShowRedDot correctly based on conflicts', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowRedDot, dismissRedDotNotification } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowRedDot.value).toBe(false)
dismissRedDotNotification()
expect(shouldShowRedDot.value).toBe(false)
})
it('should calculate shouldShowManagerBanner correctly', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowManagerBanner, dismissWarningBanner } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowManagerBanner.value).toBe(false)
dismissWarningBanner()
expect(shouldShowManagerBanner.value).toBe(false)
})
})
describe('localStorage persistence', () => {
it('should persist to localStorage automatically', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, dismissWarningBanner } =
useConflictAcknowledgment()
markConflictsAsSeen()
dismissWarningBanner()
// Wait a tick for useStorage to sync
await new Promise((resolve) => setTimeout(resolve, 10))
// VueUse useStorage should automatically persist to localStorage as JSON
expect(localStorage.getItem('Comfy.ConflictModalDismissed')).toBe('true')
expect(localStorage.getItem('Comfy.ConflictWarningBannerDismissed')).toBe(
'true'
)
})
})
})

View File

@@ -1,481 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
// Mock @vueuse/core until function
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
until: vi.fn(() => ({
toBe: vi.fn(() => Promise.resolve())
}))
}
})
// Mock dependencies
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/versionUtil', () => ({
getFrontendVersion: vi.fn(() => '1.24.0'),
checkVersionCompatibility: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/systemCompatibility', () => ({
checkOSCompatibility: vi.fn(),
checkAcceleratorCompatibility: vi.fn(),
normalizeOSList: vi.fn((list) => list)
}))
vi.mock('@/workbench/extensions/manager/utils/conflictUtils', () => ({
consolidateConflictsByPackage: vi.fn((results) => results),
createBannedConflict: vi.fn((isBanned) =>
isBanned
? {
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
: null
),
createPendingConflict: vi.fn((isPending) =>
isPending
? {
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
}
: null
),
generateConflictSummary: vi.fn((results, duration) => ({
total_packages: results.length,
compatible_packages: results.filter(
(r: ConflictDetectionResult) => r.is_compatible
).length,
conflicted_packages: results.filter(
(r: ConflictDetectionResult) => r.has_conflict
).length,
banned_packages: 0,
pending_packages: 0,
conflicts_by_type_details: {},
last_check_timestamp: new Date().toISOString(),
check_duration_ms: duration
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: vi.fn()
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
() => ({
useInstalledPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
useConflictDetectionStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: vi.fn(() => ({
isNewManagerUI: { value: true }
}))
}))
describe('useConflictDetection', () => {
let pinia: ReturnType<typeof createPinia>
const mockComfyManagerService = {
getImportFailInfoBulk: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as unknown as ReturnType<typeof useComfyManagerService>
const mockRegistryService = {
getBulkNodeVersions: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as unknown as ReturnType<typeof useComfyRegistryService>
// Create a ref that can be modified in tests
const mockInstalledPacksWithVersions = ref<{ id: string; version: string }[]>(
[]
)
const mockInstalledPacks = {
startFetchInstalled: vi.fn(),
installedPacks: ref<components['schemas']['Node'][]>([]),
installedPacksWithVersions: computed(
() => mockInstalledPacksWithVersions.value
),
isReady: ref(false),
isLoading: ref(false),
error: ref<unknown>(null)
} as unknown as ReturnType<typeof useInstalledPacks>
const mockManagerStore = {
isPackEnabled: vi.fn()
} as unknown as ReturnType<typeof useComfyManagerStore>
// Create refs that can be used to control computed properties
const mockConflictedPackages = ref<ConflictDetectionResult[]>([])
const mockConflictStore = {
hasConflicts: computed(() =>
mockConflictedPackages.value.some((p) => p.has_conflict)
),
conflictedPackages: mockConflictedPackages,
bannedPackages: computed(() =>
mockConflictedPackages.value.filter((p) =>
p.conflicts?.some((c) => c.type === 'banned')
)
),
securityPendingPackages: computed(() =>
mockConflictedPackages.value.filter((p) =>
p.conflicts?.some((c) => c.type === 'pending')
)
),
setConflictedPackages: vi.fn(),
clearConflicts: vi.fn()
} as unknown as ReturnType<typeof useConflictDetectionStore>
const mockIsInitialized = ref(true)
const mockSystemStatsStore = {
systemStats: {
system: {
os: 'darwin', // sys.platform returns 'darwin' for macOS
ram_total: 17179869184,
ram_free: 8589934592,
comfyui_version: '0.3.41',
required_frontend_version: '1.24.0',
python_version:
'3.11.0 (main, Oct 13 2023, 09:34:16) [Clang 15.0.0 (clang-1500.0.40.1)]',
pytorch_version: '2.1.0',
embedded_python: false,
argv: ['--enable-manager']
},
devices: [
{
name: 'Apple M1 Pro',
type: 'mps',
index: 0,
vram_total: 17179869184,
vram_free: 8589934592,
torch_vram_total: 17179869184,
torch_vram_free: 8589934592
}
]
},
isInitialized: mockIsInitialized,
$state: {} as never,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
$id: 'systemStats',
_customProperties: new Set<string>()
} as unknown as ReturnType<typeof useSystemStatsStore>
const mockAcknowledgment = {
checkComfyUIVersionChange: vi.fn(),
acknowledgmentState: computed(() => ({})),
shouldShowConflictModal: computed(() => false),
shouldShowRedDot: computed(() => false),
shouldShowManagerBanner: computed(() => false),
dismissRedDotNotification: vi.fn(),
dismissWarningBanner: vi.fn(),
markConflictsAsSeen: vi.fn()
} as unknown as ReturnType<typeof useConflictAcknowledgment>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Setup mocks
vi.mocked(useComfyManagerService).mockReturnValue(mockComfyManagerService)
vi.mocked(useComfyRegistryService).mockReturnValue(mockRegistryService)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
vi.mocked(useConflictAcknowledgment).mockReturnValue(mockAcknowledgment)
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
// Reset mock implementations
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
undefined
)
vi.mocked(mockManagerStore.isPackEnabled).mockReturnValue(true)
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: []
})
vi.mocked(mockComfyManagerService.getImportFailInfoBulk).mockResolvedValue(
{}
)
// Reset the installedPacksWithVersions data
mockInstalledPacksWithVersions.value = []
// Reset conflicted packages
mockConflictedPackages.value = []
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('system environment collection', () => {
it('should collect system environment correctly', async () => {
const { collectSystemEnvironment } = useConflictDetection()
const environment = await collectSystemEnvironment()
expect(environment).toEqual({
comfyui_version: '0.3.41',
frontend_version: '1.24.0',
os: 'darwin',
accelerator: 'mps'
})
})
it('should handle missing system stats gracefully', async () => {
mockSystemStatsStore.systemStats = null as never
const { collectSystemEnvironment } = useConflictDetection()
const environment = await collectSystemEnvironment()
// When systemStats is null, empty strings are used as fallback
expect(environment).toEqual({
comfyui_version: '',
frontend_version: '1.24.0',
os: '',
accelerator: ''
})
})
})
describe('conflict detection', () => {
it('should detect version conflicts', async () => {
// Setup installed packages
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{
id: 'test-pack',
version: '1.0.0'
}
]
// Mock registry response with version requirements
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'test-pack', version: '1.0.0' },
node_version: {
supported_comfyui_version: '>=0.4.0',
supported_comfyui_frontend_version: '>=2.0.0',
supported_os: ['Windows', 'Linux', 'macOS'],
supported_accelerators: ['CUDA', 'Metal', 'CPU'],
status: 'NodeVersionStatusActive' as const,
version: '1.0.0',
publisher_id: 'test-publisher',
node_id: 'test-pack',
created_at: '2024-01-01T00:00:00Z'
} as components['schemas']['NodeVersion']
}
]
})
// Mock version checks to return conflicts
vi.mocked(checkVersionCompatibility).mockImplementation(
(type, current, required) => {
if (type === 'comfyui_version' && required === '>=0.4.0') {
return {
type: 'comfyui_version',
current_value: current || '0.3.41',
required_value: '>=0.4.0'
}
}
return null
}
)
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toHaveLength(1)
expect(result.results[0].has_conflict).toBe(true)
expect(result.results[0].conflicts).toContainEqual({
type: 'comfyui_version',
current_value: '0.3.41',
required_value: '>=0.4.0'
})
})
it('should detect banned packages', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'banned-pack',
name: 'Banned Pack'
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{
id: 'banned-pack',
version: '1.0.0'
}
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'banned-pack', version: '1.0.0' },
node_version: {
status: 'NodeVersionStatusBanned' as const,
version: '1.0.0',
publisher_id: 'test-publisher',
node_id: 'banned-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_os: undefined,
supported_accelerators: undefined
} as components['schemas']['NodeVersion']
}
]
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toContainEqual({
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
})
})
it('should detect import failures', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = [
{
id: 'fail-pack',
version: '1.0.0'
}
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'fail-pack': {
msg: 'Import error',
name: 'fail-pack',
path: '/path/to/pack'
} as any // The actual API returns different structure than types
})
// Mock registry response for the package
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: []
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toHaveLength(1)
// Import failure should match the actual implementation
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'installed',
required_value: 'Import error'
})
})
})
describe('computed properties', () => {
it('should expose conflict status from store', () => {
mockConflictedPackages.value = [
{
package_id: 'test',
package_name: 'Test',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
useConflictDetection()
// The hasConflicts computed should be true since we have a conflict
expect(mockConflictedPackages.value).toHaveLength(1)
expect(mockConflictedPackages.value[0].has_conflict).toBe(true)
})
})
describe('initialization', () => {
it('should initialize without errors', async () => {
// Mock that installed packs are ready
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
// Ensure startFetchInstalled resolves
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
undefined
)
const { initializeConflictDetection } = useConflictDetection()
// Set a timeout to prevent hanging
await expect(
Promise.race([
initializeConflictDetection(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 1000)
)
])
).resolves.not.toThrow()
})
})
})

View File

@@ -1,219 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
// Mock vue-i18n for useExternalLink
const mockLocale = ref('en')
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: vi.fn(() => ({
locale: mockLocale
}))
}
})
vi.mock('@/scripts/app', () => {
const mockGraphClear = vi.fn()
const mockCanvas = { subgraph: undefined }
return {
app: {
clean: vi.fn(() => {
// Simulate app.clean() calling graph.clear() only when not in subgraph
if (!mockCanvas.subgraph) {
mockGraphClear()
}
}),
canvas: mockCanvas,
rootGraph: {
clear: mockGraphClear
}
}
}
})
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
apiURL: vi.fn(() => 'http://localhost:8188')
}
}))
vi.mock('@/platform/settings/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('@/platform/workflow/core/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('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: vi.fn(() => ({
isActiveSubscription: vi.fn().mockReturnValue(true),
showSubscriptionDialog: 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.rootGraph.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.rootGraph.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.rootGraph.clear).not.toHaveBeenCalled()
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,353 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { useErrorHandling } from '@/composables/useErrorHandling'
describe('useErrorHandling', () => {
let errorHandler: ReturnType<typeof useErrorHandling>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
errorHandler = useErrorHandling()
})
describe('wrapWithErrorHandlingAsync', () => {
it('should execute action successfully', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
expect(action).toHaveBeenCalledOnce()
})
it('should call error handler when action throws', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should call finally handler after success', async () => {
const action = vi.fn(async () => 'success')
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
it('should call finally handler after error', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
describe('error recovery', () => {
it('should not use recovery strategy when no error occurs', async () => {
const action = vi.fn(async () => 'success')
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).not.toHaveBeenCalled()
expect(recoveryStrategy.recover).not.toHaveBeenCalled()
})
it('should use recovery strategy when it matches error', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn((error) => error === testError),
recover: vi.fn(async () => {
// Recovery succeeds, does nothing
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
it('should pass action and args to recovery strategy', async () => {
const testError = new Error('test error')
const action = vi.fn(async (_arg1: string, _arg2: number) => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy<[string, number], void> =
{
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test', 123)
expect(recoveryStrategy.recover).toHaveBeenCalledWith(
testError,
action,
['test', 123]
)
})
it('should retry operation when recovery succeeds', async () => {
let attemptCount = 0
const action = vi.fn(async (value: string) => {
attemptCount++
if (attemptCount === 1) {
throw new Error('first attempt failed')
}
return `success: ${value}`
})
const recoveryStrategy: ErrorRecoveryStrategy<[string], string> = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async (_error, retry, args) => {
await retry(...args)
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test-value')
expect(action).toHaveBeenCalledTimes(2)
expect(recoveryStrategy.recover).toHaveBeenCalledOnce()
})
it('should not call error handler when recovery succeeds', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).not.toHaveBeenCalled()
})
it('should call error handler when recovery fails', async () => {
const originalError = new Error('original error')
const recoveryError = new Error('recovery error')
const action = vi.fn(async () => {
throw originalError
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
throw recoveryError
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(originalError)
})
it('should try multiple recovery strategies in order', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const strategy1: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const strategy2: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const strategy3: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[strategy1, strategy2, strategy3]
)
await wrapped()
expect(strategy1.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy1.recover).not.toHaveBeenCalled()
expect(strategy2.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy2.recover).toHaveBeenCalled()
// Strategy 3 should not be checked because strategy 2 handled it
expect(strategy3.shouldHandle).not.toHaveBeenCalled()
expect(strategy3.recover).not.toHaveBeenCalled()
})
it('should fall back to error handler when no strategy matches', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const strategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[strategy]
)
await wrapped()
expect(strategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy.recover).not.toHaveBeenCalled()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should work with synchronous actions', async () => {
const testError = new Error('test error')
const action = vi.fn(() => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
})
describe('backward compatibility', () => {
it('should work without recovery strategies parameter', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
})
it('should work with empty recovery strategies array', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
})
})
})

View File

@@ -1,146 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the environment utilities
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(),
electronAPI: vi.fn()
}))
// Provide a minimal i18n instance for the composable
const i18n = vi.hoisted(() => ({
global: {
locale: {
value: 'en'
}
}
}))
vi.mock('@/i18n', () => ({
i18n
}))
// Import after mocking to get the mocked versions
import { useExternalLink } from '@/composables/useExternalLink'
import { electronAPI, isElectron } from '@/utils/envUtil'
describe('useExternalLink', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default state
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
})
describe('staticUrls', () => {
it('should provide common external URLs', () => {
const { staticUrls } = useExternalLink()
// Static URLs
expect(staticUrls.discord).toBe('https://www.comfy.org/discord')
expect(staticUrls.github).toBe(
'https://github.com/comfyanonymous/ComfyUI'
)
expect(staticUrls.githubIssues).toBe(
'https://github.com/comfyanonymous/ComfyUI/issues'
)
expect(staticUrls.githubFrontend).toBe(
'https://github.com/Comfy-Org/ComfyUI_frontend'
)
expect(staticUrls.githubElectron).toBe(
'https://github.com/Comfy-Org/electron'
)
expect(staticUrls.forum).toBe('https://forum.comfy.org/')
expect(staticUrls.comfyOrg).toBe('https://www.comfy.org/')
})
})
describe('buildDocsUrl', () => {
it('should build basic docs URL without locale', () => {
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog')
expect(url).toBe('https://docs.comfy.org/changelog')
})
it('should build docs URL with Chinese (zh) locale when requested', () => {
i18n.global.locale.value = 'zh'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
expect(url).toBe('https://docs.comfy.org/zh-CN/changelog')
})
it('should build docs URL with Chinese (zh-TW) locale when requested', () => {
i18n.global.locale.value = 'zh-TW'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
expect(url).toBe('https://docs.comfy.org/zh-CN/changelog')
})
it('should not include locale for English when requested', () => {
i18n.global.locale.value = 'en'
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/changelog', { includeLocale: true })
expect(url).toBe('https://docs.comfy.org/changelog')
})
it('should handle path without leading slash', () => {
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('changelog')
expect(url).toBe('https://docs.comfy.org/changelog')
})
it('should add platform suffix when requested', () => {
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
} as ReturnType<typeof electronAPI>)
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/installation/desktop', { platform: true })
expect(url).toBe('https://docs.comfy.org/installation/desktop/macos')
})
it('should add platform suffix with trailing slash', () => {
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'win32'
} as ReturnType<typeof electronAPI>)
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/installation/desktop/', { platform: true })
expect(url).toBe('https://docs.comfy.org/installation/desktop/windows')
})
it('should combine locale and platform', () => {
i18n.global.locale.value = 'zh'
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(electronAPI).mockReturnValue({
getPlatform: () => 'darwin'
} as ReturnType<typeof electronAPI>)
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/installation/desktop', {
includeLocale: true,
platform: true
})
expect(url).toBe(
'https://docs.comfy.org/zh-CN/installation/desktop/macos'
)
})
it('should not add platform when not desktop', () => {
i18n.global.locale.value = 'en'
vi.mocked(isElectron).mockReturnValue(false)
const { buildDocsUrl } = useExternalLink()
const url = buildDocsUrl('/installation/desktop', { platform: true })
expect(url).toBe('https://docs.comfy.org/installation/desktop')
})
})
})

View File

@@ -1,137 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'
import {
ServerFeatureFlag,
useFeatureFlags
} from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('flags object', () => {
it('should provide reactive readonly flags', () => {
const { flags } = useFeatureFlags()
expect(isReadonly(flags)).toBe(true)
expect(isReactive(flags)).toBe(true)
})
it('should access supportsPreviewMetadata', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 209715200 as any // 200MB
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})
it('should access supportsManagerV4', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
)
})
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue as any
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.maxUploadSize).toBeUndefined()
expect(flags.supportsManagerV4).toBeUndefined()
})
})
describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value' as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const customFlag = featureFlag('custom.feature', 'default')
expect(customFlag.value).toBe('custom-value')
expect(api.getServerFeature).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})
it('should handle nested paths', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const nestedFlag = featureFlag('extension.custom.nested.feature', false)
expect(nestedFlag.value).toBe(true)
})
it('should work with ServerFeatureFlag enum', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 104857600 as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const maxUploadSize = featureFlag(ServerFeatureFlag.MAX_UPLOAD_SIZE)
expect(maxUploadSize.value).toBe(104857600)
})
})
})

View File

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

View File

@@ -1,251 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import * as dialogService from '@/services/dialogService'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import * as comfyManagerStore from '@/workbench/extensions/manager/stores/comfyManagerStore'
import * as conflictDetectionStore from '@/workbench/extensions/manager/stores/conflictDetectionStore'
// Mock the stores and services
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore')
vi.mock('@/services/dialogService')
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: vi.fn((key: string) => key)
})
}
})
describe('useImportFailedDetection', () => {
let mockComfyManagerStore: ReturnType<
typeof comfyManagerStore.useComfyManagerStore
>
let mockConflictDetectionStore: ReturnType<
typeof conflictDetectionStore.useConflictDetectionStore
>
let mockDialogService: ReturnType<typeof dialogService.useDialogService>
beforeEach(() => {
setActivePinia(createPinia())
mockComfyManagerStore = {
isPackInstalled: vi.fn()
} as unknown as ReturnType<typeof comfyManagerStore.useComfyManagerStore>
mockConflictDetectionStore = {
getConflictsForPackageByID: vi.fn()
} as unknown as ReturnType<
typeof conflictDetectionStore.useConflictDetectionStore
>
mockDialogService = {
showErrorDialog: vi.fn()
} as unknown as ReturnType<typeof dialogService.useDialogService>
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
mockComfyManagerStore
)
vi.mocked(conflictDetectionStore.useConflictDetectionStore).mockReturnValue(
mockConflictDetectionStore
)
vi.mocked(dialogService.useDialogService).mockReturnValue(mockDialogService)
})
it('should return false for importFailed when package is not installed', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(false)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when no conflicts exist', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue(undefined)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when conflicts exist but no import_failed type', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
},
{
type: 'frontend_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return true for importFailed when import_failed conflicts exist', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'current',
required_value: 'Error details'
},
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(true)
})
it('should work with computed ref packageId', () => {
const packageId = ref('test-package')
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'current',
required_value: 'Error details'
}
]
})
const { importFailed } = useImportFailedDetection(
computed(() => packageId.value)
)
expect(importFailed.value).toBe(true)
// Change packageId
packageId.value = 'another-package'
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue(undefined)
expect(importFailed.value).toBe(false)
})
it('should return correct importFailedInfo', () => {
const importFailedConflicts = [
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error 1'
},
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error 2'
}
]
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
...importFailedConflicts,
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailedInfo } = useImportFailedDetection('test-package')
expect(importFailedInfo.value).toEqual(importFailedConflicts)
})
it('should show error dialog when showImportFailedDialog is called', () => {
const importFailedConflicts = [
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error details'
}
]
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: importFailedConflicts
})
const { showImportFailedDialog } = useImportFailedDetection('test-package')
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
}
)
})
it('should handle null packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(null)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
it('should handle undefined packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(undefined)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
})

View File

@@ -1,493 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
import { buildJobDisplay } from '@/utils/queueDisplay'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import type { TaskItemImpl } from '@/stores/queueStore'
type TestTask = {
promptId: string
queueIndex: number
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
createTime?: number
workflow?: { id?: string }
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
}
}
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
}))
let totalPercent: Ref<number>
let currentNodePercent: Ref<number>
const ensureProgressRefs = () => {
if (!totalPercent) totalPercent = ref(0) as Ref<number>
if (!currentNodePercent) currentNodePercent = ref(0) as Ref<number>
return { totalPercent, currentNodePercent }
}
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => {
ensureProgressRefs()
return {
totalPercent,
currentNodePercent
}
}
}))
vi.mock('@/utils/queueDisplay', () => ({
buildJobDisplay: vi.fn(
(task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({
primary: `Job ${task.promptId}`,
secondary: `${state} meta`,
iconName: `${state}-icon`,
iconImageUrl: undefined,
showClear: state === 'failed',
options
})
)
}))
vi.mock('@/utils/queueUtil', () => ({
jobStateFromTask: vi.fn(
(task: TestTask, isInitializing?: boolean): JobState =>
task.mockState ?? (isInitializing ? 'running' : 'completed')
)
}))
let queueStoreMock: {
pendingTasks: TestTask[]
runningTasks: TestTask[]
historyTasks: TestTask[]
}
const ensureQueueStore = () => {
if (!queueStoreMock) {
queueStoreMock = reactive({
pendingTasks: [] as TestTask[],
runningTasks: [] as TestTask[],
historyTasks: [] as TestTask[]
})
}
return queueStoreMock
}
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => {
return ensureQueueStore()
}
}))
let executionStoreMock: {
activePromptId: string | null
executingNode: null | { title?: string; type?: string }
isPromptInitializing: (promptId?: string | number) => boolean
}
let isPromptInitializingMock: (promptId?: string | number) => boolean
const ensureExecutionStore = () => {
if (!isPromptInitializingMock) {
isPromptInitializingMock = vi.fn(() => false)
}
if (!executionStoreMock) {
executionStoreMock = reactive({
activePromptId: null as string | null,
executingNode: null as null | { title?: string; type?: string },
isPromptInitializing: (promptId?: string | number) =>
isPromptInitializingMock(promptId)
})
}
return executionStoreMock
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => {
return ensureExecutionStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
const ensureWorkflowStore = () => {
if (!workflowStoreMock) {
workflowStoreMock = reactive({
activeWorkflow: null as null | { activeState?: { id?: string } }
})
}
return workflowStoreMock
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => {
return ensureWorkflowStore()
}
}))
const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
promptId:
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
createTime: overrides.createTime,
workflow: overrides.workflow
})
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const wrapper = mount({
template: '<div />',
setup() {
composable = useJobList()
return {}
}
})
return { wrapper, composable: composable! }
}
const resetStores = () => {
const queueStore = ensureQueueStore()
queueStore.pendingTasks = []
queueStore.runningTasks = []
queueStore.historyTasks = []
const executionStore = ensureExecutionStore()
executionStore.activePromptId = null
executionStore.executingNode = null
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
ensureProgressRefs()
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (isPromptInitializingMock) {
vi.mocked(isPromptInitializingMock).mockReset()
vi.mocked(isPromptInitializingMock).mockReturnValue(false)
}
}
const flush = async () => {
await nextTick()
}
describe('useJobList', () => {
let wrapper: ReturnType<typeof mount> | null = null
let api: ReturnType<typeof useJobList> | null = null
beforeEach(() => {
vi.resetAllMocks()
resetStores()
wrapper?.unmount()
wrapper = null
api = null
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
api = null
vi.useRealTimers()
})
const initComposable = () => {
const mounted = mountUseJobList()
wrapper = mounted.wrapper
api = mounted.composable
return api!
}
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
vi.mocked(buildJobDisplay).mockClear()
await vi.advanceTimersByTimeAsync(3000)
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: false })
)
})
it('removes pending hint immediately when the task leaves the queue', async () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
queueStoreMock.pendingTasks = []
await flush()
expect(vi.getTimerCount()).toBe(0)
vi.mocked(buildJobDisplay).mockClear()
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
expect(buildJobDisplay).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
})
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
await flush()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper?.unmount()
wrapper = null
await flush()
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
queueStoreMock.runningTasks = [
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
]
queueStoreMock.historyTasks = [
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'r',
'h',
'p'
])
})
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
await flush()
instance.selectedJobTab.value = 'Completed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
expect(instance.hasFailedJobs.value).toBe(false)
expect(instance.selectedJobTab.value).toBe('All')
})
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflow: { id: 'workflow-1' }
}),
createTask({
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflow: { id: 'workflow-2' }
})
]
const instance = initComposable()
await flush()
instance.selectedWorkflowFilter.value = 'current'
await flush()
expect(instance.filteredTasks.value).toEqual([])
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
'wf-1'
])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'active',
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
promptId: 'other',
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
]
executionStoreMock.activePromptId = 'active'
executionStoreMock.executingNode = { title: 'Render Node' }
totalPercent.value = 80
currentNodePercent.value = 40
const { jobItems } = initComposable()
await flush()
const [activeJob, otherJob] = jobItems.value
expect(activeJob.progressTotalPercent).toBe(80)
expect(activeJob.progressCurrentPercent).toBe(40)
expect(activeJob.runningNodeName).toBe('Render Node')
expect(activeJob.computeHours).toBeCloseTo(2)
expect(otherJob.progressTotalPercent).toBeUndefined()
expect(otherJob.progressCurrentPercent).toBeUndefined()
expect(otherJob.runningNodeName).toBeUndefined()
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()
expect(instance.currentNodeName.value).toBe('--')
executionStoreMock.executingNode = { title: ' Visible Node ' }
await flush()
expect(instance.currentNodeName.value).toBe('Visible Node')
executionStoreMock.executingNode = {
title: ' ',
type: 'My Node Type'
}
await flush()
expect(instance.currentNodeName.value).toBe(
'i18n(nodeDefs.My Node Type.display_name)-My Node Type'
)
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
promptId: 'today-small',
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
promptId: 'today-large',
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
promptId: 'yesterday',
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
promptId: 'undated',
queueIndex: 1,
mockState: 'pending'
})
]
const instance = initComposable()
instance.selectedSortMode.value = 'totalGenerationTime'
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual([
'Today',
'Yesterday',
'Undated'
])
const todayGroup = groups[0]
expect(todayGroup.items.map((item) => item.id)).toEqual([
'today-large',
'today-small'
])
})
})

View File

@@ -1,728 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (...args: any[]) => downloadFileMock(...args)
}))
const copyToClipboardMock = vi.fn()
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
})
}))
const stMock = vi.fn((_: string, fallback?: string) => fallback ?? _)
const tMock = vi.fn((key: string) => `i18n:${key}`)
vi.mock('@/i18n', () => ({
st: (...args: Parameters<typeof stMock>) => stMock(...args),
t: (...args: Parameters<typeof tMock>) => tMock(...args)
}))
const mapTaskOutputToAssetItemMock = vi.fn()
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
mapTaskOutputToAssetItem: (...args: any[]) =>
mapTaskOutputToAssetItemMock(...args)
}))
const mediaAssetActionsMock = {
confirmDelete: vi.fn()
}
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActionsMock
}))
const settingStoreMock = {
get: vi.fn()
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStoreMock
}))
const workflowServiceMock = {
openWorkflow: vi.fn()
}
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => workflowServiceMock
}))
const workflowStoreMock = {
createTemporary: vi.fn()
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreMock
}))
const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (...args: any[]) => interruptMock(...args),
deleteItem: (...args: any[]) => deleteItemMock(...args)
}
}))
const downloadBlobMock = vi.fn()
vi.mock('@/scripts/utils', () => ({
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
}))
const dialogServiceMock = {
showExecutionErrorDialog: vi.fn(),
prompt: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: () => dialogServiceMock
}))
const litegraphServiceMock = {
addNodeOnGraph: vi.fn(),
getCanvasCenter: vi.fn()
}
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => litegraphServiceMock
}))
const nodeDefStoreMock = {
nodeDefsByName: {} as Record<string, any>
}
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => nodeDefStoreMock
}))
const queueStoreMock = {
update: vi.fn(),
delete: vi.fn()
}
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock
}))
const createAnnotatedPathMock = vi.fn()
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
}))
const appendJsonExtMock = vi.fn((value: string) =>
value.toLowerCase().endsWith('.json') ? value : `${value}.json`
)
vi.mock('@/utils/formatUtil', () => ({
appendJsonExt: (...args: Parameters<typeof appendJsonExtMock>) =>
appendJsonExtMock(...args)
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: overrides.id ?? 'job-1',
title: overrides.title ?? 'Test job',
meta: overrides.meta ?? 'meta',
state: overrides.state ?? 'completed',
taskRef: overrides.taskRef,
iconName: overrides.iconName,
iconImageUrl: overrides.iconImageUrl,
showClear: overrides.showClear,
progressCurrentPercent: overrides.progressCurrentPercent,
progressTotalPercent: overrides.progressTotalPercent,
runningNodeName: overrides.runningNodeName,
executionTimeMs: overrides.executionTimeMs,
computeHours: overrides.computeHours
})
let currentItem: Ref<JobListItem | null>
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
useJobMenu(() => currentItem.value, onInspectAsset)
const findActionEntry = (entries: MenuEntry[], key: string) =>
entries.find(
(entry): entry is Extract<MenuEntry, { kind?: 'item' }> =>
entry.key === key && entry.kind !== 'divider'
)
describe('useJobMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
currentItem = ref<JobListItem | null>(null)
settingStoreMock.get.mockReturnValue(false)
dialogServiceMock.prompt.mockResolvedValue(undefined)
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
litegraphServiceMock.addNodeOnGraph.mockReturnValue(null)
workflowStoreMock.createTemporary.mockImplementation((filename, data) => ({
filename,
data
}))
queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task,
output
}))
createAnnotatedPathMock.mockReturnValue('annotated-path')
nodeDefStoreMock.nodeDefsByName = {
LoadImage: { id: 'LoadImage' },
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
}
})
const setCurrentItem = (item: JobListItem | null) => {
currentItem.value = item
}
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
await openJobWorkflow()
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
'Job 55.json',
workflow
)
expect(workflowServiceMock.openWorkflow).toHaveBeenCalledWith({
filename: 'Job 55.json',
data: workflow
})
})
it('does nothing when workflow is missing', async () => {
const { openJobWorkflow } = mountJobMenu()
setCurrentItem(createJobItem({ taskRef: {} }))
await openJobWorkflow()
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
})
it('copies job id to clipboard', async () => {
const { copyJobId } = mountJobMenu()
setCurrentItem(createJobItem({ id: 'job-99' }))
await copyJobId()
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
})
it('ignores copy job id when no selection', async () => {
const { copyJobId } = mountJobMenu()
await copyJobId()
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
it.each([
['running', interruptMock, deleteItemMock],
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as any }))
await cancelJob()
expect(interruptMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('cancels pending job via deleteItem', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
await cancelJob()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
await cancelJob()
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'boom' }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
})
it('reports error via dialog when entry triggered', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'bad', extra: 1 }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
error
)
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
await copyEntry?.onClick?.()
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
})
const previewCases = [
{
label: 'image',
flags: { isImage: true },
expectedNode: 'LoadImage',
widget: 'image'
},
{
label: 'video',
flags: { isVideo: true },
expectedNode: 'LoadVideo',
widget: 'file'
},
{
label: 'audio',
flags: { isAudio: true },
expectedNode: 'LoadAudio',
widget: 'audio'
}
] as const
it.each(previewCases)(
'adds loader node for %s preview output',
async ({ flags, expectedNode, widget }) => {
const widgetCallback = vi.fn()
const node = {
widgets: [{ name: widget, value: null, callback: widgetCallback }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
const preview = {
filename: 'foo.png',
subfolder: 'bar',
type: 'output',
url: 'http://asset',
...flags
}
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: preview }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
nodeDefStoreMock.nodeDefsByName[expectedNode],
{ pos: [100, 200] }
)
expect(node.widgets?.[0].value).toBe('annotated-path')
expect(widgetCallback).toHaveBeenCalledWith('annotated-path')
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
}
)
it('skips adding node when no loader definition', async () => {
delete nodeDefStoreMock.nodeDefsByName.LoadImage
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('skips adding node when preview output lacks media flags', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
filename: 'foo',
subfolder: 'bar',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('skips annotating when litegraph node creation fails', async () => {
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('downloads preview asset when requested', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: { url: 'https://asset', isImage: true }
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
entry?.onClick?.()
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
})
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
entry?.onClick?.()
expect(downloadFileMock).not.toHaveBeenCalled()
})
it('exports workflow with default filename when prompting disabled', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '7',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledTimes(1)
const [filename, blob] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('Job 7.json')
await expect(blob.text()).resolves.toBe(
JSON.stringify({ foo: 'bar' }, null, 2)
)
})
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
title: expect.stringContaining('workflowService.exportWorkflow'),
message: expect.stringContaining('workflowService.enterFilename'),
defaultValue: 'Job job-1.json'
})
const [filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('custom-name.json')
})
it('keeps existing json extension when exporting workflow', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '42',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
const [filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('existing.json')
})
it('abandons export when prompt cancelled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
})
it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview }
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
})
it('ignores failed job delete when taskRef missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).not.toHaveBeenCalled()
})
it('provides completed menu structure with delete option', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'inspect-asset',
'add-to-current',
'download',
'd1',
'open-workflow',
'export-workflow',
'd2',
'copy-id',
'd3',
'delete'
])
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
await inspectEntry?.onClick?.()
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
})
it('omits inspect handler when callback missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
expect(inspectEntry?.onClick).toBeUndefined()
})
it('omits delete asset entry when no preview exists', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
await nextTick()
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
false
)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'copy-error',
'report-error',
'd2',
'delete'
])
})
it('returns active job entries with cancel option', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'running' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
})
it('provides pending job entries and triggers cancel action', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
await cancelEntry?.onClick?.()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('returns empty menu when no job selected', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(null)
await nextTick()
expect(jobMenuEntries.value).toEqual([])
})
})

View File

@@ -1,914 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),
uploadFile: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
describe('useLoad3d', () => {
let mockLoad3d: any
let mockNode: any
let mockToastStore: any
beforeEach(() => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
mockNode = {
properties: {
'Scene Config': {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
},
'Model Config': {
upDirection: 'original',
materialMode: 'original'
},
'Camera Config': {
cameraType: 'perspective',
fov: 75,
state: null
},
'Light Config': {
intensity: 5
},
'Resource Folder': ''
},
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
graph: {
setDirtyCanvas: vi.fn()
},
flags: {},
onMouseEnter: null,
onMouseLeave: null,
onResize: null,
onDrawBackground: null
}
mockLoad3d = {
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
refreshViewport: vi.fn(),
updateStatusMouseOnNode: vi.fn(),
updateStatusMouseOnScene: vi.fn(),
handleResize: vi.fn(),
toggleAnimation: vi.fn(),
setAnimationSpeed: vi.fn(),
updateSelectedAnimation: vi.fn(),
startRecording: vi.fn().mockResolvedValue(undefined),
stopRecording: vi.fn(),
getRecordingDuration: vi.fn().mockReturnValue(10),
exportRecording: vi.fn(),
clearRecording: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
isSplatModel: vi.fn().mockReturnValue(false),
isPlyModel: vi.fn().mockReturnValue(false),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
renderer: {
domElement: {
hidden: false
}
}
}
vi.mocked(Load3d).mockImplementation(function () {
Object.assign(this, mockLoad3d)
})
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value).toEqual({
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
expect(composable.modelConfig.value).toEqual({
upDirection: 'original',
materialMode: 'original'
})
expect(composable.cameraConfig.value).toEqual({
cameraType: 'perspective',
fov: 75
})
expect(composable.lightConfig.value).toEqual({
intensity: 5
})
expect(composable.isRecording.value).toBe(false)
expect(composable.hasRecording.value).toBe(false)
expect(composable.loading.value).toBe(false)
})
it('should initialize Load3d with container and node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3d).toHaveBeenCalledWith(
containerRef,
expect.objectContaining({
width: 512,
height: 512,
getDimensions: expect.any(Function),
onContextMenu: expect.any(Function)
})
)
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
})
it('should restore configurations from node', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
})
it('should set up node event handlers', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockNode.onMouseEnter).toBeDefined()
expect(mockNode.onMouseLeave).toBeDefined()
expect(mockNode.onResize).toBeDefined()
expect(mockNode.onDrawBackground).toBeDefined()
// Test the handlers
mockNode.onMouseEnter()
expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
mockNode.onMouseLeave()
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
mockNode.onResize()
expect(mockLoad3d.handleResize).toHaveBeenCalled()
})
it('should handle collapsed state', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockNode.flags.collapsed = true
mockNode.onDrawBackground()
expect(mockLoad3d.renderer.domElement.hidden).toBe(true)
})
it('should load model if model_file widget exists', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/test.glb'
)
})
it('should restore camera state after loading model', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
mockNode.properties['Camera Config'].state = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
}
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'subfolder',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 }
})
})
it('should set preview mode when no width/height widgets', async () => {
mockNode.widgets = []
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(function () {
throw new Error('Load3d creation failed')
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3d'
)
})
it('should handle missing container or node', async () => {
const composable = useLoad3d(mockNode)
await composable.initializeLoad3d(null as any)
expect(Load3d).not.toHaveBeenCalled()
})
it('should accept ref as parameter', () => {
const nodeRef = ref(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
})
})
describe('waitForLoad3d', () => {
it('should execute callback immediately if Load3d exists', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).toHaveBeenCalledWith(mockLoad3d)
})
it('should queue callback if Load3d does not exist', () => {
const composable = useLoad3d(mockNode)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should execute queued callbacks after initialization', async () => {
const composable = useLoad3d(mockNode)
const callback1 = vi.fn()
const callback2 = vi.fn()
composable.waitForLoad3d(callback1)
composable.waitForLoad3d(callback2)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(callback1).toHaveBeenCalledWith(mockLoad3d)
expect(callback2).toHaveBeenCalledWith(mockLoad3d)
})
})
describe('configuration watchers', () => {
it('should update scene config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
mockLoad3d.toggleGrid.mockClear()
mockLoad3d.setBackgroundColor.mockClear()
mockLoad3d.setBackgroundImage.mockClear()
composable.sceneConfig.value = {
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg',
backgroundRenderMode: 'panorama'
}
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ffffff')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('test.jpg')
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith(
'panorama'
)
expect(mockNode.properties['Scene Config']).toEqual({
showGrid: false,
backgroundColor: '#ffffff',
backgroundImage: 'test.jpg',
backgroundRenderMode: 'panorama'
})
})
it('should update model config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
mockLoad3d.setUpDirection.mockClear()
mockLoad3d.setMaterialMode.mockClear()
composable.modelConfig.value.upDirection = '+y'
composable.modelConfig.value.materialMode = 'wireframe'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(mockNode.properties['Model Config']).toEqual({
upDirection: '+y',
materialMode: 'wireframe'
})
})
it('should update camera config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
mockLoad3d.toggleCamera.mockClear()
mockLoad3d.setFOV.mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
})
})
it('should update light config when values change', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
mockLoad3d.setLightIntensity.mockClear()
composable.lightConfig.value.intensity = 10
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
expect(mockNode.properties['Light Config']).toEqual({
intensity: 10
})
})
})
describe('animation controls', () => {
it('should toggle animation playback', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.playing.value = true
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
composable.playing.value = false
await nextTick()
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(false)
})
it('should update animation speed', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedSpeed.value = 2
await nextTick()
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
})
it('should update selected animation', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.selectedAnimation.value = 1
await nextTick()
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(1)
})
})
describe('recording controls', () => {
it('should start recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(true)
})
it('should stop recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleStopRecording()
expect(mockLoad3d.stopRecording).toHaveBeenCalled()
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
it('should export recording with timestamp', async () => {
const dateSpy = vi
.spyOn(Date.prototype, 'toISOString')
.mockReturnValue('2024-01-01T12:00:00.000Z')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleExportRecording()
expect(mockLoad3d.exportRecording).toHaveBeenCalledWith(
'2024-01-01T12-00-00-000Z-scene-recording.mp4'
)
dateSpy.mockRestore()
})
it('should clear recording', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.hasRecording.value = true
composable.recordingDuration.value = 10
composable.handleClearRecording()
expect(mockLoad3d.clearRecording).toHaveBeenCalled()
expect(composable.hasRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(0)
})
})
describe('background image handling', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(composable.sceneConfig.value.backgroundImage).toBe(
'uploaded-image.jpg'
)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith(
'uploaded-image.jpg'
)
})
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should clear background image when file is null', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
await composable.handleBackgroundImageUpdate(null)
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
})
describe('model export', () => {
it('should export model successfully', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
})
it('should show alert when no Load3d instance', async () => {
const composable = useLoad3d(mockNode)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.no3dSceneToExport'
)
})
it('should handle export errors', async () => {
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await composable.handleExportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToExportModel'
)
})
})
describe('mouse interactions', () => {
it('should handle mouse enter on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseEnter()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(true)
})
it('should handle mouse leave on scene', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.handleMouseLeave()
expect(mockLoad3d.updateStatusMouseOnScene).toHaveBeenCalledWith(false)
})
})
describe('event handling', () => {
it('should add event listeners on initialization', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const expectedEvents = [
'materialModeChange',
'backgroundColorChange',
'backgroundRenderModeChange',
'lightIntensityChange',
'fovChange',
'cameraTypeChange',
'showGridChange',
'upDirectionChange',
'backgroundImageChange',
'backgroundImageLoadingStart',
'backgroundImageLoadingEnd',
'modelLoadingStart',
'modelLoadingEnd',
'exportLoadingStart',
'exportLoadingEnd',
'recordingStatusChange',
'animationListChange'
]
expectedEvents.forEach((event) => {
expect(mockLoad3d.addEventListener).toHaveBeenCalledWith(
event,
expect.any(Function)
)
})
})
it('should handle materialModeChange event', async () => {
let materialModeHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'materialModeChange') {
materialModeHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
materialModeHandler('wireframe')
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
})
it('should handle loading events', async () => {
let modelLoadingStartHandler: any
let modelLoadingEndHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'modelLoadingStart') {
modelLoadingStartHandler = handler
} else if (event === 'modelLoadingEnd') {
modelLoadingEndHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
modelLoadingStartHandler()
expect(composable.loading.value).toBe(true)
expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
modelLoadingEndHandler()
expect(composable.loading.value).toBe(false)
expect(composable.loadingMessage.value).toBe('')
})
it('should handle recordingStatusChange event', async () => {
let recordingStatusHandler: any
mockLoad3d.addEventListener.mockImplementation(
(event: string, handler: any) => {
if (event === 'recordingStatusChange') {
recordingStatusHandler = handler
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
recordingStatusHandler(false)
expect(composable.isRecording.value).toBe(false)
expect(composable.recordingDuration.value).toBe(10)
expect(composable.hasRecording.value).toBe(true)
})
})
describe('cleanup', () => {
it('should remove event listeners and clean up resources', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.cleanup()
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
expect(mockLoad3d.remove).toHaveBeenCalled()
expect(nodeToLoad3dMap.has(mockNode)).toBe(false)
})
it('should handle cleanup when not initialized', () => {
const composable = useLoad3d(mockNode)
expect(() => composable.cleanup()).not.toThrow()
})
})
describe('getModelUrl', () => {
it('should handle http URLs directly', async () => {
mockNode.widgets.push({
name: 'model_file',
value: 'http://example.com/model.glb'
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://example.com/model.glb'
)
})
it('should construct URL for local files', async () => {
mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' })
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
'models',
'test.glb'
])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/models/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/models/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'models',
'test.glb',
'input'
)
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
'http://localhost/api/view/models/test.glb'
)
})
it('should use output type for preview mode', async () => {
mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
'/api/view/test.glb'
)
vi.mocked(api.apiURL).mockReturnValue(
'http://localhost/api/view/test.glb'
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
'',
'test.glb',
'output'
)
})
})
describe('edge cases', () => {
it('should handle null node ref', () => {
const nodeRef = ref(null)
const composable = useLoad3d(nodeRef)
const callback = vi.fn()
composable.waitForLoad3d(callback)
expect(callback).not.toHaveBeenCalled()
})
it('should handle missing configurations', async () => {
delete mockNode.properties['Scene Config']
delete mockNode.properties['Model Config']
delete mockNode.properties['Camera Config']
delete mockNode.properties['Light Config']
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
// Should not throw and should use defaults
expect(Load3d).toHaveBeenCalled()
})
it('should handle background image with existing config', async () => {
mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg'
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
})
})
})

View File

@@ -1,267 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useToastStore } from '@/platform/updates/common/toastStore'
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
function createMockDragEvent(
type: string,
options: { hasFiles?: boolean; files?: File[] } = {}
): DragEvent {
const files = options.files || []
const types = options.hasFiles ? ['Files'] : []
const dataTransfer = {
types,
files,
dropEffect: 'none' as DataTransfer['dropEffect']
}
const event = {
type,
dataTransfer
} as unknown as DragEvent
return event
}
describe('useLoad3dDrag', () => {
let mockToastStore: any
let mockOnModelDrop: (file: File) => void | Promise<void>
beforeEach(() => {
vi.clearAllMocks()
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
mockOnModelDrop = vi.fn()
})
it('should initialize with default state', () => {
const { isDragging, dragMessage } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
expect(isDragging.value).toBe(false)
expect(dragMessage.value).toBe('')
})
describe('handleDragOver', () => {
it('should set isDragging to true when files are being dragged', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(true)
expect(event.dataTransfer!.dropEffect).toBe('copy')
})
it('should not set isDragging when disabled', () => {
const disabled = ref(true)
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
it('should not set isDragging when no files are being dragged', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('dragover', { hasFiles: false })
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
})
describe('handleDragLeave', () => {
it('should reset isDragging to false', () => {
const { isDragging, handleDragLeave, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
// First set isDragging to true
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(dragOverEvent)
expect(isDragging.value).toBe(true)
// Then test dragleave
handleDragLeave()
expect(isDragging.value).toBe(false)
})
})
describe('handleDrop', () => {
it('should call onModelDrop with valid model file', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
})
it('should show error toast for unsupported file types', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const invalidFile = new File([], 'image.png', { type: 'image/png' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [invalidFile]
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'load3d.unsupportedFileType'
)
})
it('should not call onModelDrop when disabled', async () => {
const disabled = ref(true)
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
})
it('should reset isDragging after drop', async () => {
const { isDragging, handleDrop, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
// Set isDragging to true
const dragOverEvent = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(dragOverEvent)
expect(isDragging.value).toBe(true)
// Drop the file
const modelFile = new File([], 'model.glb', { type: 'model/gltf-binary' })
const dropEvent = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(dropEvent)
expect(isDragging.value).toBe(false)
})
it('should support all valid 3D model extensions', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
for (const ext of extensions) {
vi.mocked(mockOnModelDrop).mockClear()
const modelFile = new File([], `model${ext}`)
const event = createMockDragEvent('drop', {
hasFiles: true,
files: [modelFile]
})
await handleDrop(event)
expect(mockOnModelDrop).toHaveBeenCalledWith(modelFile)
}
})
it('should handle empty file list', async () => {
const { handleDrop } = useLoad3dDrag({
onModelDrop: mockOnModelDrop
})
const event = createMockDragEvent('drop', {
hasFiles: true,
files: []
})
await handleDrop(event)
expect(mockOnModelDrop).not.toHaveBeenCalled()
expect(mockToastStore.addAlert).not.toHaveBeenCalled()
})
})
describe('disabled option', () => {
it('should work with reactive disabled ref', () => {
const disabled = ref(false)
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled
})
const event = createMockDragEvent('dragover', { hasFiles: true })
// Should work when disabled is false
handleDragOver(event)
expect(isDragging.value).toBe(true)
// Reset
isDragging.value = false
// Should not work when disabled is true
disabled.value = true
handleDragOver(event)
expect(isDragging.value).toBe(false)
})
it('should work with plain boolean', () => {
const { isDragging, handleDragOver } = useLoad3dDrag({
onModelDrop: mockOnModelDrop,
disabled: false
})
const event = createMockDragEvent('dragover', { hasFiles: true })
handleDragOver(event)
expect(isDragging.value).toBe(true)
})
})
})

View File

@@ -1,614 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useLoad3dService } from '@/services/load3dService'
vi.mock('@/services/load3dService', () => ({
useLoad3dService: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
uploadFile: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
describe('useLoad3dViewer', () => {
let mockLoad3d: any
let mockSourceLoad3d: any
let mockLoad3dService: any
let mockToastStore: any
let mockNode: any
beforeEach(() => {
vi.clearAllMocks()
mockNode = {
properties: {
'Scene Config': {
backgroundColor: '#282828',
showGrid: true,
backgroundImage: '',
backgroundRenderMode: 'tiled'
},
'Camera Config': {
cameraType: 'perspective',
fov: 75
},
'Light Config': {
intensity: 1
},
'Model Config': {
upDirection: 'original',
materialMode: 'original'
},
'Resource Folder': ''
},
graph: {
setDirtyCanvas: vi.fn()
},
widgets: []
} as any
mockLoad3d = {
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
exportModel: vi.fn().mockResolvedValue(undefined),
handleResize: vi.fn(),
updateStatusMouseOnViewer: vi.fn(),
getCameraState: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}),
forceRender: vi.fn(),
remove: vi.fn(),
setTargetSize: vi.fn()
}
mockSourceLoad3d = {
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
getCameraState: vi.fn().mockReturnValue({
position: { x: 1, y: 1, z: 1 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
}),
sceneManager: {
currentBackgroundColor: '#282828',
gridHelper: { visible: true },
getCurrentBackgroundInfo: vi.fn().mockReturnValue({
type: 'color',
value: '#282828'
})
},
lightingManager: {
lights: [null, { intensity: 1 }]
},
cameraManager: {
perspectiveCamera: { fov: 75 }
},
modelManager: {
currentUpDirection: 'original',
materialMode: 'original'
},
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
forceRender: vi.fn()
}
vi.mocked(Load3d).mockImplementation(function () {
Object.assign(this, mockLoad3d)
})
mockLoad3dService = {
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
handleViewportRefresh: vi.fn(),
getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d)
}
vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService)
mockToastStore = {
addAlert: vi.fn()
}
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initialization', () => {
it('should initialize with default values', () => {
const viewer = useLoad3dViewer(mockNode)
expect(viewer.backgroundColor.value).toBe('')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.fov.value).toBe(75)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
})
it('should initialize viewer with source Load3d state', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(Load3d).toHaveBeenCalledWith(containerRef, {
width: undefined,
height: undefined,
getDimensions: undefined,
isViewerMode: false
})
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockSourceLoad3d,
mockLoad3d
)
expect(viewer.cameraType.value).toBe('perspective')
expect(viewer.backgroundColor.value).toBe('#282828')
expect(viewer.showGrid.value).toBe(true)
expect(viewer.lightIntensity.value).toBe(1)
expect(viewer.fov.value).toBe(75)
expect(viewer.upDirection.value).toBe('original')
expect(viewer.materialMode.value).toBe('original')
})
it('should handle background image during initialization', async () => {
mockSourceLoad3d.sceneManager.getCurrentBackgroundInfo.mockReturnValue({
type: 'image',
value: ''
})
mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg'
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.backgroundImage.value).toBe('test-image.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(Load3d).mockImplementationOnce(function () {
throw new Error('Load3d creation failed')
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToInitializeLoad3dViewer'
)
})
})
describe('state watchers', () => {
it('should update background color when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000')
})
it('should update grid visibility when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.showGrid.value = false
await nextTick()
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
})
it('should update camera type when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.cameraType.value = 'orthographic'
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
})
it('should update FOV when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.fov.value = 90
await nextTick()
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
})
it('should update light intensity when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.lightIntensity.value = 2
await nextTick()
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2)
})
it('should update background image when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await nextTick()
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should update up direction when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.upDirection.value = '+y'
await nextTick()
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
})
it('should update material mode when state changes', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.materialMode.value = 'wireframe'
await nextTick()
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
})
it('should handle watcher errors gracefully', async () => {
mockLoad3d.setBackgroundColor.mockImplementationOnce(function () {
throw new Error('Color update failed')
})
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
await nextTick()
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUpdateBackgroundColor'
)
})
})
describe('exportModel', () => {
it('should export model successfully', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
await viewer.exportModel('glb')
expect(mockLoad3d.exportModel).toHaveBeenCalledWith('glb')
})
it('should handle export errors', async () => {
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
await viewer.exportModel('glb')
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToExportModel'
)
})
it('should not export when load3d is not initialized', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.exportModel('glb')
expect(mockLoad3d.exportModel).not.toHaveBeenCalled()
})
})
describe('UI interaction methods', () => {
it('should handle resize', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleResize()
expect(mockLoad3d.handleResize).toHaveBeenCalled()
})
it('should handle mouse enter', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleMouseEnter()
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
})
it('should handle mouse leave', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.handleMouseLeave()
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(false)
})
})
describe('restoreInitialState', () => {
it('should restore all properties to initial values', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
mockNode.properties['Scene Config'].backgroundColor = '#ff0000'
mockNode.properties['Scene Config'].showGrid = false
viewer.restoreInitialState()
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
'#282828'
)
expect(mockNode.properties['Scene Config'].showGrid).toBe(true)
expect(mockNode.properties['Camera Config'].cameraType).toBe(
'perspective'
)
expect(mockNode.properties['Camera Config'].fov).toBe(75)
expect(mockNode.properties['Light Config'].intensity).toBe(1)
})
})
describe('applyChanges', () => {
it('should apply all changes to source and node', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundColor.value = '#ff0000'
viewer.showGrid.value = false
const result = await viewer.applyChanges()
expect(result).toBe(true)
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
'#ff0000'
)
expect(mockNode.properties['Scene Config'].showGrid).toBe(false)
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
mockLoad3d,
mockSourceLoad3d
)
expect(mockSourceLoad3d.forceRender).toHaveBeenCalled()
expect(mockNode.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('should handle background image during apply', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'new-bg.jpg'
await viewer.applyChanges()
expect(mockSourceLoad3d.setBackgroundImage).toHaveBeenCalledWith(
'new-bg.jpg'
)
})
it('should return false when no load3d instances', async () => {
const viewer = useLoad3dViewer(mockNode)
const result = await viewer.applyChanges()
expect(result).toBe(false)
})
})
describe('refreshViewport', () => {
it('should refresh viewport', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.refreshViewport()
expect(mockLoad3dService.handleViewportRefresh).toHaveBeenCalledWith(
mockLoad3d
)
})
})
describe('handleBackgroundImageUpdate', () => {
it('should upload and set background image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
expect(viewer.backgroundImage.value).toBe('uploaded-image.jpg')
expect(viewer.hasBackgroundImage.value).toBe(true)
})
it('should use resource folder for upload', async () => {
mockNode.properties['Resource Folder'] = 'subfolder'
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
})
it('should clear background image when file is null', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.backgroundImage.value = 'existing.jpg'
viewer.hasBackgroundImage.value = true
await viewer.handleBackgroundImageUpdate(null)
expect(viewer.backgroundImage.value).toBe('')
expect(viewer.hasBackgroundImage.value).toBe(false)
})
it('should handle upload errors', async () => {
vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce(
new Error('Upload failed')
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'toastMessages.failedToUploadBackgroundImage'
)
})
})
describe('cleanup', () => {
it('should clean up resources', async () => {
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
viewer.cleanup()
expect(mockLoad3d.remove).toHaveBeenCalled()
})
it('should handle cleanup when not initialized', () => {
const viewer = useLoad3dViewer(mockNode)
expect(() => viewer.cleanup()).not.toThrow()
})
})
describe('edge cases', () => {
it('should handle missing container ref', async () => {
const viewer = useLoad3dViewer(mockNode)
await viewer.initializeViewer(null as any, mockSourceLoad3d)
expect(Load3d).not.toHaveBeenCalled()
})
it('should handle orthographic camera', async () => {
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
mockSourceLoad3d.cameraManager = {
perspectiveCamera: { fov: 75 }
}
delete mockNode.properties['Camera Config'].cameraType
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.cameraType.value).toBe('orthographic')
})
it('should handle missing lights', async () => {
mockSourceLoad3d.lightingManager.lights = []
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
expect(viewer.lightIntensity.value).toBe(1) // Default value
})
})
})

View File

@@ -1,222 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
// Mock the app API
vi.mock('@/scripts/app', () => ({
app: {
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clientId: 'test-client-id'
}
}
}))
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
describe('useManagerQueue', () => {
let taskHistory: any
let taskQueue: any
let installedPacks: any
const createManagerQueue = () => {
taskHistory = ref<ManagerTaskHistory>({})
taskQueue = ref<ManagerTaskQueue>({
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
})
installedPacks = ref({})
return useManagerQueue(taskHistory, taskQueue, installedPacks)
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with empty state', () => {
const queue = createManagerQueue()
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
expect(queue.isProcessing.value).toBe(false)
expect(queue.historyCount.value).toBe(0)
})
})
describe('task state management', () => {
it('should track task queue length', () => {
const queue = createManagerQueue()
// Add tasks to queue
taskQueue.value.running_queue = [
{
ui_id: 'task1',
client_id: 'test-client-id',
task_name: 'Installing pack1'
}
]
taskQueue.value.pending_queue = [
{
ui_id: 'task2',
client_id: 'test-client-id',
task_name: 'Installing pack2'
}
]
expect(queue.currentQueueLength.value).toBe(2)
expect(queue.allTasksDone.value).toBe(false)
})
it('should handle empty queues', () => {
const queue = createManagerQueue()
taskQueue.value.running_queue = []
taskQueue.value.pending_queue = []
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('task history management', () => {
it('should track task history count', () => {
const queue = createManagerQueue()
taskHistory.value = {
task1: {
ui_id: 'task1',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
},
task2: {
ui_id: 'task2',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
}
}
expect(queue.historyCount.value).toBe(2)
})
it('should filter tasks by client ID', () => {
const queue = createManagerQueue()
const mockState = {
history: {
task1: {
ui_id: 'task1',
client_id: 'test-client-id', // This client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
},
task2: {
ui_id: 'task2',
client_id: 'other-client-id', // Different client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
}
},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
queue.updateTaskState(mockState)
// Should only include task from this client
expect(taskHistory.value).toHaveProperty('task1')
expect(taskHistory.value).not.toHaveProperty('task2')
})
it('normalizes pack IDs when updating installed packs', () => {
const queue = createManagerQueue()
const mockState = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {
'ComfyUI-GGUF@1_1_4': {
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
},
'test-pack': {
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
}
}
}
queue.updateTaskState(mockState)
// Packs should be accessible by normalized keys
expect(installedPacks.value['ComfyUI-GGUF']).toEqual({
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
})
expect(installedPacks.value['test-pack']).toEqual({
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
})
// Version suffixed keys should not exist after normalization
// The pack should be accessible by its base name only (without @version)
expect(installedPacks.value['ComfyUI-GGUF@1_1_4']).toBeUndefined()
})
it('handles empty installed_packs gracefully', () => {
const queue = createManagerQueue()
const mockState: any = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: undefined
}
// Just call the function - if it throws, the test will fail automatically
queue.updateTaskState(mockState)
// installedPacks should remain unchanged
expect(installedPacks.value).toEqual({})
})
})
})

View File

@@ -1,356 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import {
ManagerUIState,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn()
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
}))
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showManagerPopup: vi.fn(),
showLegacyManagerPopup: vi.fn(),
showSettingsDialog: vi.fn()
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn()
}))
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
describe('useManagerState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('managerUIState property', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: [{ name: 'Comfy.CustomNodesManager' }]
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when server feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: undefined },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server does not support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should handle null systemStats gracefully', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref(null),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
// When systemStats is null, we can't check for --enable-manager flag, so manager is disabled
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('helper properties', () => {
it('isManagerEnabled should return true when state is not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(true)
})
it('isManagerEnabled should return false when state is DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag means disabled
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(false)
})
it('isNewManagerUI should return true when state is NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isNewManagerUI.value).toBe(true)
})
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isLegacyManagerUI.value).toBe(true)
})
it('shouldShowInstallButton should return true only for NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowInstallButton.value).toBe(true)
})
it('shouldShowManagerButtons should return true when not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowManagerButtons.value).toBe(true)
})
})
})

View File

@@ -1,927 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, shallowRef } from 'vue'
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
const triggerRAF = async () => {
// Trigger all RAF callbacks
Object.values(rafCallbacks).forEach((cb) => cb?.())
await flushPromises()
}
const mockPause = vi.fn()
const mockResume = vi.fn()
const rafCallbacks: Record<string, () => void> = {}
let rafCallbackId = 0
vi.mock('@vueuse/core', () => {
return {
useRafFn: vi.fn((callback, options) => {
const id = rafCallbackId++
rafCallbacks[id] = callback
if (options?.immediate !== false) {
void Promise.resolve().then(() => callback())
}
const resumeFn = vi.fn(() => {
mockResume()
// Execute the RAF callback immediately when resumed
if (rafCallbacks[id]) {
rafCallbacks[id]()
}
})
return {
pause: mockPause,
resume: resumeFn
}
}),
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('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => defaultCanvasStore)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
apiURL: vi.fn().mockReturnValue('http://localhost:8188')
}
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: mockGraph
}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeSubgraph: null
}))
}))
const { useMinimap } =
await import('@/renderer/extensions/minimap/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
async function createAndInitializeMinimap() {
const minimap = useMinimap({
containerRefMaybe: shallowRef(mockContainerElement),
canvasRefMaybe: shallowRef(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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
await minimap.init()
expect(minimap.visible.value).toBe(false)
expect(mockResume).not.toHaveBeenCalled()
})
})
describe('destroy', () => {
it('should cleanup all resources', async () => {
const minimap = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext')
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by triggering a graph change
mockGraph._nodes.push({
id: 'new-node',
pos: [150, 150],
size: [100, 50]
})
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
expect(getContextSpy).toHaveBeenCalled()
expect(getContextSpy).toHaveBeenCalledWith('2d')
})
it('should render at least once after initialization', async () => {
const minimap = await createAndInitializeMinimap()
await minimap.init()
// Force initial render
minimap.renderMinimap()
// Force a render by modifying a node position
mockGraph._nodes[0].pos = [50, 50]
// Trigger RAF to process changes
await triggerRAF()
await nextTick()
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)
console.log(
'clearRect calls:',
mockContext2D.clearRect.mock.calls.length
)
console.log('fillRect calls:', mockContext2D.fillRect.mock.calls.length)
console.log(
'getContext calls:',
mockCanvasElement.getContext.mock.calls.length
)
}
expect(renderingOccurred).toBe(true)
})
it('should not render when context is null', async () => {
mockCanvasElement.getContext = vi.fn().mockReturnValue(null)
const minimap = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
await minimap.init()
// The renderer has a fast path for empty graphs, force it to execute
minimap.renderMinimap()
await new Promise((resolve) => setTimeout(resolve, 100))
expect(minimap.initialized.value).toBe(true)
// With the new reactive system, the minimap may still render some elements
// The key test is that it doesn't crash and properly initializes
expect(mockContext2D.clearRect).toHaveBeenCalled()
mockGraph._nodes = originalNodes
})
})
describe('pointer interactions', () => {
it('should handle pointer down and start dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle pointer move while dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'mouse'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should handle pointer up to stop dragging (mouse)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'mouse'
})
minimap.handlePointerDown(pointerDownEvent)
minimap.handlePointerUp()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'mouse'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle pointer down and start dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerEvent)
expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled()
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('should handle pointer move while dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should handle pointer move while dragging (pen)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'pen'
})
minimap.handlePointerDown(pointerDownEvent)
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'pen'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
expect(mockCanvas.ds.offset).toBeDefined()
})
it('should not move when not dragging with pointer', async () => {
const minimap = await createAndInitializeMinimap()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
it('should handle pointer up to stop dragging (touch)', async () => {
const minimap = await createAndInitializeMinimap()
const pointerDownEvent = new PointerEvent('pointerdown', {
clientX: 150,
clientY: 150,
pointerType: 'touch'
})
minimap.handlePointerDown(pointerDownEvent)
minimap.handlePointerUp()
mockCanvas.setDirty.mockClear()
const pointerMoveEvent = new PointerEvent('pointermove', {
clientX: 200,
clientY: 200,
pointerType: 'touch'
})
minimap.handlePointerMove(pointerMoveEvent)
expect(mockCanvas.setDirty).not.toHaveBeenCalled()
})
})
describe('wheel interactions', () => {
it('should handle wheel zoom in', async () => {
const minimap = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 for dark theme', () => {
const minimap = useMinimap()
const styles = minimap.containerStyles.value
expect(styles.width).toBe('253px')
expect(styles.height).toBe('200px')
expect(styles.border).toBe('1px solid var(--interface-stroke)')
expect(styles.borderRadius).toBe('8px')
})
})
describe('edge cases', () => {
it('should handle missing node outputs', async () => {
mockGraph._nodes[0].outputs = null
const minimap = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
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 = await createAndInitializeMinimap()
await minimap.init()
expect(minimap.initialized.value).toBe(true)
})
it('should handle nodes without color', async () => {
mockGraph._nodes[0].color = undefined
const minimap = await createAndInitializeMinimap()
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 = document.createElement('div')
minimap.setMinimapRef(ref)
expect(() => minimap.setMinimapRef(ref)).not.toThrow()
})
})
})

View File

@@ -1,594 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { LGraphNode, LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('@vueuse/core', async () => {
const actual =
await vi.importActual<typeof import('@vueuse/core')>('@vueuse/core')
return {
...actual,
createSharedComposable: <Fn extends (...args: any[]) => any>(fn: Fn) => fn
}
})
// Mock the dependencies
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
() => ({
useWorkflowPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: null
}))
}))
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
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 = [
{
id: 'pack-1',
name: 'Test Pack 1',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-2',
name: 'Test Pack 2',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-3',
name: 'Installed Pack',
latest_version: { version: '1.5.0' }
}
]
const mockStartFetchWorkflowPacks = vi.fn()
const mockIsPackInstalled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup: pack-3 is installed, others are not
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
// We only need isPackInstalled method for these tests.
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled
})
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
// Reset node def store mock
// @ts-expect-error - Mocking partial NodeDefStore for testing.
// We only need nodeDefsByName for these tests.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
// Reset app.rootGraph.nodes
mockApp.rootGraph = { nodes: [] }
// Default mock for collectAllNodes - returns empty array
mockCollectAllNodes.mockReturnValue([])
})
describe('core filtering logic', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Should only include packs that are not installed (pack-1, pack-2)
expect(missingNodePacks.value).toHaveLength(2)
expect(missingNodePacks.value[0].id).toBe('pack-1')
expect(missingNodePacks.value[1].id).toBe('pack-2')
expect(
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
).toBeUndefined()
})
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock all packs as installed
mockIsPackInstalled.mockReturnValue(true)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock no packs as installed
mockIsPackInstalled.mockReturnValue(false)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toHaveLength(3)
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
})
it('returns empty array when no workflow packs exist', () => {
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
})
describe('automatic data fetching', () => {
it('fetches workflow packs automatically on initialization via watch with immediate:true', async () => {
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
})
describe('state management', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { isLoading } = useMissingNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useWorkflowPacks', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { error } = useMissingNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when workflow packs change', async () => {
const workflowPacksRef = ref([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Initially empty
expect(missingNodePacks.value).toEqual([])
// Update workflow packs
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
workflowPacksRef.value = mockWorkflowPacks
await nextTick()
// Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
})
it('clears missing nodes when switching to empty workflow', async () => {
const workflowPacksRef = ref(mockWorkflowPacks)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, missingNodePacks } = useMissingNodes()
// Should have missing nodes initially (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
expect(hasMissingNodes.value).toBe(true)
// Switch to empty workflow (simulates creating a new empty workflow)
workflowPacksRef.value = []
await nextTick()
// Should clear missing nodes
expect(missingNodePacks.value).toHaveLength(0)
expect(hasMissingNodes.value).toBe(false)
})
})
describe('missing core nodes detection', () => {
const createMockNode = (type: string, packId?: string, version?: string) =>
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
}) as unknown as LGraphNode
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')
// Mock collectAllNodes to return only the filtered nodes (missing core nodes)
mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode: { name: 'RegisteredNode' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
})
it('groups missing core nodes by version', () => {
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
// Mock collectAllNodes to return these nodes
mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
expect(missingCoreNodes.value['']).toHaveLength(1)
})
it('ignores non-core nodes', () => {
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
// Mock collectAllNodes to return only the filtered nodes (core nodes only)
mockCollectAllNodes.mockReturnValue([coreNode])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
})
it('returns empty object when no core nodes are missing', () => {
// Mock collectAllNodes to return empty array (no missing nodes after filtering)
mockCollectAllNodes.mockReturnValue([])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode1: { name: 'RegisteredNode1' },
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
RegisteredNode2: { name: 'RegisteredNode2' }
}
})
const { missingCoreNodes } = 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() }
mockApp.rootGraph = 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() }
mockApp.rootGraph = 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' }
} as unknown as LGraphNode
const mockMainGraph = {
nodes: [mainMissingNode, mockSubgraphNode]
} as Partial<LGraph> as LGraph
mockApp.rootGraph = mockMainGraph
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
SubgraphRegistered: { name: 'SubgraphRegistered' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(2)
expect(missingCoreNodes.value['1.0.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.1.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.0.0'][0].type).toBe('MainMissing')
expect(missingCoreNodes.value['1.1.0'][0].type).toBe('SubgraphMissing')
})
})
})

View File

@@ -1,160 +0,0 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { formatPercent0 } from '@/utils/numberUtil'
type ProgressValue = number | null
const localeRef: Ref<string> = ref('en-US') as Ref<string>
const executionProgressRef: Ref<ProgressValue> = ref(null)
const executingNodeProgressRef: Ref<ProgressValue> = ref(null)
const createExecutionStoreMock = () => ({
get executionProgress() {
return executionProgressRef.value ?? undefined
},
get executingNodeProgress() {
return executingNodeProgressRef.value ?? undefined
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: localeRef
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => createExecutionStoreMock()
}))
const mountedWrappers: VueWrapper[] = []
const mountUseQueueProgress = () => {
let composable: ReturnType<typeof useQueueProgress>
const wrapper = mount({
template: '<div />',
setup() {
composable = useQueueProgress()
return {}
}
})
mountedWrappers.push(wrapper)
return { wrapper, composable: composable! }
}
const setExecutionProgress = (value?: number | null) => {
executionProgressRef.value = value ?? null
}
const setExecutingNodeProgress = (value?: number | null) => {
executingNodeProgressRef.value = value ?? null
}
describe('useQueueProgress', () => {
beforeEach(() => {
localeRef.value = 'en-US'
setExecutionProgress(null)
setExecutingNodeProgress(null)
})
afterEach(() => {
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount())
})
it.each([
{
description: 'defaults to 0% when execution store values are missing',
execution: undefined,
node: undefined,
expectedTotal: 0,
expectedNode: 0
},
{
description: 'rounds fractional progress to the nearest integer',
execution: 0.324,
node: 0.005,
expectedTotal: 32,
expectedNode: 1
},
{
description: 'clamps values below 0 and above 100%',
execution: 1.5,
node: -0.25,
expectedTotal: 100,
expectedNode: 0
},
{
description: 'caps near-complete totals at 100%',
execution: 0.999,
node: 0.731,
expectedTotal: 100,
expectedNode: 73
}
])('$description', ({ execution, node, expectedTotal, expectedNode }) => {
setExecutionProgress(execution ?? null)
setExecutingNodeProgress(node ?? null)
const { composable } = mountUseQueueProgress()
expect(composable.totalPercent.value).toBe(expectedTotal)
expect(composable.currentNodePercent.value).toBe(expectedNode)
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0(localeRef.value, expectedTotal)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0(localeRef.value, expectedNode)
)
})
it('reformats output when the active locale changes', async () => {
setExecutionProgress(0.32)
setExecutingNodeProgress(0.58)
const { composable } = mountUseQueueProgress()
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0('en-US', composable.totalPercent.value)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0('en-US', composable.currentNodePercent.value)
)
localeRef.value = 'fr-FR'
await nextTick()
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0('fr-FR', composable.totalPercent.value)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0('fr-FR', composable.currentNodePercent.value)
)
})
it('builds progress bar styles that track store updates', async () => {
setExecutionProgress(0.1)
setExecutingNodeProgress(0.25)
const { composable } = mountUseQueueProgress()
expect(composable.totalProgressStyle.value).toEqual({
width: '10%',
background: 'var(--color-interface-panel-job-progress-primary)'
})
expect(composable.currentNodeProgressStyle.value).toEqual({
width: '25%',
background: 'var(--color-interface-panel-job-progress-secondary)'
})
setExecutionProgress(0.755)
setExecutingNodeProgress(0.02)
await nextTick()
expect(composable.totalProgressStyle.value.width).toBe('76%')
expect(composable.currentNodeProgressStyle.value.width).toBe('2%')
})
})

View File

@@ -1,103 +0,0 @@
import { describe, it, expect } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem } from '@/composables/queue/useJobList'
type PreviewLike = { url: string; supportsPreview: boolean }
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
url,
supportsPreview
})
const createTask = (preview?: PreviewLike) => ({
previewOutput: preview
})
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
({
id,
title: `Job ${id}`,
meta: '',
state: 'completed',
showClear: false,
taskRef: preview ? { previewOutput: preview } : undefined
}) as JobListItem
describe('useResultGallery', () => {
it('collects only previewable outputs and preserves their order', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = [
createTask(previewable[0]),
createTask({ url: 'skip-me', supportsPreview: false }),
createTask(previewable[1]),
createTask()
]
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-1', previewable[0]))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
it('does not change state when there are no previewable tasks', () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)
onViewItem(createJobItem('job-missing'))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('activates the index that matches the viewed preview URL', () => {
const previewable = [
createPreview('p-1'),
createPreview('p-2'),
createPreview('p-3')
]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-2', createPreview('p-2')))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(1)
})
it('defaults to the first entry when the clicked job lacks a preview', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-no-preview'))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when no gallery item matches the preview URL', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
})

View File

@@ -1,121 +0,0 @@
import { useEventListener } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
import type { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({
api: {
subscribeLogs: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn().mockReturnValue(vi.fn())
}))
describe('useServerLogs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should initialize with empty logs array', () => {
const { logs } = useServerLogs()
expect(logs.value).toEqual([])
})
it('should not subscribe to logs by default', () => {
useServerLogs()
expect(api.subscribeLogs).not.toHaveBeenCalled()
})
it('should subscribe to logs when immediate is true', () => {
useServerLogs({ immediate: true })
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
it('should start listening when startListening is called', async () => {
const { startListening } = useServerLogs()
await startListening()
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
it('should stop listening when stopListening is called', async () => {
const { startListening, stopListening } = useServerLogs()
await startListening()
await stopListening()
expect(api.subscribeLogs).toHaveBeenCalledWith(false)
})
it('should register event listener when starting', async () => {
const { startListening } = useServerLogs()
await startListening()
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
api,
'logs',
expect.any(Function)
)
})
it('should handle log messages correctly', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
// Get the callback that was registered with useEventListener
const eventCallback = vi.mocked(useEventListener).mock.calls[0][2] as (
event: CustomEvent<LogsWsMessage>
) => void
// Simulate receiving a log event
const mockEvent = new CustomEvent('logs', {
detail: {
type: 'logs',
entries: [{ m: 'Log message 1' }, { m: 'Log message 2' }]
} as unknown as LogsWsMessage
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)
await nextTick()
expect(logs.value).toEqual(['Log message 1', 'Log message 2'])
})
it('should use the message filter if provided', async () => {
const { logs, startListening } = useServerLogs({
messageFilter: (msg) => msg !== 'remove me'
})
await startListening()
const eventCallback = vi.mocked(useEventListener).mock.calls[0][2] as (
event: CustomEvent<LogsWsMessage>
) => void
const mockEvent = new CustomEvent('logs', {
detail: {
type: 'logs',
entries: [
{ m: 'Log message 1 dont remove me' },
{ m: 'remove me' },
{ m: '' }
]
} as unknown as LogsWsMessage
}) as CustomEvent<LogsWsMessage>
eventCallback(mockEvent)
await nextTick()
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
})
})

View File

@@ -1,428 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { st } from '@/i18n'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
// Mock dependencies
vi.mock('@/i18n', () => ({
st: vi.fn((_: string, fallback: string) => fallback)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
}))
describe('useSettingSearch', () => {
let mockSettingStore: any
let mockSettings: any
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// Mock settings data
mockSettings = {
'Category.Setting1': {
id: 'Category.Setting1',
name: 'Setting One',
type: 'text',
defaultValue: 'default',
category: ['Category', 'Basic']
},
'Category.Setting2': {
id: 'Category.Setting2',
name: 'Setting Two',
type: 'boolean',
defaultValue: false,
category: ['Category', 'Advanced']
},
'Category.HiddenSetting': {
id: 'Category.HiddenSetting',
name: 'Hidden Setting',
type: 'hidden',
defaultValue: 'hidden',
category: ['Category', 'Basic']
},
'Category.DeprecatedSetting': {
id: 'Category.DeprecatedSetting',
name: 'Deprecated Setting',
type: 'text',
defaultValue: 'deprecated',
deprecated: true,
category: ['Category', 'Advanced']
},
'Other.Setting3': {
id: 'Other.Setting3',
name: 'Other Setting',
type: 'select',
defaultValue: 'option1',
category: ['Other', 'SubCategory']
}
}
// Mock setting store
mockSettingStore = {
settingsById: mockSettings
}
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
// Mock getSettingInfo function
vi.mocked(getSettingInfo).mockImplementation((setting: any) => {
const parts = setting.category || setting.id.split('.')
return {
category: parts[0] ?? 'Other',
subCategory: parts[1] ?? 'Other'
}
})
// Mock st function to return fallback value
vi.mocked(st).mockImplementation((_: string, fallback: string) => fallback)
})
describe('initialization', () => {
it('initializes with default state', () => {
const search = useSettingSearch()
expect(search.searchQuery.value).toBe('')
expect(search.filteredSettingIds.value).toEqual([])
expect(search.searchInProgress.value).toBe(false)
expect(search.queryIsEmpty.value).toBe(true)
expect(search.inSearch.value).toBe(false)
expect(search.searchResultsCategories.value).toEqual(new Set())
})
})
describe('reactive properties', () => {
it('queryIsEmpty computed property works correctly', () => {
const search = useSettingSearch()
expect(search.queryIsEmpty.value).toBe(true)
search.searchQuery.value = 'test'
expect(search.queryIsEmpty.value).toBe(false)
search.searchQuery.value = ''
expect(search.queryIsEmpty.value).toBe(true)
})
it('inSearch computed property works correctly', () => {
const search = useSettingSearch()
// Empty query, not in search
expect(search.inSearch.value).toBe(false)
// Has query but search in progress
search.searchQuery.value = 'test'
search.searchInProgress.value = true
expect(search.inSearch.value).toBe(false)
// Has query and search complete
search.searchInProgress.value = false
expect(search.inSearch.value).toBe(true)
})
it('searchResultsCategories computed property works correctly', () => {
const search = useSettingSearch()
// No results
expect(search.searchResultsCategories.value).toEqual(new Set())
// Add some filtered results
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
expect(search.searchResultsCategories.value).toEqual(
new Set(['Category', 'Other'])
)
})
it('watches searchQuery and sets searchInProgress to true', async () => {
const search = useSettingSearch()
expect(search.searchInProgress.value).toBe(false)
search.searchQuery.value = 'test'
await nextTick()
expect(search.searchInProgress.value).toBe(true)
})
})
describe('handleSearch', () => {
it('clears results when query is empty', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1']
search.handleSearch('')
expect(search.filteredSettingIds.value).toEqual([])
})
it('filters settings by ID (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('category.setting1')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
expect(search.filteredSettingIds.value).not.toContain('Other.Setting3')
})
it('filters settings by name (case insensitive)', () => {
const search = useSettingSearch()
search.handleSearch('setting one')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
expect(search.filteredSettingIds.value).not.toContain('Category.Setting2')
})
it('filters settings by category', () => {
const search = useSettingSearch()
search.handleSearch('other')
expect(search.filteredSettingIds.value).toContain('Other.Setting3')
expect(search.filteredSettingIds.value).not.toContain('Category.Setting1')
})
it('excludes hidden settings from results', () => {
const search = useSettingSearch()
search.handleSearch('hidden')
expect(search.filteredSettingIds.value).not.toContain(
'Category.HiddenSetting'
)
})
it('excludes deprecated settings from results', () => {
const search = useSettingSearch()
search.handleSearch('deprecated')
expect(search.filteredSettingIds.value).not.toContain(
'Category.DeprecatedSetting'
)
})
it('sets searchInProgress to false after search', () => {
const search = useSettingSearch()
search.searchInProgress.value = true
search.handleSearch('test')
expect(search.searchInProgress.value).toBe(false)
})
it('includes visible settings in results', () => {
const search = useSettingSearch()
search.handleSearch('setting')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining([
'Category.Setting1',
'Category.Setting2',
'Other.Setting3'
])
)
expect(search.filteredSettingIds.value).not.toContain(
'Category.HiddenSetting'
)
expect(search.filteredSettingIds.value).not.toContain(
'Category.DeprecatedSetting'
)
})
it('includes all visible settings in comprehensive search', () => {
const search = useSettingSearch()
// Search for a partial match that should include multiple settings
search.handleSearch('setting')
// Should find all visible settings (not hidden/deprecated)
expect(search.filteredSettingIds.value.length).toBeGreaterThan(0)
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining([
'Category.Setting1',
'Category.Setting2',
'Other.Setting3'
])
)
})
it('uses translated categories for search', () => {
const search = useSettingSearch()
// Mock st to return translated category names
vi.mocked(st).mockImplementation((key: string, fallback: string) => {
if (key === 'settingsCategories.Category') {
return 'Translated Category'
}
return fallback
})
search.handleSearch('translated category')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining(['Category.Setting1', 'Category.Setting2'])
)
})
})
describe('getSearchResults', () => {
it('groups results by subcategory', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = [
'Category.Setting1',
'Category.Setting2'
]
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'Advanced',
settings: [mockSettings['Category.Setting2']]
}
])
})
it('filters results by active category', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
const activeCategory = { label: 'Category' } as any
const results = search.getSearchResults(activeCategory)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
}
])
})
it('returns all results when no active category', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = ['Category.Setting1', 'Other.Setting3']
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [mockSettings['Category.Setting1']]
},
{
label: 'SubCategory',
settings: [mockSettings['Other.Setting3']]
}
])
})
it('returns empty array when no filtered results', () => {
const search = useSettingSearch()
search.filteredSettingIds.value = []
const results = search.getSearchResults(null)
expect(results).toEqual([])
})
it('handles multiple settings in same subcategory', () => {
const search = useSettingSearch()
// Add another setting to Basic subcategory
mockSettings['Category.Setting4'] = {
id: 'Category.Setting4',
name: 'Setting Four',
type: 'text',
defaultValue: 'default',
category: ['Category', 'Basic']
}
search.filteredSettingIds.value = [
'Category.Setting1',
'Category.Setting4'
]
const results = search.getSearchResults(null)
expect(results).toEqual([
{
label: 'Basic',
settings: [
mockSettings['Category.Setting1'],
mockSettings['Category.Setting4']
]
}
])
})
})
describe('edge cases', () => {
it('handles empty settings store', () => {
mockSettingStore.settingsById = {}
const search = useSettingSearch()
search.handleSearch('test')
expect(search.filteredSettingIds.value).toEqual([])
})
it('handles settings with undefined category', () => {
mockSettings['NoCategorySetting'] = {
id: 'NoCategorySetting',
name: 'No Category',
type: 'text',
defaultValue: 'default'
}
const search = useSettingSearch()
search.handleSearch('category')
expect(search.filteredSettingIds.value).toContain('NoCategorySetting')
})
it('handles special characters in search query', () => {
const search = useSettingSearch()
// Search for part of the ID that contains a dot
search.handleSearch('category.setting')
expect(search.filteredSettingIds.value).toContain('Category.Setting1')
})
it('handles very long search queries', () => {
const search = useSettingSearch()
const longQuery = 'a'.repeat(1000)
search.handleSearch(longQuery)
expect(search.filteredSettingIds.value).toEqual([])
})
it('handles rapid consecutive searches', async () => {
const search = useSettingSearch()
search.handleSearch('setting')
search.handleSearch('other')
search.handleSearch('category')
expect(search.filteredSettingIds.value).toEqual(
expect.arrayContaining(['Category.Setting1', 'Category.Setting2'])
)
})
})
})

View File

@@ -1,261 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
const defaultSettingStore = {
get: vi.fn((key: string) => {
switch (key) {
case 'Comfy.Templates.SelectedModels':
case 'Comfy.Templates.SelectedUseCases':
case 'Comfy.Templates.SelectedRunsOn':
return []
case 'Comfy.Templates.SortBy':
return 'newest'
default:
return undefined
}
}),
set: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn()
}))
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
const gb = (value: number) => value * 1024 ** 3
const templates = ref<TemplateInfo[]>([
{
name: 'missing-vram',
description: 'no vram value',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'highest-vram',
description: 'high usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(12)
},
{
name: 'mid-vram',
description: 'medium usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(7.5)
},
{
name: 'low-vram',
description: 'low usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(5)
},
{
name: 'zero-vram',
description: 'unknown usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: 0
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'vram-low-to-high'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low-vram',
'mid-vram',
'highest-vram',
'missing-vram',
'zero-vram'
])
})
it('filters by search text, models, tags, and license with debounce handling', async () => {
vi.useFakeTimers()
const templates = ref<TemplateInfo[]>([
{
name: 'api-template',
description: 'Enterprise API workflow for video',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['API', 'Video'],
models: ['Flux'],
date: '2024-06-01',
vram: 15 * 1024 ** 3,
openSource: false
},
{
name: 'portrait-flow',
description: 'Portrait template tuned for SDXL',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Portrait'],
models: ['SDXL'],
date: '2024-05-15',
vram: 10 * 1024 ** 3
},
{
name: 'landscape-lite',
description: 'Lightweight landscape generator',
mediaType: 'image',
mediaSubtype: 'png',
tags: ['Landscape'],
models: ['SDXL', 'Flux'],
date: '2024-04-20'
}
])
const {
searchQuery,
selectedModels,
selectedUseCases,
selectedRunsOn,
filteredTemplates,
availableModels,
availableUseCases,
availableRunsOn,
filteredCount,
totalCount,
removeUseCaseFilter,
resetFilters
} = useTemplateFiltering(templates)
expect(totalCount.value).toBe(3)
expect(availableModels.value).toEqual(['Flux', 'SDXL'])
expect(availableUseCases.value).toEqual([
'API',
'Landscape',
'Portrait',
'Video'
])
expect(availableRunsOn.value).toEqual(['ComfyUI', 'External or Remote API'])
searchQuery.value = 'enterprise'
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedRunsOn.value = ['External or Remote API']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedModels.value = ['Flux']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
selectedUseCases.value = ['Video']
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template'
])
expect(filteredCount.value).toBe(1)
removeUseCaseFilter('Video')
await nextTick()
expect(selectedUseCases.value).toHaveLength(0)
resetFilters()
await nextTick()
await vi.runOnlyPendingTimersAsync()
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'api-template',
'portrait-flow',
'landscape-lite'
])
})
it('supports alphabetical, newest, and size-based sorting options', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'zeta-extended',
description: 'older template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-01-01',
size: 300
},
{
name: 'alpha-starter',
description: 'new template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-07-01',
size: 100
},
{
name: 'beta-pro',
description: 'mid template',
mediaType: 'image',
mediaSubtype: 'png',
date: '2024-05-01',
size: 200
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
// default is 'newest'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'alphabetical'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'model-size-low-to-high'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'alpha-starter',
'beta-pro',
'zeta-extended'
])
sortBy.value = 'default'
await nextTick()
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'zeta-extended',
'alpha-starter',
'beta-pro'
])
})
})

View File

@@ -1,303 +0,0 @@
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
// Mock the store
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: vi.fn()
})
)
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((path) => `mock-file-url${path}`),
apiURL: vi.fn((path) => `mock-api-url${path}`)
}
}))
// Mock the app
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
// Mock Vue I18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key, fallback) => fallback || key)
}),
createI18n: () => ({
global: {
t: (key: string) => key
}
})
}))
// Mock the dialog store
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
}))
}))
// Mock fetch
global.fetch = vi.fn()
describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: any
beforeEach(() => {
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: [
{
label: 'ComfyUI Examples',
modules: [
{
moduleName: 'all',
title: 'All',
localizedTitle: 'All Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'default',
localizedTitle: 'Template 1'
},
{
name: 'template2',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'custom-module',
description: 'A custom template'
}
]
},
{
moduleName: 'default',
title: 'Default',
localizedTitle: 'Default Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
localizedTitle: 'Template 1',
localizedDescription: 'A default template'
}
]
}
]
}
]
}
vi.mocked(useWorkflowTemplatesStore).mockReturnValue(
mockWorkflowTemplatesStore
)
// Mock fetch response
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
})
it('should load templates from store', async () => {
const { loadTemplates, isTemplatesLoaded } = useTemplateWorkflows()
expect(isTemplatesLoaded.value).toBe(false)
await loadTemplates()
expect(mockWorkflowTemplatesStore.loadWorkflowTemplates).toHaveBeenCalled()
})
it('should select the first template category', () => {
const { selectFirstTemplateCategory, selectedTemplate } =
useTemplateWorkflows()
selectFirstTemplateCategory()
expect(selectedTemplate.value).toEqual(
mockWorkflowTemplatesStore.groupedTemplates[0].modules[0]
)
})
it('should select a template category', () => {
const { selectTemplateCategory, selectedTemplate } = useTemplateWorkflows()
const category = mockWorkflowTemplatesStore.groupedTemplates[0].modules[1] // Default category
const result = selectTemplateCategory(category)
expect(result).toBe(true)
expect(selectedTemplate.value).toEqual(category)
})
it('should format template thumbnails correctly for default templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'default', '1')
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
})
it('should format template thumbnails correctly for custom templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'custom-module')
expect(url).toBe(
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
// Default template with localized title
const titleWithLocalized = getTemplateTitle(
{
name: 'test',
localizedTitle: 'Localized Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithLocalized).toBe('Localized Title')
// Default template without localized title
const titleWithFallback = getTemplateTitle(
{
name: 'test',
title: 'Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithFallback).toBe('Title')
// Custom template
const customTitle = getTemplateTitle(
{
name: 'test-template',
title: 'Custom Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(customTitle).toBe('Custom Title')
// Fallback to name
const nameOnly = getTemplateTitle(
{
name: 'name-only',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(nameOnly).toBe('name-only')
})
it('should format template descriptions correctly', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})
it('should load a template from the "All" category', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the "All" category
const result = await loadWorkflowTemplate('template1', 'all')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
})
it('should load a template from a regular category', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the default category
const result = await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Mock fetch to throw an error
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
// Spy on console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Load a template that will fail
const result = await loadWorkflowTemplate('error-template', 'default')
expect(result).toBe(false)
expect(consoleSpy).toHaveBeenCalled()
expect(loadingTemplateId.value).toBe(null) // Should reset even after error
// Restore console.error
consoleSpy.mockRestore()
})
})

View File

@@ -1,558 +0,0 @@
import { compare, valid } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
() => ({
useInstalledPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn()
}))
const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockSemverCompare = vi.mocked(compare)
const mockSemverValid = vi.mocked(valid)
describe('useUpdateAvailableNodes', () => {
const mockInstalledPacks = [
{
id: 'pack-1',
name: 'Outdated Pack',
latest_version: { version: '2.0.0' }
} as components['schemas']['Node'],
{
id: 'pack-2',
name: 'Up to Date Pack',
latest_version: { version: '1.0.0' }
} as components['schemas']['Node'],
{
id: 'pack-3',
name: 'Nightly Pack',
latest_version: { version: '1.5.0' }
} as components['schemas']['Node'],
{
id: 'pack-4',
name: 'No Latest Version',
latest_version: undefined
} as components['schemas']['Node']
]
const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
const mockIsPackEnabled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
return '1.0.0' // outdated
case 'pack-2':
return '1.0.0' // up to date
case 'pack-3':
return 'nightly-abc123' // nightly
case 'pack-4':
return '1.0.0' // no latest version
default:
return '1.0.0'
}
})
mockSemverValid.mockImplementation((version) => {
return version &&
typeof version === 'string' &&
!version.includes('nightly')
? version
: null
})
mockSemverCompare.mockImplementation((latest, installed) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
})
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion,
isPackEnabled: mockIsPackEnabled
} as unknown as ReturnType<typeof useComfyManagerStore>)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
})
describe('core filtering logic', () => {
it('identifies outdated packs correctly', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Should only include pack-1 (outdated)
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
})
it('excludes up-to-date packs', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes nightly packs from updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes packs with no latest version', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes uninstalled packs', () => {
mockIsPackInstalled.mockReturnValue(false)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('returns empty array when no installed packs exist', () => {
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toEqual([])
})
})
describe('hasUpdateAvailable computed', () => {
it('returns true when updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
it('returns false when no updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
})
describe('automatic data fetching', () => {
it('fetches installed packs automatically when none exist', () => {
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
it('does not fetch when already loading', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useInstalledPacks', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { isLoading } = useUpdateAvailableNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useInstalledPacks', () => {
const testError = 'Failed to fetch installed packs'
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { error } = useUpdateAvailableNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when installed packs change', async () => {
const installedPacksRef = ref<components['schemas']['Node'][]>([])
mockUseInstalledPacks.mockReturnValue({
installedPacks: installedPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, hasUpdateAvailable } =
useUpdateAvailableNodes()
// Initially empty
expect(updateAvailableNodePacks.value).toEqual([])
expect(hasUpdateAvailable.value).toBe(false)
// Update installed packs
installedPacksRef.value = [mockInstalledPacks[0]]
await nextTick()
// Should update available updates
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(hasUpdateAvailable.value).toBe(true)
})
})
describe('version comparison logic', () => {
it('calls compareVersions with correct parameters', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockSemverCompare).toHaveBeenCalledWith('2.0.0', '1.0.0')
})
it('calls semver.valid to check nightly versions', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockSemverValid).toHaveBeenCalledWith('nightly-abc123')
})
it('calls isPackInstalled for each pack', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
})
})
describe('enabledUpdateAvailableNodePacks', () => {
it('returns only enabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0], mockInstalledPacks[1]]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
// pack-1 has updates but is disabled
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
// enabledUpdateAvailableNodePacks should be empty
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(0)
})
it('returns all packs when all are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value[0].id).toBe('pack-1')
})
})
describe('hasDisabledUpdatePacks', () => {
it('returns true when there are disabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(true)
})
it('returns false when all packs with updates are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
it('returns false when no packs have updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
})
describe('hasUpdateAvailable with disabled packs', () => {
it('returns false when only disabled packs have updates', () => {
mockIsPackEnabled.mockReturnValue(false) // All packs disabled
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
it('returns true when at least one enabled pack has updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// Only pack-1 is enabled
return id === 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
})
})

View File

@@ -1,183 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
// Mock useChainCallback
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, newCallback) => {
return function (this: any, ...args: any[]) {
original?.call(this, ...args)
newCallback.call(this, ...args)
}
})
}))
describe('useComputedWithWidgetWatch', () => {
const createMockNode = (
widgets: Array<{
name: string
value: any
callback?: (...args: any[]) => void
}> = []
) => {
const mockNode = {
widgets: widgets.map((widget) => ({
name: widget.name,
value: widget.value,
callback: widget.callback
})),
graph: {
setDirtyCanvas: vi.fn()
}
} as unknown as LGraphNode
return mockNode
}
it('should create a reactive computed that responds to widget changes', async () => {
const mockNode = createMockNode([
{ name: 'width', value: 100 },
{ name: 'height', value: 200 }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
const computedValue = computedWithWidgetWatch(() => {
const width =
(mockNode.widgets?.find((w) => w.name === 'width')?.value as number) ||
0
const height =
(mockNode.widgets?.find((w) => w.name === 'height')?.value as number) ||
0
return width * height
})
// Initial value should be computed correctly
expect(computedValue.value).toBe(20000)
// Change widget value and trigger callback
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
}
await nextTick()
// Computed should update
expect(computedValue.value).toBe(30000)
})
it('should only observe specific widgets when widgetNames is provided', async () => {
const mockNode = createMockNode([
{ name: 'width', value: 100 },
{ name: 'height', value: 200 },
{ name: 'depth', value: 50 }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'height']
})
const computedValue = computedWithWidgetWatch(() => {
return mockNode.widgets?.map((w) => w.value).join('-') || ''
})
expect(computedValue.value).toBe('100-200-50')
// Change observed widget
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
}
await nextTick()
expect(computedValue.value).toBe('150-200-50')
// Change non-observed widget - should not trigger update
const depthWidget = mockNode.widgets?.find((w) => w.name === 'depth')
if (depthWidget) {
depthWidget.value = 75
// Depth widget callback should not have been modified
expect(depthWidget.callback).toBeUndefined()
}
})
it('should trigger canvas redraw when triggerCanvasRedraw is true', async () => {
const mockNode = createMockNode([{ name: 'value', value: 10 }])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
triggerCanvasRedraw: true
})
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
widget.value = 20
;(widget.callback as any)?.()
}
await nextTick()
// Canvas redraw should have been triggered
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('should not trigger canvas redraw when triggerCanvasRedraw is false', async () => {
const mockNode = createMockNode([{ name: 'value', value: 10 }])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
triggerCanvasRedraw: false
})
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
widget.value = 20
;(widget.callback as any)?.()
}
await nextTick()
// Canvas redraw should not have been triggered
expect(mockNode.graph?.setDirtyCanvas).not.toHaveBeenCalled()
})
it('should handle nodes without widgets gracefully', () => {
const mockNode = createMockNode([])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
const computedValue = computedWithWidgetWatch(() => 'no widgets')
expect(computedValue.value).toBe('no widgets')
})
it('should chain with existing widget callbacks', async () => {
const existingCallback = vi.fn()
const mockNode = createMockNode([
{ name: 'value', value: 10, callback: existingCallback }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Trigger widget callback
const widget = mockNode.widgets?.[0]
if (widget) {
;(widget.callback as any)?.()
}
await nextTick()
// Both existing callback and our callback should have been called
expect(existingCallback).toHaveBeenCalled()
})
})

View File

@@ -1,296 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
saveWorkflow: vi.fn()
}))
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key) => {
if (key === 'Comfy.Workflow.AutoSave') return mockAutoSaveSetting
if (key === 'Comfy.Workflow.AutoSaveDelay') return mockAutoSaveDelay
return null
})
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: mockActiveWorkflow
}))
}))
let mockAutoSaveSetting: string = 'off'
let mockAutoSaveDelay: number = 1000
let mockActiveWorkflow: { isModified: boolean; isPersisted?: boolean } | null =
null
describe('useWorkflowAutoSave', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should auto-save workflow after delay when modified and autosave enabled', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).toHaveBeenCalledWith(
mockActiveWorkflow
)
})
it('should not auto-save workflow after delay when not modified and autosave enabled', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: false, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
mockActiveWorkflow
)
})
it('should not auto save workflow when autosave is off', async () => {
mockAutoSaveSetting = 'off'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(mockAutoSaveDelay)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
})
it('should respect the user specified auto save delay', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 2000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(serviceInstance.saveWorkflow).toHaveBeenCalled()
})
it('should debounce save requests', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 2000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
vi.advanceTimersByTime(500)
graphChangedCallback()
vi.advanceTimersByTime(1999)
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
})
it('should handle save error gracefully', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
try {
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
serviceInstance.saveWorkflow.mockRejectedValue(new Error('Test Error'))
vi.advanceTimersByTime(1000)
await Promise.resolve()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Auto save failed:',
expect.any(Error)
)
} finally {
consoleErrorSpy.mockRestore()
}
})
it('should queue autosave requests during saving and reschedule after save completes', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
const serviceInstance = (useWorkflowService as any).mock.results[0].value
let resolveSave: () => void
const firstSavePromise = new Promise<void>((resolve) => {
resolveSave = resolve
})
serviceInstance.saveWorkflow.mockImplementationOnce(() => firstSavePromise)
vi.advanceTimersByTime(1000)
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
resolveSave!()
await Promise.resolve()
vi.advanceTimersByTime(1000)
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(2)
})
it('should clean up event listeners on component unmount', async () => {
mockAutoSaveSetting = 'after delay'
const wrapper = mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
wrapper.unmount()
expect(api.removeEventListener).toHaveBeenCalled()
})
it('should handle edge case delay values properly', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 0
mockActiveWorkflow = { isModified: true, isPersisted: true }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
await vi.runAllTimersAsync()
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
serviceInstance.saveWorkflow.mockClear()
mockAutoSaveDelay = -500
const graphChangedCallback = (api.addEventListener as any).mock.calls[0][1]
graphChangedCallback()
await vi.runAllTimersAsync()
expect(serviceInstance.saveWorkflow).toHaveBeenCalledTimes(1)
})
it('should not autosave if workflow is not persisted', async () => {
mockAutoSaveSetting = 'after delay'
mockAutoSaveDelay = 1000
mockActiveWorkflow = { isModified: true, isPersisted: false }
mount({
template: `<div></div>`,
setup() {
useWorkflowAutoSave()
return {}
}
})
vi.advanceTimersByTime(1000)
const serviceInstance = (useWorkflowService as any).mock.results[0].value
expect(serviceInstance.saveWorkflow).not.toHaveBeenCalledWith(
mockActiveWorkflow
)
})
})

View File

@@ -1,151 +0,0 @@
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)
})
})
})

View File

@@ -1,139 +0,0 @@
import { afterEach, describe, expect, it, test, vi } from 'vitest'
import { processDynamicPrompt } from '@/utils/formatUtil'
describe('dynamic prompts', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('handles single and multiline comments', () => {
const input =
'/*\nStart\n*/Hello /* this is a comment */ world!\n// it\nEnd'
expect(processDynamicPrompt(input)).toBe('Hello world!\n\nEnd')
})
it('handles simple option groups', () => {
const input = '{option1|option2}'
vi.spyOn(Math, 'random').mockReturnValue(0)
expect(processDynamicPrompt(input)).toBe('option1')
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('option2')
})
test('handles trailing empty options', () => {
const input = '{a|}'
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('')
})
test('handles leading empty options', () => {
const input = '{|a}'
vi.spyOn(Math, 'random').mockReturnValue(0)
expect(processDynamicPrompt(input)).toBe('')
})
test('handles multiple empty alternatives', () => {
const input = '{||}'
expect(processDynamicPrompt(input)).toBe('')
})
test('handles multiple nested empty alternatives', () => {
const input = '{a|{b||c}|}'
vi.spyOn(Math, 'random').mockReturnValue(0.5)
expect(processDynamicPrompt(input)).toBe('')
})
test('handles unescaped special characters gracefully', () => {
const input = '{a|\\}'
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('}')
})
it('handles nested option groups', () => {
vi.spyOn(Math, 'random').mockReturnValue(0) // pick the first option at each level
const input = '{a|{b|{c|d}}}'
expect(processDynamicPrompt(input)).toBe('a')
vi.spyOn(Math, 'random').mockReturnValue(0.99) // pick the last option at each level
expect(processDynamicPrompt(input)).toBe('d')
})
test('handles escaped braces', () => {
const input = '\\{a|b\\}'
expect(processDynamicPrompt(input)).toBe('{a|b}')
})
test('handles escaped pipe', () => {
const input = 'a\\|b'
expect(processDynamicPrompt(input)).toBe('a|b')
})
it('handles escaped characters', () => {
const input = '{\\{escaped\\}\\|escaped pipe}'
expect(processDynamicPrompt(input)).toBe('{escaped}|escaped pipe')
})
test('handles deeply nested escaped characters', () => {
const input = '{a|{b|\\{c\\}}}'
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('{c}')
})
it('handles mixed input', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.99)
const input =
'<{option1|option2}>/*comment*/ ({something|else}:2) \\{escaped\\}!'
expect(processDynamicPrompt(input)).toBe('<option2> (else:2) {escaped}!')
})
it('handles non-paired braces gracefully', () => {
vi.spyOn(Math, 'random').mockReturnValue(0)
const input = '{option1|option2|{nested1|nested2'
expect(processDynamicPrompt(input)).toBe('option1')
vi.spyOn(Math, 'random').mockReturnValue(0.4)
expect(processDynamicPrompt(input)).toBe('option2')
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('nested2')
})
it('handles deep nesting', () => {
const input = '{a|{b|{c|{d|{e|{f|{g}}1}2}3}4}5}'
vi.spyOn(Math, 'random').mockReturnValue(0.99)
expect(processDynamicPrompt(input)).toBe('g12345')
})
test('handles empty alternative inside braces', () => {
const input = '{|a||b|}'
vi.spyOn(Math, 'random').mockReturnValue(0)
expect(processDynamicPrompt(input)).toBe('')
vi.spyOn(Math, 'random').mockReturnValue(0.3)
expect(processDynamicPrompt(input)).toBe('a')
vi.spyOn(Math, 'random').mockReturnValue(0.5)
expect(processDynamicPrompt(input)).toBe('')
vi.spyOn(Math, 'random').mockReturnValue(0.75)
expect(processDynamicPrompt(input)).toBe('b')
vi.spyOn(Math, 'random').mockReturnValue(0.999)
expect(processDynamicPrompt(input)).toBe('')
})
test('handles no braces', () => {
const input = 'abcdef'
expect(processDynamicPrompt(input)).toBe('abcdef')
})
test('handles empty input', () => {
const input = ''
expect(processDynamicPrompt(input)).toBe('')
})
test('handles complex mixed cases', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5) //pick the second option from each group
const input = '1{a|b|{c|d}}2{e|f}3'
expect(processDynamicPrompt(input)).toBe('1b2f3')
})
})

View File

@@ -1,231 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useExtensionService } from '@/services/extensionService'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
describe('Context Menu Extension API', () => {
let mockCanvas: LGraphCanvas
let mockNode: LGraphNode
let extensionStore: ReturnType<typeof useExtensionStore>
let extensionService: ReturnType<typeof useExtensionService>
// Mock menu items
const canvasMenuItem1: IContextMenuValue = {
content: 'Canvas Item 1',
callback: () => {}
}
const canvasMenuItem2: IContextMenuValue = {
content: 'Canvas Item 2',
callback: () => {}
}
const nodeMenuItem1: IContextMenuValue = {
content: 'Node Item 1',
callback: () => {}
}
const nodeMenuItem2: IContextMenuValue = {
content: 'Node Item 2',
callback: () => {}
}
// Mock extensions
const createCanvasMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getCanvasMenuItems: () => items
})
const createNodeMenuExtension = (
name: string,
items: IContextMenuValue[]
): ComfyExtension => ({
name,
getNodeMenuItems: () => items
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
extensionStore = useExtensionStore()
extensionService = useExtensionService()
mockCanvas = {
graph_mouse: [100, 100],
selectedItems: new Set()
} as unknown as LGraphCanvas
mockNode = {
id: 1,
type: 'TestNode',
pos: [0, 0]
} as unknown as LGraphNode
})
describe('collectCanvasMenuItems', () => {
it('should call getCanvasMenuItems and collect into flat array', () => {
const ext1 = createCanvasMenuExtension('Extension 1', [canvasMenuItem1])
const ext2 = createCanvasMenuExtension('Extension 2', [
canvasMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Canvas Item 1' })
expect(items[1]).toMatchObject({ content: 'Canvas Item 2' })
expect(items[2]).toMatchObject({ content: 'Item 3' })
})
it('should support submenus and separators', () => {
const extension = createCanvasMenuExtension('Test Extension', [
{
content: 'Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Submenu Item 1', callback: () => {} },
{ content: 'Submenu Item 2', callback: () => {} }
]
}
},
null as unknown as IContextMenuValue,
{ content: 'After Separator', callback: () => {} }
])
extensionStore.registerExtension(extension)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(3)
expect(items[0].content).toBe('Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
expect(items[1]).toBeNull()
expect(items[2].content).toBe('After Separator')
})
it('should skip extensions without getCanvasMenuItems', () => {
const canvasExtension = createCanvasMenuExtension('Canvas Ext', [
canvasMenuItem1
])
const extensionWithoutCanvasMenu: ComfyExtension = {
name: 'No Canvas Menu'
}
extensionStore.registerExtension(canvasExtension)
extensionStore.registerExtension(extensionWithoutCanvasMenu)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Canvas Item 1')
})
it('should not duplicate menu items when collected multiple times', () => {
const extension = createCanvasMenuExtension('Test Extension', [
canvasMenuItem1,
canvasMenuItem2
])
extensionStore.registerExtension(extension)
// Collect items multiple times (simulating repeated menu opens)
const items1: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
const items2: IContextMenuValue[] = extensionService
.invokeExtensions('getCanvasMenuItems', mockCanvas)
.flat()
// Both collections should have the same items (no duplication)
expect(items1).toHaveLength(2)
expect(items2).toHaveLength(2)
// Verify items are unique by checking their content
const contents1 = items1.map((item) => item.content)
const uniqueContents1 = new Set(contents1)
expect(uniqueContents1.size).toBe(contents1.length)
const contents2 = items2.map((item) => item.content)
const uniqueContents2 = new Set(contents2)
expect(uniqueContents2.size).toBe(contents2.length)
})
})
describe('collectNodeMenuItems', () => {
it('should call getNodeMenuItems and collect into flat array', () => {
const ext1 = createNodeMenuExtension('Extension 1', [nodeMenuItem1])
const ext2 = createNodeMenuExtension('Extension 2', [
nodeMenuItem2,
{ content: 'Item 3', callback: () => {} }
])
extensionStore.registerExtension(ext1)
extensionStore.registerExtension(ext2)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ content: 'Node Item 1' })
expect(items[1]).toMatchObject({ content: 'Node Item 2' })
})
it('should support submenus', () => {
const extension = createNodeMenuExtension('Submenu Extension', [
{
content: 'Node Menu with Submenu',
has_submenu: true,
submenu: {
options: [
{ content: 'Node Submenu 1', callback: () => {} },
{ content: 'Node Submenu 2', callback: () => {} }
]
}
}
])
extensionStore.registerExtension(extension)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items[0].content).toBe('Node Menu with Submenu')
expect(items[0].submenu?.options).toHaveLength(2)
})
it('should skip extensions without getNodeMenuItems', () => {
const nodeExtension = createNodeMenuExtension('Node Ext', [nodeMenuItem1])
const extensionWithoutNodeMenu: ComfyExtension = {
name: 'No Node Menu'
}
extensionStore.registerExtension(nodeExtension)
extensionStore.registerExtension(extensionWithoutNodeMenu)
const items: IContextMenuValue[] = extensionService
.invokeExtensions('getNodeMenuItems', mockNode)
.flat()
expect(items).toHaveLength(1)
expect(items[0].content).toBe('Node Item 1')
})
})
})

View File

@@ -1,71 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
/**
* Test that demonstrates the extension name appearing in deprecation warnings
*/
describe('Context Menu Extension Name in Warnings', () => {
it('should include extension name in deprecation warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate what happens during extension setup
legacyMenuCompat.setCurrentExtension('MyCustomExtension')
// Extension monkey-patches the method
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'My Custom Menu Item', callback: () => {} })
return items
}
// Clear extension (happens after setup completes)
legacyMenuCompat.setCurrentExtension(null)
// Verify the warning includes the extension name
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getCanvasMenuOptions')
expect(warningMessage).toContain('"MyCustomExtension"')
vi.restoreAllMocks()
})
it('should include extension name for node menu patches', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getNodeMenuOptions')
// Simulate what happens during extension setup
legacyMenuCompat.setCurrentExtension('AnotherExtension')
// Extension monkey-patches the method
const original = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'My Node Menu Item', callback: () => {} })
return items
}
// Clear extension (happens after setup completes)
legacyMenuCompat.setCurrentExtension(null)
// Verify the warning includes extension info
expect(warnSpy).toHaveBeenCalled()
const warningMessage = warnSpy.mock.calls[0][0]
expect(warningMessage).toContain('[DEPRECATED]')
expect(warningMessage).toContain('getNodeMenuOptions')
expect(warningMessage).toContain('"AnotherExtension"')
vi.restoreAllMocks()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
function swapNodes(nodes: LGraphNode[]) {
const firstNode = nodes[0]
const lastNode = nodes[nodes.length - 1]
nodes[0] = lastNode
nodes[nodes.length - 1] = firstNode
return nodes
}
function createGraph(...nodes: LGraphNode[]) {
const graph = new LGraph()
nodes.forEach((node) => graph.add(node))
return graph
}
class DummyNode extends LGraphNode {
constructor() {
super('dummy')
}
}
describe('LGraph', () => {
it('should serialize deterministic node order', async () => {
LiteGraph.registerNodeType('dummy', DummyNode)
const node1 = new DummyNode()
const node2 = new DummyNode()
const graph = createGraph(node1, node2)
const result1 = graph.serialize({ sortNodes: true })
expect(result1.nodes).not.toHaveLength(0)
graph._nodes = swapNodes(graph.nodes)
const result2 = graph.serialize({ sortNodes: true })
expect(result1).toEqual(result2)
})
})

View File

@@ -1,59 +0,0 @@
# LiteGraph Tests
This directory contains the test suite for the LiteGraph library.
## Structure
```
litegraph/
├── core/ # Core functionality tests (LGraph, LGraphNode, etc.)
├── canvas/ # Canvas-related tests (rendering, interactions)
├── infrastructure/ # Infrastructure tests (Rectangle, utilities)
├── subgraph/ # Subgraph-specific tests
├── utils/ # Utility function tests
└── fixtures/ # Test helpers, fixtures, and assets
```
## Running Tests
```bash
# Run all litegraph tests
pnpm test:unit -- tests-ui/tests/litegraph/
# Run specific subdirectory
pnpm test:unit -- tests-ui/tests/litegraph/core/
# Run single test file
pnpm test:unit -- tests-ui/tests/litegraph/core/LGraph.test.ts
```
## Migration Status
These tests were migrated from `src/lib/litegraph/test/` to centralize test infrastructure. Currently, some tests are marked with `.skip` due to import/setup issues that need to be resolved.
### TODO: Fix Skipped Tests
The following test files have been temporarily disabled and need fixes:
- Most subgraph tests (circular dependency issues)
- Some core tests (missing test utilities)
- Canvas tests (mock setup issues)
See individual test files marked with `// TODO: Fix these tests after migration` for specific issues.
## Writing New Tests
1. Always import from the barrel export to avoid circular dependencies:
```typescript
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
```
2. Use the test fixtures from `fixtures/` directory
3. Follow existing patterns for test organization
## Test Fixtures
Test fixtures and helpers are located in the `fixtures/` directory:
- `testExtensions.ts` - Custom vitest extensions
- `subgraphHelpers.ts` - Helpers for creating test subgraphs
- `subgraphFixtures.ts` - Common subgraph test scenarios
- `assets/` - Test data files

View File

@@ -1,155 +0,0 @@
// TODO: Fix these tests after migration
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
// We don't strictly need RenderLink interface import for the mock
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
// Mocks
const mockSetConnectingLinks = vi.fn()
// Mock a structure that has the needed method
function mockRenderLinkImpl(canConnect: boolean) {
return {
canConnectToInput: vi.fn().mockReturnValue(canConnect)
// Add other properties if they become necessary for tests
}
}
const mockNode = {} as LGraphNode
const mockInput = {} as INodeInputSlot
describe.skip('LinkConnector', () => {
let connector: LinkConnector
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
// Clear the array directly before each test
connector.renderLinks.length = 0
vi.clearAllMocks()
})
describe.skip('isInputValidDrop', () => {
test('should return false if there are no render links', () => {
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
})
test('should return true if at least one render link can connect', () => {
const link1 = mockRenderLinkImpl(false)
const link2 = mockRenderLinkImpl(true)
// Cast to any to satisfy the push requirement, as we only need the canConnectToInput method
connector.renderLinks.push(link1 as any, link2 as any)
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true)
expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
})
test('should return false if no render links can connect', () => {
const link1 = mockRenderLinkImpl(false)
const link2 = mockRenderLinkImpl(false)
connector.renderLinks.push(link1 as any, link2 as any)
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(false)
expect(link1.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
})
test('should call canConnectToInput on each render link until one returns true', () => {
const link1 = mockRenderLinkImpl(false)
const link2 = mockRenderLinkImpl(true) // This one can connect
const link3 = mockRenderLinkImpl(false)
connector.renderLinks.push(link1 as any, link2 as any, link3 as any)
expect(connector.isInputValidDrop(mockNode, mockInput)).toBe(true)
expect(link1.canConnectToInput).toHaveBeenCalledTimes(1)
expect(link2.canConnectToInput).toHaveBeenCalledTimes(1) // Stops here
expect(link3.canConnectToInput).not.toHaveBeenCalled() // Should not be called
})
})
describe.skip('listenUntilReset', () => {
test('should add listener for the specified event and for reset', () => {
const listener = vi.fn()
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
connector.listenUntilReset('before-drop-links', listener)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'before-drop-links',
listener,
undefined
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'reset',
expect.any(Function),
{ once: true }
)
})
test('should call the listener when the event is dispatched before reset', () => {
const listener = vi.fn()
const eventData = { renderLinks: [], event: {} as any } // Mock event data
connector.listenUntilReset('before-drop-links', listener)
connector.events.dispatch('before-drop-links', eventData)
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
new CustomEvent('before-drop-links')
)
})
test('should remove the listener when reset is dispatched', () => {
const listener = vi.fn()
const removeEventListenerSpy = vi.spyOn(
connector.events,
'removeEventListener'
)
connector.listenUntilReset('before-drop-links', listener)
// Simulate the reset event being dispatched
connector.events.dispatch('reset', false)
// Check if removeEventListener was called correctly for the original listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'before-drop-links',
listener
)
})
test('should not call the listener after reset is dispatched', () => {
const listener = vi.fn()
const eventData = { renderLinks: [], event: {} as any }
connector.listenUntilReset('before-drop-links', listener)
// Dispatch reset first
connector.events.dispatch('reset', false)
// Then dispatch the original event
connector.events.dispatch('before-drop-links', eventData)
expect(listener).not.toHaveBeenCalled()
})
test('should pass options to addEventListener', () => {
const listener = vi.fn()
const options = { once: true }
const addEventListenerSpy = vi.spyOn(connector.events, 'addEventListener')
connector.listenUntilReset('after-drop-links', listener, options)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'after-drop-links',
listener,
options
)
// Still adds the reset listener
expect(addEventListenerSpy).toHaveBeenCalledWith(
'reset',
expect.any(Function),
{ once: true }
)
})
})
})

View File

@@ -1,358 +0,0 @@
// TODO: Fix these tests after migration
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LinkConnector,
MovingOutputLink,
ToOutputRenderLink,
LGraphNode,
LLink
} from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'
describe('LinkConnector SubgraphInput connection validation', () => {
let connector: LinkConnector
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})
describe('Link disconnection validation', () => {
it('should properly cleanup a moved input link', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const fromTargetNode = new LGraphNode('TargetNode')
fromTargetNode.addInput('number_in', 'number')
subgraph.add(fromTargetNode)
const toTargetNode = new LGraphNode('TargetNode')
toTargetNode.addInput('number_in', 'number')
subgraph.add(toTargetNode)
const startLink = subgraph.inputNode.slots[0].connect(
fromTargetNode.inputs[0],
fromTargetNode
)
fromTargetNode.onConnectionsChange = vi.fn()
toTargetNode.onConnectionsChange = vi.fn()
const renderLink = new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraph.inputNode.slots[0],
undefined,
LinkDirection.CENTER,
startLink
)
renderLink.connectToInput(
toTargetNode,
toTargetNode.inputs[0],
connector.events
)
expect(fromTargetNode.inputs[0].link).toBeNull()
expect(toTargetNode.inputs[0].link).not.toBeNull()
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
})
})
describe('MovingOutputLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Verify the method exists
expect(typeof movingLink.canConnectToSubgraphInput).toBe('function')
})
it('should validate type compatibility correctly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid link (number -> number)
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
subgraph._links.set(validLink.id, validLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
// Create invalid link (string -> number)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(invalidLink.id, invalidLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const numberInput = subgraph.inputs[0]
// Test validation
expect(validMovingLink.canConnectToSubgraphInput(numberInput)).toBe(true)
expect(invalidMovingLink.canConnectToSubgraphInput(numberInput)).toBe(
false
)
})
it('should handle wildcard types', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'wildcard_input', type: '*' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
const wildcardInput = subgraph.inputs[0]
// Wildcard should accept any type
expect(movingLink.canConnectToSubgraphInput(wildcardInput)).toBe(true)
})
})
describe('ToOutputRenderLink validation', () => {
it('should implement canConnectToSubgraphInput method', () => {
// Create a minimal valid setup
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
node.id = 1
node.addInput('test_in', 'number')
subgraph.add(node)
const slot = node.inputs[0] as NodeInputSlot
const renderLink = new ToOutputRenderLink(subgraph, node, slot)
// Verify the method exists
expect(typeof renderLink.canConnectToSubgraphInput).toBe('function')
})
})
describe('dropOnIoNode validation', () => {
it('should prevent invalid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create an invalid link (string output -> string input, but subgraph expects number)
const link = new LLink(1, 'string', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Mock console.warn to verify it's called
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it's NOT called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the invalid connection was skipped
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Invalid connection type',
'string',
'->',
'number'
)
expect(connectSpy).not.toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
it('should allow valid connections when dropping on SubgraphInputNode', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
subgraph.add(targetNode)
// Create a valid link (number -> number)
const link = new LLink(1, 'number', sourceNode.id, 0, targetNode.id, 0)
subgraph._links.set(link.id, link)
const movingLink = new MovingOutputLink(subgraph, link)
// Add the link to the connector
connector.renderLinks.push(movingLink)
connector.state.connectingTo = 'output'
// Create mock event
const mockEvent = {
canvasX: 100,
canvasY: 100
} as any
// Mock the getSlotInPosition to return the subgraph input
const mockGetSlotInPosition = vi.fn().mockReturnValue(subgraph.inputs[0])
subgraph.inputNode.getSlotInPosition = mockGetSlotInPosition
// Spy on connectToSubgraphInput to ensure it IS called
const connectSpy = vi.spyOn(movingLink, 'connectToSubgraphInput')
// Drop on the SubgraphInputNode
connector.dropOnIoNode(subgraph.inputNode, mockEvent)
// Verify that the valid connection was made
expect(connectSpy).toHaveBeenCalledWith(
subgraph.inputs[0],
connector.events
)
})
})
describe('isSubgraphInputValidDrop', () => {
it('should check if render links can connect to SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const sourceNode = new LGraphNode('SourceNode')
sourceNode.addOutput('number_out', 'number')
sourceNode.addOutput('string_out', 'string')
subgraph.add(sourceNode)
const targetNode = new LGraphNode('TargetNode')
targetNode.addInput('number_in', 'number')
targetNode.addInput('string_in', 'string')
subgraph.add(targetNode)
// Create valid and invalid links
const validLink = new LLink(
1,
'number',
sourceNode.id,
0,
targetNode.id,
0
)
const invalidLink = new LLink(
2,
'string',
sourceNode.id,
1,
targetNode.id,
1
)
subgraph._links.set(validLink.id, validLink)
subgraph._links.set(invalidLink.id, invalidLink)
const validMovingLink = new MovingOutputLink(subgraph, validLink)
const invalidMovingLink = new MovingOutputLink(subgraph, invalidLink)
const subgraphInput = subgraph.inputs[0]
// Test with only invalid link
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
// Test with valid link
connector.renderLinks.length = 0
connector.renderLinks.push(validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
// Test with mixed links
connector.renderLinks.length = 0
connector.renderLinks.push(invalidMovingLink, validMovingLink)
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(true)
})
it('should handle render links without canConnectToSubgraphInput method', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
// Create a mock render link without the method
const mockLink = {
fromSlot: { type: 'number' }
// No canConnectToSubgraphInput method
} as any
connector.renderLinks.push(mockLink)
const subgraphInput = subgraph.inputs[0]
// Should return false as the link doesn't have the method
expect(connector.isSubgraphInputValidDrop(subgraphInput)).toBe(false)
})
})
})

View File

@@ -1,21 +0,0 @@
// TODO: Fix these tests after migration
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './fixtures/testExtensions'
describe.skip('LGraph configure()', () => {
dirtyTest(
'LGraph matches previous snapshot (normal configure() usage)',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const configuredMinGraph = new LGraph()
configuredMinGraph.configure(minimalSerialisableGraph)
expect(configuredMinGraph).toMatchSnapshot('configuredMinGraph')
const configuredBasicGraph = new LGraph()
configuredBasicGraph.configure(basicSerialisableGraph)
expect(configuredBasicGraph).toMatchSnapshot('configuredBasicGraph')
}
)
})

View File

@@ -1,192 +0,0 @@
import { describe } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
describe('LGraph', () => {
test('can be instantiated', ({ expect }) => {
// @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised
const graph = new LGraph({ extra: 'TestGraph' })
expect(graph).toBeInstanceOf(LGraph)
expect(graph.extra).toBe('TestGraph')
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {
const dGraph = new LGraph(minimalSerialisableGraph)
expect(dGraph.links).toBeInstanceOf(Map)
expect(dGraph.nodes).toBeInstanceOf(Array)
expect(dGraph.groups).toBeInstanceOf(Array)
})
test('supports schema v0.4 graphs', ({ expect, oldSchemaGraph }) => {
const fromOldSchema = new LGraph(oldSchemaGraph)
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
})
})
describe('Floating Links / Reroutes', () => {
test('Floating reroute should be removed when node and link are removed', ({
expect,
floatingLinkGraph
}) => {
const graph = new LGraph(floatingLinkGraph)
expect(graph.nodes.length).toBe(1)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(0)
expect(graph.reroutes.size).toBe(0)
})
test('Can add reroute to existing link', ({ expect, linkedNodesGraph }) => {
const graph = new LGraph(linkedNodesGraph)
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(0)
graph.createReroute([0, 0], graph.links.values().next().value!)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
})
test('Create floating reroute when one side of node is removed', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
graph.remove(graph.nodes[0])
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
})
test('Create floating reroute when one side of link is removed', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
graph.nodes[0].disconnectOutput(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(1)
expect(graph.reroutes.values().next().value!.floating).not.toBeUndefined()
})
test('Reroutes and branches should be retained when the input node is removed', ({
expect,
floatingBranchGraph: graph
}) => {
expect(graph.nodes.length).toBe(3)
graph.remove(graph.nodes[2])
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(4)
graph.remove(graph.nodes[1])
expect(graph.nodes.length).toBe(1)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(2)
expect(graph.reroutes.size).toBe(4)
})
test('Floating reroutes should be removed when neither input nor output is connected', ({
expect,
floatingBranchGraph: graph
}) => {
// Remove output node
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(2)
// The original floating reroute should be removed
expect(graph.reroutes.size).toBe(3)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(1)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(1)
expect(graph.reroutes.size).toBe(3)
graph.remove(graph.nodes[0])
expect(graph.nodes.length).toBe(0)
expect(graph.links.size).toBe(0)
expect(graph.floatingLinks.size).toBe(0)
expect(graph.reroutes.size).toBe(0)
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
}) => {
const graph = new LGraph()
// Create test nodes with onRemoved callbacks
const node1 = new LGraphNode('TestNode1')
const node2 = new LGraphNode('TestNode2')
// Add nodes to graph
graph.add(node1)
graph.add(node2)
// Track callback invocations
const nodeRemovedCallbacks = new Set<string>()
const graphRemovedCallbacks = new Set<string>()
// Set up node.onRemoved() callbacks
node1.onRemoved = () => {
nodeRemovedCallbacks.add(String(node1.id))
}
node2.onRemoved = () => {
nodeRemovedCallbacks.add(String(node2.id))
}
// Set up graph.onNodeRemoved() callback
graph.onNodeRemoved = (node) => {
graphRemovedCallbacks.add(String(node.id))
}
// Verify nodes are in graph before clearing
expect(graph.nodes.length).toBe(2)
// Clear the graph
graph.clear()
// Verify both types of callbacks were called
expect(nodeRemovedCallbacks).toContain(String(node1.id))
expect(nodeRemovedCallbacks).toContain(String(node2.id))
expect(graphRemovedCallbacks).toContain(String(node1.id))
expect(graphRemovedCallbacks).toContain(String(node2.id))
// Verify nodes were actually removed
expect(graph.nodes.length).toBe(0)
})
})
describe('Legacy LGraph Compatibility Layer', () => {
test('can be extended via prototype', ({ expect, minimalGraph }) => {
// @ts-expect-error Should always be an error.
LGraph.prototype.newMethod = function () {
return 'New method added via prototype'
}
// @ts-expect-error Should always be an error.
expect(minimalGraph.newMethod()).toBe('New method added via prototype')
})
test('is correctly assigned to LiteGraph', ({ expect }) => {
expect(LiteGraph.LGraph).toBe(LGraph)
})
})

View File

@@ -1,195 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import { Rectangle } from '@/lib/litegraph/src/litegraph'
describe('LGraphButton', () => {
describe('Constructor', () => {
it('should create a button with default options', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(button._last_area).toBeInstanceOf(Rectangle)
})
it('should create a button with custom name', () => {
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
expect(button.name).toBe('test_button')
})
it('should inherit badge properties', () => {
const button = new LGraphButton({
text: 'Test',
fgColor: '#FF0000',
bgColor: '#0000FF',
fontSize: 16
})
expect(button.text).toBe('Test')
expect(button.fgColor).toBe('#FF0000')
expect(button.bgColor).toBe('#0000FF')
expect(button.fontSize).toBe(16)
expect(button.visible).toBe(true) // visible is computed based on text length
})
})
describe('draw', () => {
it('should not draw if not visible', () => {
const button = new LGraphButton({ text: '' }) // Empty text makes it invisible
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 100 })
} as unknown as CanvasRenderingContext2D
const superDrawSpy = vi.spyOn(
Object.getPrototypeOf(Object.getPrototypeOf(button)),
'draw'
)
button.draw(ctx, 50, 100)
expect(superDrawSpy).not.toHaveBeenCalled()
expect(button._last_area.width).toBe(0) // Rectangle default width
})
it('should draw and update last area when visible', () => {
const button = new LGraphButton({
text: 'Click',
xOffset: 5,
yOffset: 10
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 60 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(80)
button.getWidth = mockGetWidth
const x = 100
const y = 50
button.draw(ctx, x, y)
// Check that last area was updated correctly
expect(button._last_area[0]).toBe(x + button.xOffset) // 100 + 5 = 105
expect(button._last_area[1]).toBe(y + button.yOffset) // 50 + 10 = 60
expect(button._last_area[2]).toBe(80)
expect(button._last_area[3]).toBe(button.height)
})
it('should calculate last area without offsets', () => {
const button = new LGraphButton({
text: 'Test'
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 40 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
const mockGetWidth = vi.fn().mockReturnValue(50)
button.getWidth = mockGetWidth
button.draw(ctx, 200, 100)
expect(button._last_area[0]).toBe(200)
expect(button._last_area[1]).toBe(100)
expect(button._last_area[2]).toBe(50)
})
})
describe('isPointInside', () => {
it('should return true when point is inside button area', () => {
const button = new LGraphButton({ text: 'Test' })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points inside
expect(button.isPointInside(100, 50)).toBe(true) // Top-left corner
expect(button.isPointInside(179, 69)).toBe(true) // Bottom-right corner
expect(button.isPointInside(140, 60)).toBe(true) // Center
})
it('should return false when point is outside button area', () => {
const button = new LGraphButton({ text: 'Test' })
// Set the last area manually
button._last_area[0] = 100
button._last_area[1] = 50
button._last_area[2] = 80
button._last_area[3] = 20
// Test various points outside
expect(button.isPointInside(99, 50)).toBe(false) // Just left
expect(button.isPointInside(181, 50)).toBe(false) // Just right
expect(button.isPointInside(100, 49)).toBe(false) // Just above
expect(button.isPointInside(100, 71)).toBe(false) // Just below
expect(button.isPointInside(0, 0)).toBe(false) // Far away
})
it('should work with buttons that have not been drawn yet', () => {
const button = new LGraphButton({ text: 'Test' })
// _last_area has default values (0, 0, 0, 0)
expect(button.isPointInside(10, 10)).toBe(false)
expect(button.isPointInside(0, 0)).toBe(false)
})
})
describe('Integration with LGraphBadge', () => {
it('should properly inherit and use badge functionality', () => {
const button = new LGraphButton({
text: '→',
fontSize: 20,
// @ts-expect-error TODO: Fix after merge - color property not defined in type
color: '#FFFFFF',
backgroundColor: '#333333',
xOffset: -10,
yOffset: 5
})
const ctx = {
measureText: vi.fn().mockReturnValue({ width: 20 }),
fillRect: vi.fn(),
fillText: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
font: '',
fillStyle: '',
globalAlpha: 1
} as unknown as CanvasRenderingContext2D
// Draw the button
button.draw(ctx, 100, 50)
// Verify button draws text without background
expect(ctx.beginPath).not.toHaveBeenCalled() // No background
expect(ctx.roundRect).not.toHaveBeenCalled() // No background
expect(ctx.fill).not.toHaveBeenCalled() // No background
expect(ctx.fillText).toHaveBeenCalledWith(
'→',
expect.any(Number),
expect.any(Number)
) // Just text
})
})
})

View File

@@ -1,290 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
describe('LGraphCanvas Title Button Rendering', () => {
let canvas: LGraphCanvas
let ctx: CanvasRenderingContext2D
let node: LGraphNode
beforeEach(() => {
// Create a mock canvas element
const canvasElement = document.createElement('canvas')
ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
// @ts-expect-error TODO: Fix after merge - LGraphCanvas constructor type issues
canvas = new LGraphCanvas(canvasElement, null, {
skip_render: true,
skip_events: true
})
node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [200, 100]
// Mock required methods
node.drawTitleBarBackground = vi.fn()
// @ts-expect-error Property 'drawTitleBarText' does not exist on type 'LGraphNode'
node.drawTitleBarText = vi.fn()
node.drawBadges = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawToggles not defined in type
node.drawToggles = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawNodeShape not defined in type
node.drawNodeShape = vi.fn()
node.drawSlots = vi.fn()
// @ts-expect-error TODO: Fix after merge - drawContent not defined in type
node.drawContent = vi.fn()
node.drawWidgets = vi.fn()
node.drawCollapsedSlots = vi.fn()
node.drawTitleBox = vi.fn()
node.drawTitleText = vi.fn()
node.drawProgressBar = vi.fn()
node._setConcreteSlots = vi.fn()
node.arrange = vi.fn()
// @ts-expect-error TODO: Fix after merge - isSelectable not defined in type
node.isSelectable = vi.fn().mockReturnValue(true)
})
describe('drawNode title button rendering', () => {
it('should render visible title buttons', () => {
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
// Mock button methods
const getWidth1 = vi.fn().mockReturnValue(20)
const getWidth2 = vi.fn().mockReturnValue(25)
const draw1 = vi.spyOn(button1, 'draw')
const draw2 = vi.spyOn(button2, 'draw')
button1.getWidth = getWidth1
button2.getWidth = getWidth2
// Draw the node (this is a simplified version of what drawNode does)
canvas.drawNode(node, ctx)
// Verify both buttons' getWidth was called
expect(getWidth1).toHaveBeenCalledWith(ctx)
expect(getWidth2).toHaveBeenCalledWith(ctx)
// Verify both buttons were drawn
expect(draw1).toHaveBeenCalled()
expect(draw2).toHaveBeenCalled()
// Check draw positions (right-aligned from node width)
// First button (rightmost): 200 - 5 = 195, then subtract width
// Second button: first button position - 5 - button width
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Centered
expect(draw1).toHaveBeenCalledWith(ctx, 180, buttonY) // 200 - 20
expect(draw2).toHaveBeenCalledWith(ctx, 153, buttonY) // 180 - 2 - 25
})
it('should skip invisible title buttons', () => {
const visibleButton = node.addTitleButton({
name: 'visible',
text: 'V',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const invisibleButton = node.addTitleButton({
name: 'invisible',
text: '' // Empty text makes it invisible
})
const getWidthVisible = vi.fn().mockReturnValue(30)
const getWidthInvisible = vi.fn().mockReturnValue(30)
const drawVisible = vi.spyOn(visibleButton, 'draw')
const drawInvisible = vi.spyOn(invisibleButton, 'draw')
visibleButton.getWidth = getWidthVisible
invisibleButton.getWidth = getWidthInvisible
canvas.drawNode(node, ctx)
// Only visible button should be measured and drawn
expect(getWidthVisible).toHaveBeenCalledWith(ctx)
expect(getWidthInvisible).not.toHaveBeenCalled()
expect(drawVisible).toHaveBeenCalled()
expect(drawInvisible).not.toHaveBeenCalled()
})
it('should handle nodes without title buttons', () => {
// Node has no title buttons
expect(node.title_buttons).toHaveLength(0)
// Should draw without errors
expect(() => canvas.drawNode(node, ctx)).not.toThrow()
})
it('should position multiple buttons with correct spacing', () => {
const buttons = []
const drawSpies = []
// Add 3 buttons
for (let i = 0; i < 3; i++) {
const button = node.addTitleButton({
name: `button${i}`,
text: String(i),
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(15) // All same width for simplicity
const spy = vi.spyOn(button, 'draw')
buttons.push(button)
drawSpies.push(spy)
}
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Check positions are correctly spaced (right to left)
// Starting position: 200
const buttonY = -titleHeight + (titleHeight - 20) / 2 // Button height is 20 (default)
expect(drawSpies[0]).toHaveBeenCalledWith(ctx, 185, buttonY) // 200 - 15
expect(drawSpies[1]).toHaveBeenCalledWith(ctx, 168, buttonY) // 185 - 2 - 15
expect(drawSpies[2]).toHaveBeenCalledWith(ctx, 151, buttonY) // 168 - 2 - 15
})
it('should render buttons in low quality mode', () => {
const button = node.addTitleButton({
name: 'test',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
// Set low quality rendering
// @ts-expect-error TODO: Fix after merge - lowQualityRenderingRequired not defined in type
canvas.lowQualityRenderingRequired = true
canvas.drawNode(node, ctx)
// Buttons should still be rendered in low quality mode
const buttonY =
-LiteGraph.NODE_TITLE_HEIGHT + (LiteGraph.NODE_TITLE_HEIGHT - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 180, buttonY)
})
it('should handle buttons with different widths', () => {
const smallButton = node.addTitleButton({
name: 'small',
text: 'S',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
const largeButton = node.addTitleButton({
name: 'large',
text: 'LARGE',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
smallButton.getWidth = vi.fn().mockReturnValue(15)
largeButton.getWidth = vi.fn().mockReturnValue(50)
const drawSmall = vi.spyOn(smallButton, 'draw')
const drawLarge = vi.spyOn(largeButton, 'draw')
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Small button (rightmost): 200 - 15 = 185
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSmall).toHaveBeenCalledWith(ctx, 185, buttonY)
// Large button: 185 - 2 - 50 = 133
expect(drawLarge).toHaveBeenCalledWith(ctx, 133, buttonY)
})
})
describe('Integration with node properties', () => {
it('should respect node size for button positioning', () => {
node.size = [300, 150] // Wider node
const button = node.addTitleButton({
name: 'test',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not in LGraphButtonOptions
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
canvas.drawNode(node, ctx)
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
// Should use new width: 300 - 20 = 280
const buttonY = -titleHeight + (titleHeight - 20) / 2
expect(drawSpy).toHaveBeenCalledWith(ctx, 280, buttonY)
})
it('should NOT render buttons on collapsed nodes', () => {
node.flags.collapsed = true
const button = node.addTitleButton({
name: 'test',
text: 'C'
})
button.getWidth = vi.fn().mockReturnValue(20)
const drawSpy = vi.spyOn(button, 'draw')
canvas.drawNode(node, ctx)
// Title buttons should NOT be rendered on collapsed nodes
expect(drawSpy).not.toHaveBeenCalled()
expect(button.getWidth).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,12 +0,0 @@
import { describe, expect } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
describe('LGraphGroup', () => {
test('serializes to the existing format', () => {
const link = new LGraphGroup('title', 929)
expect(link.serialize()).toMatchSnapshot('Basic')
})
})

View File

@@ -1,131 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
describe('LGraphNode resize functionality', () => {
let node: LGraphNode
beforeEach(() => {
// Set up LiteGraph constants needed for measure
LiteGraph.NODE_TITLE_HEIGHT = 20
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [200, 150]
// Create a mock canvas context for updateArea
const mockCtx = {} as CanvasRenderingContext2D
// Call updateArea to populate boundingRect
node.updateArea(mockCtx)
})
describe('findResizeDirection', () => {
describe('corners', () => {
test('should detect NW (top-left) corner', () => {
// With title bar, top is at y=80 (100 - 20)
// Corner is from (100, 80) to (100 + 15, 80 + 15)
expect(node.findResizeDirection(100, 80)).toBe('NW')
expect(node.findResizeDirection(110, 90)).toBe('NW')
expect(node.findResizeDirection(114, 94)).toBe('NW')
})
test('should detect NE (top-right) corner', () => {
// Corner is from (300 - 15, 80) to (300, 80 + 15)
expect(node.findResizeDirection(285, 80)).toBe('NE')
expect(node.findResizeDirection(290, 90)).toBe('NE')
expect(node.findResizeDirection(299, 94)).toBe('NE')
})
test('should detect SW (bottom-left) corner', () => {
// Bottom is at y=250 (100 + 150)
// Corner is from (100, 250 - 15) to (100 + 15, 250)
expect(node.findResizeDirection(100, 235)).toBe('SW')
expect(node.findResizeDirection(110, 240)).toBe('SW')
expect(node.findResizeDirection(114, 249)).toBe('SW')
})
test('should detect SE (bottom-right) corner', () => {
// Corner is from (300 - 15, 250 - 15) to (300, 250)
expect(node.findResizeDirection(285, 235)).toBe('SE')
expect(node.findResizeDirection(290, 240)).toBe('SE')
expect(node.findResizeDirection(299, 249)).toBe('SE')
})
})
describe('priority', () => {
test('corners should have priority over edges', () => {
// These points are technically on both corner and edge
// Corner should win
expect(node.findResizeDirection(100, 84)).toBe('NW') // Not "W"
expect(node.findResizeDirection(104, 80)).toBe('NW') // Not "N"
})
})
describe('negative cases', () => {
test('should return undefined when outside node bounds', () => {
expect(node.findResizeDirection(50, 50)).toBeUndefined()
expect(node.findResizeDirection(350, 300)).toBeUndefined()
expect(node.findResizeDirection(99, 150)).toBeUndefined()
expect(node.findResizeDirection(301, 150)).toBeUndefined()
})
test('should return undefined when inside node but not on resize areas', () => {
// Center of node (accounting for title bar offset)
expect(node.findResizeDirection(200, 165)).toBeUndefined()
// Just inside the edge threshold
expect(node.findResizeDirection(106, 150)).toBeUndefined()
expect(node.findResizeDirection(294, 150)).toBeUndefined()
expect(node.findResizeDirection(150, 86)).toBeUndefined() // 80 + 6
expect(node.findResizeDirection(150, 244)).toBeUndefined()
})
test('should return undefined when node is not resizable', () => {
node.resizable = false
expect(node.findResizeDirection(100, 100)).toBeUndefined()
expect(node.findResizeDirection(300, 250)).toBeUndefined()
expect(node.findResizeDirection(150, 100)).toBeUndefined()
})
})
describe('edge cases', () => {
test('should handle nodes at origin', () => {
node.pos = [0, 0]
node.size = [100, 100]
// Update boundingRect with new position/size
const mockCtx = {} as CanvasRenderingContext2D
node.updateArea(mockCtx)
expect(node.findResizeDirection(0, -20)).toBe('NW') // Account for title bar
expect(node.findResizeDirection(99, 99)).toBe('SE') // Bottom-right corner (100-1, 100-1)
})
test('should handle very small nodes', () => {
node.size = [20, 20] // Smaller than corner size
// Update boundingRect with new size
const mockCtx = {} as CanvasRenderingContext2D
node.updateArea(mockCtx)
// Corners still work (accounting for title bar offset)
expect(node.findResizeDirection(100, 80)).toBe('NW')
expect(node.findResizeDirection(119, 119)).toBe('SE')
})
})
})
describe('resizeEdgeSize static property', () => {
test('should have default value of 5', () => {
expect(LGraphNode.resizeEdgeSize).toBe(5)
})
})
describe('resizeHandleSize static property', () => {
test('should have default value of 15', () => {
expect(LGraphNode.resizeHandleSize).toBe(15)
})
})
})

View File

@@ -1,664 +0,0 @@
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
import type {
INodeInputSlot,
Point,
ISerialisedNode
} from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
LGraph,
NodeInputSlot,
NodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
function getMockISerialisedNode(
data: Partial<ISerialisedNode>
): ISerialisedNode {
return Object.assign(
{
id: 0,
flags: {},
type: 'TestNode',
pos: [100, 100],
size: [100, 100],
order: 0,
mode: 0
},
data
)
}
describe('LGraphNode', () => {
let node: LGraphNode
let origLiteGraph: typeof LiteGraph
beforeEach(() => {
origLiteGraph = Object.assign({}, LiteGraph)
// @ts-expect-error TODO: Fix after merge - Classes property not in type
delete origLiteGraph.Classes
Object.assign(LiteGraph, {
NODE_TITLE_HEIGHT: 20,
NODE_SLOT_HEIGHT: 15,
NODE_TEXT_SIZE: 14,
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
DEFAULT_GROUP_FONT_SIZE: 24,
isValidConnection: vi.fn().mockReturnValue(true)
})
node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [150, 100] // Example size
// Reset mocks if needed
vi.clearAllMocks()
})
afterEach(() => {
Object.assign(LiteGraph, origLiteGraph)
})
test('should serialize position/size correctly', () => {
const node = new LGraphNode('TestNode')
node.pos = [10, 20]
node.size = [30, 40]
const json = node.serialize()
expect(json.pos).toEqual([10, 20])
expect(json.size).toEqual([30, 40])
const configureData: ISerialisedNode = {
id: node.id,
type: node.type,
pos: [50, 60],
size: [70, 80],
flags: {},
order: node.order,
mode: node.mode,
inputs: node.inputs?.map((i) => ({
name: i.name,
type: i.type,
link: i.link
})),
outputs: node.outputs?.map((o) => ({
name: o.name,
type: o.type,
links: o.links,
slot_index: o.slot_index
}))
}
node.configure(configureData)
expect(node.pos).toEqual(new Float64Array([50, 60]))
expect(node.size).toEqual(new Float64Array([70, 80]))
})
test('should configure inputs correctly', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
inputs: [{ name: 'TestInput', type: 'number', link: null }]
})
)
expect(node.inputs.length).toEqual(1)
expect(node.inputs[0].name).toEqual('TestInput')
expect(node.inputs[0].link).toEqual(null)
expect(node.inputs[0]).instanceOf(NodeInputSlot)
// Should not override existing inputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
expect(node.inputs.length).toEqual(1)
})
test('should configure outputs correctly', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
outputs: [{ name: 'TestOutput', type: 'number', links: [] }]
})
)
expect(node.outputs.length).toEqual(1)
expect(node.outputs[0].name).toEqual('TestOutput')
expect(node.outputs[0].type).toEqual('number')
expect(node.outputs[0].links).toEqual([])
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
// Should not override existing outputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
expect(node.outputs.length).toEqual(1)
})
describe('Disconnect I/O Slots', () => {
test('should disconnect input correctly', () => {
const node1 = new LGraphNode('SourceNode')
const node2 = new LGraphNode('TargetNode')
// Configure nodes with input/output slots
node1.configure(
getMockISerialisedNode({
id: 1,
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
node2.configure(
getMockISerialisedNode({
id: 2,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
// Create a graph and add nodes to it
const graph = new LGraph()
graph.add(node1)
graph.add(node2)
// Connect the nodes
const link = node1.connect(0, node2, 0)
expect(link).not.toBeNull()
expect(node2.inputs[0].link).toBe(link?.id)
expect(node1.outputs[0].links).toContain(link?.id)
// Test disconnecting by slot number
const disconnected = node2.disconnectInput(0)
expect(disconnected).toBe(true)
expect(node2.inputs[0].link).toBeNull()
expect(node1.outputs[0].links?.length).toBe(0)
expect(graph._links.has(link?.id ?? -1)).toBe(false)
// Test disconnecting by slot name
node1.connect(0, node2, 0)
const disconnectedByName = node2.disconnectInput('Input1')
expect(disconnectedByName).toBe(true)
expect(node2.inputs[0].link).toBeNull()
// Test disconnecting non-existent slot
const invalidDisconnect = node2.disconnectInput(999)
expect(invalidDisconnect).toBe(false)
// Test disconnecting already disconnected input
const alreadyDisconnected = node2.disconnectInput(0)
expect(alreadyDisconnected).toBe(true)
})
test('should disconnect output correctly', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode1 = new LGraphNode('TargetNode1')
const targetNode2 = new LGraphNode('TargetNode2')
// Configure nodes with input/output slots
sourceNode.configure(
getMockISerialisedNode({
id: 1,
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'number', links: [] }
]
})
)
targetNode1.configure(
getMockISerialisedNode({
id: 2,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
targetNode2.configure(
getMockISerialisedNode({
id: 3,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
// Create a graph and add nodes to it
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode1)
graph.add(targetNode2)
// Connect multiple nodes to the same output
const link1 = sourceNode.connect(0, targetNode1, 0)
const link2 = sourceNode.connect(0, targetNode2, 0)
expect(link1).not.toBeNull()
expect(link2).not.toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(2)
// Test disconnecting specific target node
const disconnectedSpecific = sourceNode.disconnectOutput(0, targetNode1)
expect(disconnectedSpecific).toBe(true)
expect(targetNode1.inputs[0].link).toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(1)
expect(graph._links.has(link1?.id ?? -1)).toBe(false)
expect(graph._links.has(link2?.id ?? -1)).toBe(true)
// Test disconnecting by slot name
const link3 = sourceNode.connect(1, targetNode1, 0)
expect(link3).not.toBeNull()
const disconnectedByName = sourceNode.disconnectOutput(
'Output2',
targetNode1
)
expect(disconnectedByName).toBe(true)
expect(targetNode1.inputs[0].link).toBeNull()
expect(sourceNode.outputs[1].links?.length).toBe(0)
// Test disconnecting all connections from an output
const link4 = sourceNode.connect(0, targetNode1, 0)
expect(link4).not.toBeNull()
expect(sourceNode.outputs[0].links?.length).toBe(2)
const disconnectedAll = sourceNode.disconnectOutput(0)
expect(disconnectedAll).toBe(true)
expect(sourceNode.outputs[0].links).toBeNull()
expect(targetNode1.inputs[0].link).toBeNull()
expect(targetNode2.inputs[0].link).toBeNull()
expect(graph._links.has(link2?.id ?? -1)).toBe(false)
expect(graph._links.has(link4?.id ?? -1)).toBe(false)
// Test disconnecting non-existent slot
const invalidDisconnect = sourceNode.disconnectOutput(999)
expect(invalidDisconnect).toBe(false)
// Test disconnecting already disconnected output
const alreadyDisconnected = sourceNode.disconnectOutput(0)
expect(alreadyDisconnected).toBe(false)
})
})
describe('Applies correct link type on connection', () => {
it.for<[string, string, string]>([
['IMAGE', 'IMAGE', 'IMAGE'],
['*', 'IMAGE', 'IMAGE'],
['IMAGE', '*', 'IMAGE'],
['*', '*', '*'],
['IMAGE,MASK', 'IMAGE,LATENT', 'IMAGE'],
//An invalid connection should use input type
['Mask', 'IMAGE', 'IMAGE']
])(
'Link from %s to %s should have type %s',
([output, input, expected]) => {
const target = new LGraphNode('target')
const source = new LGraphNode('source')
const graph = new LGraph()
target.addInput('input', input)
source.addOutput('output', output)
graph.add(source)
graph.add(target)
const link = source.connect(0, target, 0)
expect(link?.type).toBe(expected)
}
)
})
describe('getInputPos and getOutputPos', () => {
test('should handle collapsed nodes correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float64Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 100
node.boundingRect[3] = 100
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Collapse the node
node.flags.collapsed = true
// Get positions in collapsed state
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
expect(inputPos).toEqual([100, 90])
expect(outputPos).toEqual([180, 90])
})
test('should return correct positions for input and output slots', () => {
const node = new LGraphNode('TestNode')
node.pos = [100, 100]
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
expect(inputPos).toEqual([107.5, 110.5])
expect(outputPos).toEqual([193.5, 110.5])
})
})
describe('getSlotOnPos', () => {
test('should return undefined when point is outside node bounds', () => {
const node = new LGraphNode('TestNode')
node.pos = [100, 100]
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Test point far outside node bounds
expect(node.getSlotOnPos([0, 0])).toBeUndefined()
// Test point just outside node bounds
expect(node.getSlotOnPos([99, 99])).toBeUndefined()
})
test('should detect input slots correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float64Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [
{ name: 'Input1', type: 'number', link: null },
{ name: 'Input2', type: 'string', link: null }
]
})
)
// Get position of first input slot
const inputPos = node.getInputPos(0)
// Test point directly on input slot
const slot = node.getSlotOnPos(inputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Input1')
// Test point near but not on input slot
expect(node.getSlotOnPos([inputPos[0] - 15, inputPos[1]])).toBeUndefined()
})
test('should detect output slots correctly', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float64Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'string', links: [] }
]
})
)
// Get position of first output slot
const outputPos = node.getOutputPos(0)
// Test point directly on output slot
const slot = node.getSlotOnPos(outputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Output1')
// Test point near but not on output slot
const gotslot = node.getSlotOnPos([outputPos[0] + 30, outputPos[1]])
expect(gotslot).toBeUndefined()
})
test('should prioritize input slots over output slots', () => {
const node = new LGraphNode('TestNode') as unknown as Omit<
LGraphNode,
'boundingRect'
> & { boundingRect: Float64Array }
node.pos = [100, 100]
node.size = [100, 100]
node.boundingRect[0] = 100
node.boundingRect[1] = 100
node.boundingRect[2] = 200
node.boundingRect[3] = 200
node.configure(
getMockISerialisedNode({
id: 1,
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
// Get positions of first input and output slots
const inputPos = node.getInputPos(0)
// Test point that could theoretically hit both slots
// Should return the input slot due to priority
const slot = node.getSlotOnPos(inputPos)
expect(slot).toBeDefined()
expect(slot?.name).toBe('Input1')
})
})
describe('LGraphNode slot positioning', () => {
test('should correctly position slots with absolute coordinates', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add input/output with absolute positions
node.addInput('abs-input', 'number')
node.inputs[0].pos = [10, 20]
node.addOutput('abs-output', 'number')
node.outputs[0].pos = [50, 30]
// Test
const inputPos = node.getInputPos(0)
const outputPos = node.getOutputPos(0)
// Absolute positions should be relative to node position
expect(inputPos).toEqual([110, 120]) // node.pos + slot.pos
expect(outputPos).toEqual([150, 130]) // node.pos + slot.pos
})
test('should correctly position default vertical slots', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add multiple inputs/outputs without absolute positions
node.addInput('input1', 'number')
node.addInput('input2', 'number')
node.addOutput('output1', 'number')
node.addOutput('output2', 'number')
// Calculate expected positions
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
const nodeWidth = node.size[0]
// Test input positions
expect(node.getInputPos(0)).toEqual([
100 + slotOffset,
100 + (0 + 0.7) * slotSpacing
])
expect(node.getInputPos(1)).toEqual([
100 + slotOffset,
100 + (1 + 0.7) * slotSpacing
])
// Test output positions
expect(node.getOutputPos(0)).toEqual([
100 + nodeWidth + 1 - slotOffset,
100 + (0 + 0.7) * slotSpacing
])
expect(node.getOutputPos(1)).toEqual([
100 + nodeWidth + 1 - slotOffset,
100 + (1 + 0.7) * slotSpacing
])
})
test('should skip absolute positioned slots when calculating vertical positions', () => {
// Setup
const node = new LGraphNode('test')
node.pos = [100, 100]
// Add mix of absolute and default positioned slots
node.addInput('abs-input', 'number')
node.inputs[0].pos = [10, 20]
node.addInput('default-input1', 'number')
node.addInput('default-input2', 'number')
const slotOffset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const slotSpacing = LiteGraph.NODE_SLOT_HEIGHT
// Test: default positioned slots should be consecutive, ignoring absolute positioned ones
expect(node.getInputPos(1)).toEqual([
100 + slotOffset,
100 + (0 + 0.7) * slotSpacing // First default slot starts at index 0
])
expect(node.getInputPos(2)).toEqual([
100 + slotOffset,
100 + (1 + 0.7) * slotSpacing // Second default slot at index 1
])
})
})
describe('widget serialization', () => {
test('should only serialize widgets with serialize flag not set to false', () => {
const node = new LGraphNode('TestNode')
node.serialize_widgets = true
// Add widgets with different serialization settings
node.addWidget('number', 'serializable1', 1, null)
node.addWidget('number', 'serializable2', 2, null)
node.addWidget('number', 'non-serializable', 3, null)
expect(node.widgets?.length).toBe(3)
// Set serialize flag to false for the last widget
node.widgets![2].serialize = false
// Set some widget values
node.widgets![0].value = 10
node.widgets![1].value = 20
node.widgets![2].value = 30
// Serialize the node
const serialized = node.serialize()
// Check that only serializable widgets' values are included
expect(serialized.widgets_values).toEqual([10, 20])
expect(serialized.widgets_values).toHaveLength(2)
})
test('should only configure widgets with serialize flag not set to false', () => {
const node = new LGraphNode('TestNode')
node.serialize_widgets = true
node.addWidget('number', 'non-serializable', 1, null)
node.addWidget('number', 'serializable1', 2, null)
expect(node.widgets?.length).toBe(2)
node.widgets![0].serialize = false
node.configure(
getMockISerialisedNode({
id: 1,
type: 'TestNode',
pos: [100, 100],
size: [100, 100],
properties: {},
widgets_values: [100]
})
)
expect(node.widgets![0].value).toBe(1)
expect(node.widgets![1].value).toBe(100)
})
})
describe('getInputSlotPos', () => {
let inputSlot: INodeInputSlot
beforeEach(() => {
inputSlot = {
name: 'test_in',
type: 'string',
link: null,
boundingRect: [0, 0, 0, 0]
}
})
test('should return position based on title height when collapsed', () => {
node.flags.collapsed = true
const expectedPos: Point = [100, 200 - LiteGraph.NODE_TITLE_HEIGHT * 0.5]
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
})
test('should return position based on input.pos when defined and not collapsed', () => {
node.flags.collapsed = false
inputSlot.pos = [10, 50]
node.inputs = [inputSlot]
const expectedPos: Point = [100 + 10, 200 + 50]
expect(node.getInputSlotPos(inputSlot)).toEqual(expectedPos)
})
test('should return default vertical position when input.pos is undefined and not collapsed', () => {
node.flags.collapsed = false
const inputSlot2: INodeInputSlot = {
name: 'test_in_2',
type: 'number',
link: null,
boundingRect: [0, 0, 0, 0]
}
node.inputs = [inputSlot, inputSlot2]
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
const slotIndex2 = 1
const expectedY2 =
200 + (slotIndex2 + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
expect(node.getInputSlotPos(inputSlot2)).toEqual([expectedX, expectedY2])
})
test('should return default vertical position including slot_start_y when defined', () => {
;(node.constructor as any).slot_start_y = 25
node.flags.collapsed = false
node.inputs = [inputSlot]
const slotIndex = 0
const nodeOffsetY = 25
const expectedY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
delete (node.constructor as any).slot_start_y
})
test('should not overwrite onMouseDown prototype', () => {
expect(Object.prototype.hasOwnProperty.call(node, 'onMouseDown')).toEqual(
false
)
})
})
})

View File

@@ -1,319 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
describe('LGraphNode Title Buttons', () => {
describe('addTitleButton', () => {
it('should add a title button to the node', () => {
const node = new LGraphNode('Test Node')
const button = node.addTitleButton({
name: 'test_button',
text: 'X',
fgColor: '#FF0000'
})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe('test_button')
expect(button.text).toBe('X')
expect(button.fgColor).toBe('#FF0000')
expect(node.title_buttons).toHaveLength(1)
expect(node.title_buttons[0]).toBe(button)
})
it('should add multiple title buttons', () => {
const node = new LGraphNode('Test Node')
const button1 = node.addTitleButton({ name: 'button1', text: 'A' })
const button2 = node.addTitleButton({ name: 'button2', text: 'B' })
const button3 = node.addTitleButton({ name: 'button3', text: 'C' })
expect(node.title_buttons).toHaveLength(3)
expect(node.title_buttons[0]).toBe(button1)
expect(node.title_buttons[1]).toBe(button2)
expect(node.title_buttons[2]).toBe(button3)
})
it('should create buttons with default options', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - addTitleButton type issues
const button = node.addTitleButton({})
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBeUndefined()
expect(node.title_buttons).toHaveLength(1)
})
})
describe('title button handling via canvas', () => {
it('should handle click on title button through canvas processClick', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: 'close_button',
text: 'X',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
// Mock button methods
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
button.isPointInside = vi.fn().mockReturnValue(true)
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Mock the node's onTitleButtonClick method to verify it gets called
const onTitleButtonClickSpy = vi.spyOn(node, 'onTitleButtonClick')
// Calculate node-relative position for the click
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 165
178 - node.pos[1] // -22
]
// Test the title button logic that's now in the canvas
// This simulates what happens in LGraphCanvas.processMouseDown
if (node.title_buttons?.length && !node.flags.collapsed) {
const nodeRelativeX = clickPosRelativeToNode[0]
const nodeRelativeY = clickPosRelativeToNode[1]
for (let i = 0; i < node.title_buttons.length; i++) {
const btn = node.title_buttons[i]
if (btn.visible && btn.isPointInside(nodeRelativeX, nodeRelativeY)) {
node.onTitleButtonClick(btn, canvas)
break
}
}
}
expect(button.isPointInside).toHaveBeenCalledWith(165, -22)
expect(onTitleButtonClickSpy).toHaveBeenCalledWith(button, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button
}
)
})
it('should not handle click outside title buttons', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button = node.addTitleButton({
name: 'test_button',
text: 'T',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
button.getWidth = vi.fn().mockReturnValue(20)
button.height = 16
button.isPointInside = vi.fn().mockReturnValue(false)
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
150 - node.pos[0], // 50
180 - node.pos[1] // -20
]
// Mock the node's onTitleButtonClick method to ensure it doesn't get called
const onTitleButtonClickSpy = vi.spyOn(node, 'onTitleButtonClick')
// Test the title button logic that's now in the canvas
let buttonClicked = false
if (node.title_buttons?.length && !node.flags.collapsed) {
const nodeRelativeX = clickPosRelativeToNode[0]
const nodeRelativeY = clickPosRelativeToNode[1]
for (let i = 0; i < node.title_buttons.length; i++) {
const btn = node.title_buttons[i]
if (btn.visible && btn.isPointInside(nodeRelativeX, nodeRelativeY)) {
node.onTitleButtonClick(btn, canvas)
buttonClicked = true
break
}
}
}
expect(button.isPointInside).toHaveBeenCalledWith(50, -20)
expect(onTitleButtonClickSpy).not.toHaveBeenCalled()
expect(canvas.dispatch).not.toHaveBeenCalled()
expect(buttonClicked).toBe(false)
})
it('should handle multiple buttons correctly', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [200, 60]
const button1 = node.addTitleButton({
name: 'button1',
text: 'A',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
const button2 = node.addTitleButton({
name: 'button2',
text: 'B',
// @ts-expect-error TODO: Fix after merge - visible property not defined in type
visible: true
})
// Mock button methods
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
button1.isPointInside = vi.fn().mockReturnValue(false)
button2.isPointInside = vi.fn().mockReturnValue(true)
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Mock the node's onTitleButtonClick method
const onTitleButtonClickSpy = vi.spyOn(node, 'onTitleButtonClick')
// Click on second button
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
255 - node.pos[0], // 155
titleY - node.pos[1] // -22
]
// Test the title button logic that's now in the canvas
if (node.title_buttons?.length && !node.flags.collapsed) {
const nodeRelativeX = clickPosRelativeToNode[0]
const nodeRelativeY = clickPosRelativeToNode[1]
for (let i = 0; i < node.title_buttons.length; i++) {
const btn = node.title_buttons[i]
if (btn.visible && btn.isPointInside(nodeRelativeX, nodeRelativeY)) {
node.onTitleButtonClick(btn, canvas)
break
}
}
}
expect(button1.isPointInside).toHaveBeenCalledWith(155, -22)
expect(button2.isPointInside).toHaveBeenCalledWith(155, -22)
expect(onTitleButtonClickSpy).toHaveBeenCalledWith(button2, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button2
}
)
})
it('should skip invisible buttons', () => {
const node = new LGraphNode('Test Node')
node.pos = [100, 200]
node.size = [180, 60]
const button1 = node.addTitleButton({
name: 'invisible_button',
text: '' // Empty text makes it invisible
})
const button2 = node.addTitleButton({
name: 'visible_button',
text: 'V'
})
button1.getWidth = vi.fn().mockReturnValue(20)
button2.getWidth = vi.fn().mockReturnValue(20)
button1.height = button2.height = 16
// Set visibility - button1 is invisible (empty text), button2 is visible
button1.isPointInside = vi.fn().mockReturnValue(true) // Would be clicked if visible
button2.isPointInside = vi.fn().mockReturnValue(true)
const canvas = {
ctx: {} as CanvasRenderingContext2D,
dispatch: vi.fn()
} as unknown as LGraphCanvas
// Mock the node's onTitleButtonClick method
const onTitleButtonClickSpy = vi.spyOn(node, 'onTitleButtonClick')
// Click where both buttons would be positioned
const titleY = 178 // node.pos[1] - NODE_TITLE_HEIGHT + 8 = 200 - 30 + 8 = 178
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
265 - node.pos[0], // 165
titleY - node.pos[1] // -22
]
// Test the title button logic that's now in the canvas
if (node.title_buttons?.length && !node.flags.collapsed) {
const nodeRelativeX = clickPosRelativeToNode[0]
const nodeRelativeY = clickPosRelativeToNode[1]
for (let i = 0; i < node.title_buttons.length; i++) {
const btn = node.title_buttons[i]
// Only visible buttons are processed
if (btn.visible && btn.isPointInside(nodeRelativeX, nodeRelativeY)) {
node.onTitleButtonClick(btn, canvas)
break
}
}
}
// button1 should not be checked because it's not visible
expect(button1.isPointInside).not.toHaveBeenCalled()
// button2 should be checked and clicked because it's visible
expect(button2.isPointInside).toHaveBeenCalledWith(165, -22)
expect(onTitleButtonClickSpy).toHaveBeenCalledWith(button2, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button2 // Should click visible button, not invisible
}
)
})
})
describe('onTitleButtonClick', () => {
it('should dispatch litegraph:node-title-button-clicked event', () => {
const node = new LGraphNode('Test Node')
// @ts-expect-error TODO: Fix after merge - LGraphButton constructor type issues
const button = new LGraphButton({ name: 'test_button' })
const canvas = {
dispatch: vi.fn()
} as unknown as LGraphCanvas
node.onTitleButtonClick(button, canvas)
expect(canvas.dispatch).toHaveBeenCalledWith(
'litegraph:node-title-button-clicked',
{
node: node,
button: button
}
)
})
})
})

View File

@@ -1,162 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
let node: LGraphNode
beforeEach(() => {
node = new LGraphNode('TestNode')
})
describe('configure with widgets_values', () => {
it('should apply widget values in correct order when widgets order matches input_order', () => {
// Create node with widgets
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
// Configure with widget values
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
node.configure(info)
// Check widget values are applied correctly
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe(12345) // seed
expect(node.widgets![2].value).toBe('test prompt') // prompt
})
it('should handle mismatched widget order with input_order', () => {
// Simulate widgets created in wrong order (e.g., from unordered Object.entries)
// but widgets_values is in the correct order according to input_order
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
node.addWidget('number', 'steps', 20, null, {})
// Widget values are in input_order: [steps, seed, prompt]
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt']
}
// This would apply values incorrectly without proper ordering
node.configure(info)
// Without fix, values would be applied in wrong order:
// seed (widget[0]) would get 30 (should be 12345)
// prompt (widget[1]) would get 12345 (should be 'test prompt')
// steps (widget[2]) would get 'test prompt' (should be 30)
// This test demonstrates the bug - values are applied in wrong order
expect(node.widgets![0].value).toBe(30) // seed gets steps value (WRONG)
expect(node.widgets![1].value).toBe(12345) // prompt gets seed value (WRONG)
expect(node.widgets![2].value).toBe('test prompt') // steps gets prompt value (WRONG)
})
it('should skip widgets with serialize: false', () => {
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('button', 'action', 'Click', null, {})
node.widgets![1].serialize = false // button should not serialize
node.addWidget('number', 'seed', 0, null, {})
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345] // Only serializable widgets
}
node.configure(info)
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe('Click') // button unchanged
expect(node.widgets![2].value).toBe(12345) // seed
})
})
})
describe('sortWidgetValuesByInputOrder', () => {
it('should reorder widget values based on input_order', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should reorder to match input_order: [steps, seed, prompt]
expect(reordered).toEqual([30, 12345, 'test prompt'])
})
it('should handle widgets not in input_order', () => {
const inputOrder = ['steps', 'seed']
const currentWidgetOrder = ['seed', 'prompt', 'steps', 'cfg']
const widgetValues = [12345, 'test prompt', 30, 7.5]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should put ordered items first, then unordered
expect(reordered).toEqual([30, 12345, 'test prompt', 7.5])
})
it('should handle empty input_order', () => {
const inputOrder: string[] = []
const currentWidgetOrder = ['seed', 'prompt', 'steps']
const widgetValues = [12345, 'test prompt', 30]
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should return values unchanged
expect(reordered).toEqual([12345, 'test prompt', 30])
})
it('should handle mismatched array lengths', () => {
const inputOrder = ['steps', 'seed', 'prompt']
const currentWidgetOrder = ['seed', 'prompt']
const widgetValues = [12345, 'test prompt', 30] // Extra value
const reordered = sortWidgetValuesByInputOrder(
widgetValues,
currentWidgetOrder,
inputOrder
)
// Should handle gracefully, keeping extra values at the end
// Since 'steps' is not in currentWidgetOrder, it won't be reordered
// Only 'seed' and 'prompt' will be reordered based on input_order
expect(reordered).toEqual([12345, 'test prompt', 30])
})
})

View File

@@ -1,152 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
describe('LGraphNodeProperties', () => {
let mockNode: any
let mockGraph: any
beforeEach(() => {
mockGraph = {
trigger: vi.fn()
}
mockNode = {
id: 123,
title: 'Test Node',
flags: {},
graph: mockGraph
}
})
describe('property tracking', () => {
it('should track changes to existing properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'New Title'
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'title',
oldValue: 'Test Node',
newValue: 'New Title'
})
})
it('should track changes to nested properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'flags.collapsed',
oldValue: undefined,
newValue: true
})
})
it('should emit event when value is set to the same value', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'Test Node' // Same value as original
expect(mockGraph.trigger).toHaveBeenCalledTimes(1)
})
it('should not emit events when node has no graph', () => {
mockNode.graph = null
new LGraphNodeProperties(mockNode)
// Should not throw
expect(() => {
mockNode.title = 'New Title'
}).not.toThrow()
})
})
describe('isTracked', () => {
it('should correctly identify tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
expect(propManager.isTracked('title')).toBe(true)
expect(propManager.isTracked('flags.collapsed')).toBe(true)
expect(propManager.isTracked('untracked')).toBe(false)
})
})
describe('serialization behavior', () => {
it('should not make non-existent properties enumerable', () => {
new LGraphNodeProperties(mockNode)
// flags.collapsed doesn't exist initially
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should make properties enumerable when set to non-default values', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(true)
})
it('should make properties non-enumerable when set back to undefined', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
mockNode.flags.collapsed = undefined
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should keep existing properties enumerable', () => {
// title exists initially
const initialDescriptor = Object.getOwnPropertyDescriptor(
mockNode,
'title'
)
expect(initialDescriptor?.enumerable).toBe(true)
new LGraphNodeProperties(mockNode)
const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
expect(afterDescriptor?.enumerable).toBe(true)
})
it('should only include non-undefined values in JSON.stringify', () => {
new LGraphNodeProperties(mockNode)
// Initially, flags.collapsed shouldn't appear
let json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
// After setting to true, it should appear
mockNode.flags.collapsed = true
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(true)
// After setting to false, it should still appear (false is not undefined)
mockNode.flags.collapsed = false
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(false)
// After setting back to undefined, it should disappear
mockNode.flags.collapsed = undefined
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
})
})
})

View File

@@ -1,19 +0,0 @@
// TODO: Fix these tests after migration
import { describe } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import { dirtyTest } from './fixtures/testExtensions'
describe.skip('LGraph (constructor only)', () => {
dirtyTest(
'Matches previous snapshot',
({ expect, minimalSerialisableGraph, basicSerialisableGraph }) => {
const minLGraph = new LGraph(minimalSerialisableGraph)
expect(minLGraph).toMatchSnapshot('minLGraph')
const basicLGraph = new LGraph(basicSerialisableGraph)
expect(basicLGraph).toMatchSnapshot('basicLGraph')
}
)
})

View File

@@ -1,17 +0,0 @@
import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
describe('LLink', () => {
test('matches previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
test('serializes to the previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,326 +0,0 @@
import { test as baseTest, describe, expect, vi } from 'vitest'
import type {
MovingInputLink,
RerouteId,
LinkNetwork,
ISlotType
} from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LLink,
Reroute,
LinkConnector,
ToInputRenderLink,
LinkDirection
} from '@/lib/litegraph/src/litegraph'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
interface TestContext {
network: LinkNetwork & { add(node: LGraphNode): void }
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
createTestNode: (id: number, slotType?: ISlotType) => LGraphNode
createTestLink: (
id: number,
sourceId: number,
targetId: number,
slotType?: ISlotType
) => LLink
}
const test = baseTest.extend<TestContext>({
network: async ({}, use) => {
const graph = new LGraph()
const floatingLinks = new Map<number, LLink>()
const reroutes = new Map<number, Reroute>()
await use({
links: new Map<number, LLink>(),
reroutes,
floatingLinks,
getLink: graph.getLink.bind(graph),
getNodeById: (id: number) => graph.getNodeById(id),
addFloatingLink: (link: LLink) => {
floatingLinks.set(link.id, link)
return link
},
removeFloatingLink: (link: LLink) => floatingLinks.delete(link.id),
getReroute: ((id: RerouteId | null | undefined) =>
id == null ? undefined : reroutes.get(id)) as LinkNetwork['getReroute'],
removeReroute: (id: number) => reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node)
})
},
setConnectingLinks: async (
{},
use: (mock: (value: ConnectingLink[]) => void) => Promise<void>
) => {
const mock = vi.fn()
await use(mock)
},
connector: async ({ setConnectingLinks }, use) => {
const connector = new LinkConnector(setConnectingLinks)
await use(connector)
},
createTestNode: async ({ network }, use) => {
await use((id: number): LGraphNode => {
const node = new LGraphNode('test')
node.id = id
network.add(node)
return node
})
},
createTestLink: async ({ network }, use) => {
await use(
(
id: number,
sourceId: number,
targetId: number,
slotType: ISlotType = 'number'
): LLink => {
const link = new LLink(id, slotType, sourceId, 0, targetId, 0)
network.links.set(link.id, link)
return link
}
)
}
})
describe('LinkConnector', () => {
test('should initialize with default state', ({ connector }) => {
expect(connector.state).toEqual({
connectingTo: undefined,
multi: false,
draggingExistingLinks: false
})
expect(connector.renderLinks).toEqual([])
expect(connector.inputLinks).toEqual([])
expect(connector.outputLinks).toEqual([])
expect(connector.hiddenReroutes.size).toBe(0)
})
describe('Moving Input Links', () => {
test('should handle moving input links', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const targetNode = createTestNode(2)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
targetNode.inputs[0].link = link.id
connector.moveInputLink(network, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.inputLinks).toContain(link)
expect(link._dragging).toBe(true)
})
test('should not move input link if already connecting', ({
connector,
network
}) => {
connector.state.connectingTo = 'input'
expect(() => {
connector.moveInputLink(network, { link: 1 } as any)
}).toThrow('Already dragging links.')
})
})
describe('Moving Output Links', () => {
test('should handle moving output links', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const targetNode = createTestNode(2)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
sourceNode.outputs[0].links = [link.id]
connector.moveOutputLink(network, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.state.multi).toBe(true)
expect(connector.outputLinks).toContain(link)
expect(link._dragging).toBe(true)
})
test('should not move output link if already connecting', ({
connector,
network
}) => {
connector.state.connectingTo = 'output'
expect(() => {
connector.moveOutputLink(network, { links: [1] } as any)
}).toThrow('Already dragging links.')
})
})
describe('Dragging New Links', () => {
test('should handle dragging new link from output', ({
network,
connector,
createTestNode
}) => {
const sourceNode = createTestNode(1)
const slotType: ISlotType = 'number'
sourceNode.addOutput('out', slotType)
connector.dragNewFromOutput(network, sourceNode, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.renderLinks.length).toBe(1)
expect(connector.state.draggingExistingLinks).toBe(false)
})
test('should handle dragging new link from input', ({
network,
connector,
createTestNode
}) => {
const targetNode = createTestNode(1)
const slotType: ISlotType = 'number'
targetNode.addInput('in', slotType)
connector.dragNewFromInput(network, targetNode, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.renderLinks.length).toBe(1)
expect(connector.state.draggingExistingLinks).toBe(false)
})
})
describe('Dragging from reroutes', () => {
test('should handle dragging from reroutes', ({
network,
connector,
createTestNode,
createTestLink
}) => {
const originNode = createTestNode(1)
const targetNode = createTestNode(2)
const output = originNode.addOutput('out', 'number')
targetNode.addInput('in', 'number')
const link = createTestLink(1, 1, 2)
const reroute = new Reroute(1, network, [0, 0], undefined, [link.id])
network.reroutes.set(reroute.id, reroute)
link.parentId = reroute.id
connector.dragFromReroute(network, reroute)
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(false)
expect(connector.renderLinks.length).toBe(1)
const renderLink = connector.renderLinks[0]
expect(renderLink instanceof ToInputRenderLink).toBe(true)
expect(renderLink.toType).toEqual('input')
expect(renderLink.node).toEqual(originNode)
expect(renderLink.fromSlot).toEqual(output)
expect(renderLink.fromReroute).toEqual(reroute)
expect(renderLink.fromDirection).toEqual(LinkDirection.NONE)
expect(renderLink.network).toEqual(network)
})
})
describe('Reset', () => {
test('should reset state and clear links', ({ network, connector }) => {
connector.state.connectingTo = 'input'
connector.state.multi = true
connector.state.draggingExistingLinks = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
link._dragging = true
connector.inputLinks.push(link)
const reroute = new Reroute(1, network)
reroute.pos = [0, 0]
reroute._dragging = true
connector.hiddenReroutes.add(reroute)
connector.reset()
expect(connector.state).toEqual({
connectingTo: undefined,
multi: false,
draggingExistingLinks: false
})
expect(connector.renderLinks).toEqual([])
expect(connector.inputLinks).toEqual([])
expect(connector.outputLinks).toEqual([])
expect(connector.hiddenReroutes.size).toBe(0)
expect(link._dragging).toBeUndefined()
expect(reroute._dragging).toBeUndefined()
})
})
describe('Event Handling', () => {
test('should handle event listeners until reset', ({
connector,
createTestNode
}) => {
const listener = vi.fn()
connector.listenUntilReset('input-moved', listener)
const sourceNode = createTestNode(1)
const mockRenderLink = {
node: sourceNode,
fromSlot: { name: 'out', type: 'number' },
fromPos: [0, 0],
fromDirection: LinkDirection.RIGHT,
toType: 'input',
link: new LLink(1, 'number', 1, 0, 2, 0)
} as MovingInputLink
connector.events.dispatch('input-moved', mockRenderLink)
expect(listener).toHaveBeenCalled()
connector.reset()
connector.events.dispatch('input-moved', mockRenderLink)
expect(listener).toHaveBeenCalledTimes(1)
})
})
describe('Export', () => {
test('should export current state', ({ network, connector }) => {
connector.state.connectingTo = 'input'
connector.state.multi = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
connector.inputLinks.push(link)
const exported = connector.export(network)
expect(exported.state).toEqual(connector.state)
expect(exported.inputLinks).toEqual(connector.inputLinks)
expect(exported.outputLinks).toEqual(connector.outputLinks)
expect(exported.renderLinks).toEqual(connector.renderLinks)
expect(exported.network).toBe(network)
})
})
})

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import {
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/litegraph'
describe('NodeSlot', () => {
describe('inputAsSerialisable', () => {
it('removes _data from serialized slot', () => {
// @ts-expect-error Missing boundingRect property for test
const slot: INodeOutputSlot = {
_data: 'test data',
name: 'test-id',
type: 'STRING',
links: []
}
// @ts-expect-error Argument type mismatch for test
const serialized = outputAsSerialisable(slot)
expect(serialized).not.toHaveProperty('_data')
})
it('removes pos from widget input slots', () => {
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
pos: [10, 20],
type: 'STRING',
link: null,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
}
}
const serialized = inputAsSerialisable(widgetInputSlot)
expect(serialized).not.toHaveProperty('pos')
})
it('preserves pos for non-widget input slots', () => {
// @ts-expect-error TODO: Fix after merge - missing boundingRect property for test
const normalSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
pos: [10, 20],
link: null
}
const serialized = inputAsSerialisable(normalSlot)
expect(serialized).toHaveProperty('pos')
})
it('preserves only widget name during serialization', () => {
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
link: null,
widget: {
name: 'test-widget',
// @ts-expect-error TODO: Fix after merge - type property not in IWidgetLocator
type: 'combo',
value: 'test-value-1',
options: {
values: ['test-value-1', 'test-value-2']
}
}
}
const serialized = inputAsSerialisable(widgetInputSlot)
expect(serialized.widget).toEqual({ name: 'test-widget' })
expect(serialized.widget).not.toHaveProperty('type')
expect(serialized.widget).not.toHaveProperty('value')
expect(serialized.widget).not.toHaveProperty('options')
})
})
})

View File

@@ -1,96 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ToOutputRenderLink } from '@/lib/litegraph/src/litegraph'
import { LinkDirection } from '@/lib/litegraph/src/litegraph'
describe('ToOutputRenderLink', () => {
describe('connectToOutput', () => {
it('should return early if inputNode is null', () => {
// Setup
const mockNetwork = {}
const mockFromSlot = {}
const mockNode = {
id: 'test-id',
inputs: [mockFromSlot],
getInputPos: vi.fn().mockReturnValue([0, 0])
}
const renderLink = new ToOutputRenderLink(
mockNetwork as any,
mockNode as any,
mockFromSlot as any,
undefined,
LinkDirection.CENTER
)
// Override the node property to simulate null case
Object.defineProperty(renderLink, 'node', {
value: null
})
const mockTargetNode = {
connectSlots: vi.fn()
}
const mockEvents = {
dispatch: vi.fn()
}
// Act
renderLink.connectToOutput(
mockTargetNode as any,
{} as any,
mockEvents as any
)
// Assert
expect(mockTargetNode.connectSlots).not.toHaveBeenCalled()
expect(mockEvents.dispatch).not.toHaveBeenCalled()
})
it('should create connection and dispatch event when inputNode exists', () => {
// Setup
const mockNetwork = {}
const mockFromSlot = {}
const mockNode = {
id: 'test-id',
inputs: [mockFromSlot],
getInputPos: vi.fn().mockReturnValue([0, 0])
}
const renderLink = new ToOutputRenderLink(
mockNetwork as any,
mockNode as any,
mockFromSlot as any,
undefined,
LinkDirection.CENTER
)
const mockNewLink = { id: 'new-link' }
const mockTargetNode = {
connectSlots: vi.fn().mockReturnValue(mockNewLink)
}
const mockEvents = {
dispatch: vi.fn()
}
// Act
renderLink.connectToOutput(
mockTargetNode as any,
{} as any,
mockEvents as any
)
// Assert
expect(mockTargetNode.connectSlots).toHaveBeenCalledWith(
expect.anything(),
mockNode,
mockFromSlot,
undefined
)
expect(mockEvents.dispatch).toHaveBeenCalledWith(
'link-created',
mockNewLink
)
})
})
})

View File

@@ -1,331 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float32Array [
20,
20,
],
"_size": Float32Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
LGraph {
"_groups": [],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [],
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 0,
"lastLinkId": 0,
"lastNodeId": 0,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;

View File

@@ -1,302 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph > supports schema v0.4 graphs > oldSchemaGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Rectangle [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float64Array [
20,
20,
],
"_size": Float64Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float64Array [
10,
10,
],
"_posSize": Rectangle [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float64Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": true,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": {
"id": 1,
},
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": undefined,
"title_buttons": [],
"type": "",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {
Symbol(listeners): {
"bubbling": Map {},
"capturing": Map {},
},
Symbol(listenerOptions): {
"bubbling": Map {},
"capturing": Map {},
},
},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"floatingLinksInternal": Map {},
"globaltime": 0,
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
"reroutesInternal": Map {},
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 0.4,
}
`;

View File

@@ -1,17 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraphGroup > serializes to the existing format > Basic 1`] = `
{
"bounding": [
10,
10,
140,
80,
],
"color": "#3f789e",
"flags": {},
"font_size": 24,
"id": 929,
"title": "title",
}
`;

View File

@@ -1,331 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph (constructor only) > Matches previous snapshot > basicLGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float32Array [
20,
20,
],
"_size": Float32Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"title_buttons": [],
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
exports[`LGraph (constructor only) > Matches previous snapshot > minLGraph 1`] = `
LGraph {
"_groups": [],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [],
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 0,
"lastLinkId": 0,
"lastNodeId": 0,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;

View File

@@ -1,23 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LLink > matches previous snapshot > Basic 1`] = `
[
1,
4,
2,
5,
3,
"float",
]
`;
exports[`LLink > serializes to the previous snapshot > Basic 1`] = `
[
1,
4,
2,
5,
3,
"float",
]
`;

View File

@@ -1,202 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Litegraph module > has the same structure > minLGraph 1`] = `
LiteGraphGlobal {
"ACTION": -1,
"ALWAYS": 0,
"ARROW_SHAPE": 5,
"AUTOHIDE_TITLE": 3,
"BOX_SHAPE": 1,
"CANVAS_GRID_SIZE": 10,
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],
"Rectangle": [Function],
"SubgraphIONodeBase": [Function],
"SubgraphSlot": [Function],
},
"ContextMenu": [Function],
"CurveEditor": [Function],
"DEFAULT_FONT": "Inter",
"DEFAULT_GROUP_FONT": 24,
"DEFAULT_GROUP_FONT_SIZE": undefined,
"DEFAULT_POSITION": [
100,
100,
],
"DEFAULT_SHADOW_COLOR": "rgba(0,0,0,0.5)",
"DOWN": 2,
"DragAndScale": [Function],
"EVENT": -1,
"EVENT_LINK_COLOR": "#A86",
"GRID_SHAPE": 6,
"GROUP_FONT": "Inter",
"Globals": {},
"HIDDEN_LINK": -1,
"INPUT": 1,
"LEFT": 3,
"LGraph": [Function],
"LGraphCanvas": [Function],
"LGraphGroup": [Function],
"LGraphNode": [Function],
"LINEAR_LINK": 1,
"LINK_COLOR": "#9A9",
"LINK_RENDER_MODES": [
"Straight",
"Linear",
"Spline",
],
"LLink": [Function],
"LabelPosition": {
"Left": "left",
"Right": "right",
},
"MAX_NUMBER_OF_NODES": 10000,
"NEVER": 2,
"NODE_BOX_OUTLINE_COLOR": "#FFF",
"NODE_COLLAPSED_RADIUS": 10,
"NODE_COLLAPSED_WIDTH": 80,
"NODE_DEFAULT_BGCOLOR": "#353535",
"NODE_DEFAULT_BOXCOLOR": "#666",
"NODE_DEFAULT_BYPASS_COLOR": "#FF00FF",
"NODE_DEFAULT_COLOR": "#333",
"NODE_DEFAULT_SHAPE": 2,
"NODE_ERROR_COLOUR": "#E00",
"NODE_FONT": "Inter",
"NODE_MIN_WIDTH": 50,
"NODE_MODES": [
"Always",
"On Event",
"Never",
"On Trigger",
],
"NODE_MODES_COLORS": [
"#666",
"#422",
"#333",
"#224",
"#626",
],
"NODE_OPACITY": 0.9,
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_SLOT_HEIGHT": 20,
"NODE_SUBTEXT_SIZE": 12,
"NODE_TEXT_COLOR": "#AAA",
"NODE_TEXT_HIGHLIGHT_COLOR": "#EEE",
"NODE_TEXT_SIZE": 14,
"NODE_TITLE_COLOR": "#999",
"NODE_TITLE_HEIGHT": 30,
"NODE_TITLE_TEXT_Y": 20,
"NODE_WIDGET_HEIGHT": 20,
"NODE_WIDTH": 140,
"NORMAL_TITLE": 0,
"NO_TITLE": 1,
"Nodes": {},
"ON_EVENT": 1,
"ON_TRIGGER": 3,
"OUTPUT": 2,
"RIGHT": 4,
"ROUND_RADIUS": 8,
"ROUND_SHAPE": 2,
"Reroute": [Function],
"SPLINE_LINK": 2,
"STRAIGHT_LINK": 0,
"SlotDirection": {},
"SlotShape": {
"1": "Box",
"3": "Circle",
"5": "Arrow",
"6": "Grid",
"7": "HollowCircle",
"Arrow": 5,
"Box": 1,
"Circle": 3,
"Grid": 6,
"HollowCircle": 7,
},
"SlotType": {
"-1": "Event",
"Array": "array",
"Event": -1,
},
"TRANSPARENT_TITLE": 2,
"UP": 1,
"VALID_SHAPES": [
"default",
"box",
"round",
"card",
],
"VERSION": 0.4,
"VERTICAL_LAYOUT": "vertical",
"WIDGET_ADVANCED_OUTLINE_COLOR": "rgba(56, 139, 253, 0.8)",
"WIDGET_BGCOLOR": "#222",
"WIDGET_DISABLED_TEXT_COLOR": "#666",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#DDD",
"allow_multi_output_for_events": true,
"allow_scripts": false,
"alt_drag_do_clone_nodes": false,
"alwaysRepeatWarnings": false,
"alwaysSnapToGrid": undefined,
"auto_load_slot_types": false,
"canvasNavigationMode": "legacy",
"catch_exceptions": true,
"click_do_break_link_to": false,
"context_menu_scaling": false,
"ctrl_alt_click_do_break_link": true,
"ctrl_shift_v_paste_connect_unselected_outputs": true,
"debug": false,
"dialog_close_on_mouse_leave": false,
"dialog_close_on_mouse_leave_delay": 500,
"distance": [Function],
"do_add_triggers_slots": false,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",
"macGesturesRequireMac": true,
"macTrackpadGestures": false,
"middle_click_slot_add_default_node": false,
"mouseWheelScroll": "panning",
"nodeLightness": undefined,
"nodeOpacity": 1,
"node_box_coloured_by_mode": false,
"node_box_coloured_when_on": false,
"node_images_path": "",
"node_types_by_file_extension": {},
"onDeprecationWarning": [
[Function],
],
"overlapBounding": [Function],
"pointerevents_method": "pointer",
"proxy": null,
"registered_node_types": {},
"registered_slot_in_types": {},
"registered_slot_out_types": {},
"release_link_on_empty_shows_menu": false,
"saveViewportWithGraph": true,
"search_filter_enabled": false,
"search_hide_on_mouse_leave": true,
"search_show_all_on_open": true,
"searchbox_extras": {},
"shift_click_do_break_link_from": false,
"slot_types_default_in": {},
"slot_types_default_out": {},
"slot_types_in": [],
"slot_types_out": [],
"snapToGrid": undefined,
"snap_highlights_node": true,
"snaps_for_comfy": true,
"throw_errors": true,
"truncateWidgetTextEvenly": false,
"truncateWidgetValuesFirst": false,
"use_uuids": false,
"uuidv4": [Function],
"vueNodesMode": false,
}
`;

View File

@@ -1,367 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
describe('contextMenuCompat', () => {
let originalGetCanvasMenuOptions: typeof LGraphCanvas.prototype.getCanvasMenuOptions
let mockCanvas: LGraphCanvas
beforeEach(() => {
// Save original method
originalGetCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
// Create mock canvas
mockCanvas = {
constructor: {
prototype: LGraphCanvas.prototype
}
} as unknown as LGraphCanvas
// Clear console warnings
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
// Restore original method
LGraphCanvas.prototype.getCanvasMenuOptions = originalGetCanvasMenuOptions
vi.restoreAllMocks()
})
describe('install', () => {
it('should install compatibility layer on prototype', () => {
const methodName = 'getCanvasMenuOptions'
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
// The method should still be callable
expect(typeof LGraphCanvas.prototype.getCanvasMenuOptions).toBe(
'function'
)
})
it('should detect monkey patches and warn', () => {
const methodName = 'getCanvasMenuOptions'
const warnSpy = vi.spyOn(console, 'warn')
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
// Set current extension before monkey-patching
legacyMenuCompat.setCurrentExtension('Test Extension')
// Simulate extension monkey-patching
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
const items = (original as any).apply(this, args)
items.push({ content: 'Custom Item', callback: () => {} })
return items
}
// Should have logged a warning with extension name
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEPRECATED]'),
expect.any(String),
expect.any(String)
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('"Test Extension"'),
expect.any(String),
expect.any(String)
)
// Clear extension
legacyMenuCompat.setCurrentExtension(null)
})
it('should only warn once per unique function', () => {
const methodName = 'getCanvasMenuOptions'
const warnSpy = vi.spyOn(console, 'warn')
legacyMenuCompat.install(LGraphCanvas.prototype, methodName)
legacyMenuCompat.setCurrentExtension('test.extension')
const patchFunction = function (this: LGraphCanvas, ...args: any[]) {
const items = (originalGetCanvasMenuOptions as any).apply(this, args)
items.push({ content: 'Custom', callback: () => {} })
return items
}
// Patch twice with same function
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
LGraphCanvas.prototype.getCanvasMenuOptions = patchFunction
// Should only warn once
expect(warnSpy).toHaveBeenCalledTimes(1)
})
})
describe('extractLegacyItems', () => {
// Cache base items to ensure reference equality for set-based diffing
const baseItem1 = { content: 'Item 1', callback: () => {} }
const baseItem2 = { content: 'Item 2', callback: () => {} }
beforeEach(() => {
// Setup a mock original method that returns cached items
// This ensures reference equality when set-based diffing compares items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [baseItem1, baseItem2]
}
// Install compatibility layer
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
})
it('should extract items added by monkey patches', () => {
// Monkey-patch to add items
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Custom Item 1', callback: () => {} })
items.push({ content: 'Custom Item 2', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Custom Item 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Custom Item 2' })
})
it('should return empty array when no items added', () => {
// No monkey-patching, so no extra items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
})
it('should detect replaced items as additions and warn about removed items', () => {
const warnSpy = vi.spyOn(console, 'warn')
// Monkey-patch that replaces items with different ones (same count)
// With set-based diffing, these are detected as new items since they're different references
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [
{ content: 'Replaced 1', callback: () => {} },
{ content: 'Replaced 2', callback: () => {} }
]
}
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Set-based diffing detects the replaced items as additions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Replaced 1' })
expect(legacyItems[1]).toMatchObject({ content: 'Replaced 2' })
// Should warn about removed original items
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('removed'))
})
it('should handle errors gracefully', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Monkey-patch that throws error
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
throw new Error('Test error')
}
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to extract legacy items'),
expect.any(Error)
)
})
})
describe('integration', () => {
// Cache base items to ensure reference equality for set-based diffing
const integrationBaseItem = { content: 'Base Item', callback: () => {} }
const integrationBaseItem1 = { content: 'Base Item 1', callback: () => {} }
const integrationBaseItem2 = { content: 'Base Item 2', callback: () => {} }
it('should work with multiple extensions patching', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// First extension patches
const original1 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original1.apply(this)
items.push({ content: 'Extension 1 Item', callback: () => {} })
return items
}
// Second extension patches
const original2 = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original2.apply(this)
items.push({ content: 'Extension 2 Item', callback: () => {} })
return items
}
// Extract legacy items
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Should extract both items added by extensions
expect(legacyItems).toHaveLength(2)
expect(legacyItems[0]).toMatchObject({ content: 'Extension 1 Item' })
expect(legacyItems[1]).toMatchObject({ content: 'Extension 2 Item' })
})
it('should extract legacy items only once even when called multiple times', () => {
// Setup base method with cached items
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem1, integrationBaseItem2]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Simulate legacy extension monkey-patching the prototype
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item 1', callback: () => {} })
items.push({ content: 'Legacy Item 2', callback: () => {} })
return items
}
// Extract legacy items multiple times (simulating repeated menu opens)
const legacyItems1 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
const legacyItems2 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
const legacyItems3 = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
// Each extraction should return the same items (no accumulation)
expect(legacyItems1).toHaveLength(2)
expect(legacyItems2).toHaveLength(2)
expect(legacyItems3).toHaveLength(2)
// Verify items are the expected ones
expect(legacyItems1[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems1[1]).toMatchObject({ content: 'Legacy Item 2' })
expect(legacyItems2[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems2[1]).toMatchObject({ content: 'Legacy Item 2' })
expect(legacyItems3[0]).toMatchObject({ content: 'Legacy Item 1' })
expect(legacyItems3[1]).toMatchObject({ content: 'Legacy Item 2' })
})
it('should not extract items from registered wrapper methods', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Create a wrapper that adds new API items (simulating useContextMenuTranslation)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (
this: LGraphCanvas
): (IContextMenuValue | null)[] {
const items = originalMethod.apply(this)
// Add new API items
items.push({ content: 'New API Item 1', callback: () => {} })
items.push({ content: 'New API Item 2', callback: () => {} })
return items
}
// Set the wrapper as the current method
LGraphCanvas.prototype.getCanvasMenuOptions = wrapperMethod
// Register the wrapper so it's not treated as a legacy patch
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapperMethod,
originalMethod,
LGraphCanvas.prototype // Wrapper is installed
)
// Extract legacy items - should return empty because current method is a registered wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(0)
})
it('should extract legacy items even when a wrapper is registered but not active', () => {
// Setup base method with cached item
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
return [integrationBaseItem]
}
legacyMenuCompat.install(LGraphCanvas.prototype, 'getCanvasMenuOptions')
// Register a wrapper (but don't set it as the current method)
const originalMethod = LGraphCanvas.prototype.getCanvasMenuOptions
const wrapperMethod = function (): (IContextMenuValue | null)[] {
return [{ content: 'Wrapper Item', callback: () => {} }]
}
legacyMenuCompat.registerWrapper(
'getCanvasMenuOptions',
wrapperMethod,
originalMethod
// NOT passing prototype, so it won't be marked as installed
)
// Monkey-patch with a different function (legacy extension)
const original = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions =
function (): (IContextMenuValue | null)[] {
const items = original.apply(this)
items.push({ content: 'Legacy Item', callback: () => {} })
return items
}
// Extract legacy items - should return the legacy item because current method is NOT the wrapper
const legacyItems = legacyMenuCompat.extractLegacyItems(
'getCanvasMenuOptions',
mockCanvas
)
expect(legacyItems).toHaveLength(1)
expect(legacyItems[0]).toMatchObject({ content: 'Legacy Item' })
})
})
})

View File

@@ -1,123 +0,0 @@
{
"id": "e5ffd5e1-1c01-45ac-90dd-b7d83a206b0f",
"revision": 0,
"last_node_id": 3,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "InvertMask",
"pos": [100, 130],
"size": [140, 26],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": null
}
],
"outputs": [
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": [2, 3]
}
],
"properties": { "Node name for S&R": "InvertMask" },
"widgets_values": []
},
{
"id": 3,
"type": "InvertMask",
"pos": [400, 220],
"size": [140, 26],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "localized_name": "mask", "name": "mask", "type": "MASK", "link": 3 }
],
"outputs": [
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": { "Node name for S&R": "InvertMask" },
"widgets_values": []
},
{
"id": 2,
"type": "InvertMask",
"pos": [400, 130],
"size": [140, 26],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "localized_name": "mask", "name": "mask", "type": "MASK", "link": 2 }
],
"outputs": [
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": { "Node name for S&R": "InvertMask" },
"widgets_values": []
}
],
"links": [
[2, 1, 0, 2, 0, "MASK"],
[3, 1, 0, 3, 0, "MASK"]
],
"floatingLinks": [
{
"id": 6,
"origin_id": 1,
"origin_slot": 0,
"target_id": -1,
"target_slot": -1,
"type": "MASK",
"parentId": 1
}
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.2100000000000002,
"offset": [319.8264462809916, 109.2148760330578]
},
"linkExtensions": [
{ "id": 2, "parentId": 3 },
{ "id": 3, "parentId": 3 }
],
"reroutes": [
{
"id": 1,
"parentId": 2,
"pos": [350, 110],
"linkIds": [],
"floating": { "slotType": "output" }
},
{ "id": 2, "parentId": 4, "pos": [310, 150], "linkIds": [2, 3] },
{ "id": 3, "parentId": 2, "pos": [360, 170], "linkIds": [2, 3] },
{
"id": 4,
"pos": [271.9090881347656, 146.9834747314453],
"linkIds": [2, 3]
}
]
},
"version": 0.4
}

Some files were not shown because too many files have changed in this diff Show More