mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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%')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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', ''])
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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",
|
||||
]
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user