mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-21 06:49:37 +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:
330
src/services/audioService.test.ts
Normal file
330
src/services/audioService.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { AudioRecordingError } 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]')
|
||||
})
|
||||
})
|
||||
})
|
||||
426
src/services/customerEventsService.test.ts
Normal file
426
src/services/customerEventsService.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import axios from 'axios'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EventType,
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
// Hoist the mocks to avoid hoisting issues
|
||||
const mockAxiosInstance = vi.hoisted(() => ({
|
||||
get: vi.fn()
|
||||
}))
|
||||
|
||||
const mockFirebaseAuthStore = vi.hoisted(() => ({
|
||||
getAuthHeader: vi.fn()
|
||||
}))
|
||||
|
||||
const mockI18n = vi.hoisted(() => ({
|
||||
d: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => mockAxiosInstance),
|
||||
isAxiosError: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => mockI18n)
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isAbortError: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useCustomerEventsService', () => {
|
||||
let service: ReturnType<typeof useCustomerEventsService>
|
||||
|
||||
const mockAuthHeaders = {
|
||||
Authorization: 'Bearer mock-token',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
const mockEventsResponse = {
|
||||
events: [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: 'credit_added',
|
||||
params: {
|
||||
amount: 1000,
|
||||
transaction_id: 'txn-123',
|
||||
payment_method: 'stripe'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'Image Generation',
|
||||
model: 'sdxl-base',
|
||||
duration: 5000,
|
||||
cost: 50
|
||||
},
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup default mocks
|
||||
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
|
||||
mockI18n.d.mockImplementation((date, options) => {
|
||||
// Mock i18n date formatting
|
||||
if (options?.month === 'short') {
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
return date.toLocaleString()
|
||||
})
|
||||
|
||||
service = useCustomerEventsService()
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default state', () => {
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should initialize i18n date formatter', () => {
|
||||
expect(mockI18n.d).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMyEvents', () => {
|
||||
it('should fetch events successfully', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
|
||||
|
||||
const result = await service.getMyEvents({
|
||||
page: 1,
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalled()
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
|
||||
params: { page: 1, limit: 10 },
|
||||
headers: mockAuthHeaders
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockEventsResponse)
|
||||
expect(service.isLoading.value).toBe(false)
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should use default parameters when none provided', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
|
||||
|
||||
await service.getMyEvents()
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
|
||||
params: { page: 1, limit: 10 },
|
||||
headers: mockAuthHeaders
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when auth headers are missing', async () => {
|
||||
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
|
||||
|
||||
const result = await service.getMyEvents()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe('Authentication header is missing')
|
||||
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle 400 errors', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 400,
|
||||
data: { message: 'Invalid input' }
|
||||
}
|
||||
}
|
||||
mockAxiosInstance.get.mockRejectedValue(errorResponse)
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
const result = await service.getMyEvents()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe('Invalid input, object invalid')
|
||||
})
|
||||
|
||||
it('should handle 404 errors', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 404,
|
||||
data: { message: 'Not found' }
|
||||
}
|
||||
}
|
||||
mockAxiosInstance.get.mockRejectedValue(errorResponse)
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(true)
|
||||
|
||||
const result = await service.getMyEvents()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe('Not found')
|
||||
})
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const networkError = new Error('Network Error')
|
||||
mockAxiosInstance.get.mockRejectedValue(networkError)
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||
|
||||
const result = await service.getMyEvents()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe(
|
||||
'Fetching customer events failed: Network Error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatEventType', () => {
|
||||
it('should format known event types correctly', () => {
|
||||
expect(service.formatEventType(EventType.CREDIT_ADDED)).toBe(
|
||||
'Credits Added'
|
||||
)
|
||||
expect(service.formatEventType(EventType.ACCOUNT_CREATED)).toBe(
|
||||
'Account Created'
|
||||
)
|
||||
expect(service.formatEventType(EventType.API_USAGE_COMPLETED)).toBe(
|
||||
'API Usage'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return the original string for unknown event types', () => {
|
||||
expect(service.formatEventType('unknown_event')).toBe('unknown_event')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventSeverity', () => {
|
||||
it('should return correct severity for known event types', () => {
|
||||
expect(service.getEventSeverity(EventType.CREDIT_ADDED)).toBe('success')
|
||||
expect(service.getEventSeverity(EventType.ACCOUNT_CREATED)).toBe('info')
|
||||
expect(service.getEventSeverity(EventType.API_USAGE_COMPLETED)).toBe(
|
||||
'warning'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return default severity for unknown event types', () => {
|
||||
expect(service.getEventSeverity('unknown_event')).toBe('info')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAmount', () => {
|
||||
it('should format amounts correctly', () => {
|
||||
expect(service.formatAmount(1000)).toBe('10.00')
|
||||
expect(service.formatAmount(2550)).toBe('25.50')
|
||||
expect(service.formatAmount(100)).toBe('1.00')
|
||||
})
|
||||
|
||||
it('should handle undefined amounts', () => {
|
||||
expect(service.formatAmount(undefined)).toBe('0.00')
|
||||
expect(service.formatAmount(0)).toBe('0.00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should use i18n date formatter', () => {
|
||||
const dateString = '2024-01-01T10:00:00Z'
|
||||
|
||||
service.formatDate(dateString)
|
||||
|
||||
expect(mockI18n.d).toHaveBeenCalledWith(new Date(dateString), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return formatted date string', () => {
|
||||
const dateString = '2024-01-01T10:00:00Z'
|
||||
const result = service.formatDate(dateString)
|
||||
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAdditionalInfo', () => {
|
||||
it('should return true when event has additional parameters', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'test-api',
|
||||
model: 'test-model',
|
||||
duration: 1000,
|
||||
extra_param: 'extra_value'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
expect(service.hasAdditionalInfo(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when event only has known parameters', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
amount: 1000,
|
||||
api_name: 'test-api',
|
||||
model: 'test-model'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
expect(service.hasAdditionalInfo(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when params is undefined', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'account_created',
|
||||
params: undefined,
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
expect(service.hasAdditionalInfo(event)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTooltipContent', () => {
|
||||
it('should generate HTML tooltip content for all parameters', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
transaction_id: 'txn-123',
|
||||
duration: 5000,
|
||||
status: 'completed'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
const result = service.getTooltipContent(event)
|
||||
|
||||
expect(result).toContain('<strong>Transaction Id:</strong> txn-123')
|
||||
expect(result).toContain('<strong>Duration:</strong> 5,000')
|
||||
expect(result).toContain('<strong>Status:</strong> completed')
|
||||
expect(result).toContain('<br>')
|
||||
})
|
||||
|
||||
it('should return empty string when no parameters', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'account_created',
|
||||
params: {},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
expect(service.getTooltipContent(event)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined params', () => {
|
||||
const event = {
|
||||
event_id: 'test',
|
||||
event_type: 'account_created',
|
||||
params: undefined,
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
expect(service.getTooltipContent(event)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatJsonKey', () => {
|
||||
it('should format keys correctly', () => {
|
||||
expect(service.formatJsonKey('transaction_id')).toBe('Transaction Id')
|
||||
expect(service.formatJsonKey('api_name')).toBe('Api Name')
|
||||
expect(service.formatJsonKey('simple')).toBe('Simple')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatJsonValue', () => {
|
||||
it('should format numbers with commas', () => {
|
||||
expect(service.formatJsonValue(1000)).toBe('1,000')
|
||||
expect(service.formatJsonValue(1234567)).toBe('1,234,567')
|
||||
})
|
||||
|
||||
it('should format date strings', () => {
|
||||
const dateString = '2024-01-01T10:00:00Z'
|
||||
const result = service.formatJsonValue(dateString)
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result).not.toBe(dateString) // Should be formatted
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling edge cases', () => {
|
||||
it('should handle non-Error objects', async () => {
|
||||
const stringError = 'String error'
|
||||
mockAxiosInstance.get.mockRejectedValue(stringError)
|
||||
vi.mocked(axios.isAxiosError).mockReturnValue(false)
|
||||
|
||||
const result = await service.getMyEvents()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(service.error.value).toBe(
|
||||
'Fetching customer events failed: String error'
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset error state on new request', async () => {
|
||||
// First request fails
|
||||
mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
|
||||
await service.getMyEvents()
|
||||
expect(service.error.value).toBeTruthy()
|
||||
|
||||
// Second request succeeds
|
||||
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockEventsResponse })
|
||||
await service.getMyEvents()
|
||||
expect(service.error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType enum', () => {
|
||||
it('should have correct enum values', () => {
|
||||
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||
expect(EventType.API_USAGE_STARTED).toBe('api_usage_started')
|
||||
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases for formatting functions', () => {
|
||||
it('formatJsonKey should handle empty strings', () => {
|
||||
expect(service.formatJsonKey('')).toBe('')
|
||||
})
|
||||
|
||||
it('formatJsonKey should handle single words', () => {
|
||||
expect(service.formatJsonKey('test')).toBe('Test')
|
||||
})
|
||||
|
||||
it('formatAmount should handle very large numbers', () => {
|
||||
expect(service.formatAmount(999999999)).toBe('9999999.99')
|
||||
})
|
||||
})
|
||||
})
|
||||
445
src/services/gateway/registrySearchGateway.test.ts
Normal file
445
src/services/gateway/registrySearchGateway.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
|
||||
// Mock the provider modules to control their behavior
|
||||
vi.mock('@/services/providers/algoliaSearchProvider')
|
||||
vi.mock('@/services/providers/registrySearchProvider')
|
||||
|
||||
describe('useRegistrySearchGateway', () => {
|
||||
let consoleWarnSpy: any
|
||||
let consoleInfoSpy: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
consoleInfoSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Provider initialization', () => {
|
||||
it('should initialize with both providers', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
expect(useAlgoliaSearchProvider).toHaveBeenCalled()
|
||||
expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
|
||||
expect(gateway).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle Algolia initialization failure gracefully', () => {
|
||||
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
|
||||
throw new Error('Algolia init failed')
|
||||
})
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Gateway should still work with just the Registry provider
|
||||
expect(gateway).toBeDefined()
|
||||
expect(typeof gateway.searchPacks).toBe('function')
|
||||
|
||||
// Verify it can still search using the fallback provider
|
||||
return expect(
|
||||
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search functionality', () => {
|
||||
it('should use Algolia provider by default and fallback on failure', async () => {
|
||||
const algoliaResult = {
|
||||
nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(algoliaResult)
|
||||
.mockRejectedValueOnce(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// First call should use Algolia
|
||||
const result1 = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
expect(result1.nodePacks[0].name).toBe('Algolia Pack')
|
||||
|
||||
// Second call should fallback to Registry when Algolia fails
|
||||
const result2 = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
expect(result2.nodePacks[0].name).toBe('Registry Pack')
|
||||
})
|
||||
|
||||
it('should throw error when all providers fail', async () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
await expect(
|
||||
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
).rejects.toThrow('All search providers failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Circuit breaker functionality', () => {
|
||||
it('should switch to fallback provider after failure and log warnings', async () => {
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
// Create mock that fails
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// First call should try Algolia, fail, and use Registry
|
||||
const result = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||
|
||||
// Circuit breaker behavior is internal implementation detail
|
||||
// We only test the observable behavior (fallback works)
|
||||
})
|
||||
|
||||
it('should have circuit breaker timeout mechanism', () => {
|
||||
// This test verifies that the constants exist for circuit breaker behavior
|
||||
// The actual circuit breaker logic is tested in integration with real provider behavior
|
||||
expect(typeof useRegistrySearchGateway).toBe('function')
|
||||
|
||||
// We can test that the gateway logs circuit breaker behavior
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
expect(gateway).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache management', () => {
|
||||
it('should clear cache for all providers', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
gateway.clearSearchCache()
|
||||
|
||||
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle cache clear failures gracefully', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Cache clear failed')
|
||||
}),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Should not throw when clearing cache even if one provider fails
|
||||
expect(() => gateway.clearSearchCache()).not.toThrow()
|
||||
|
||||
// Should still attempt to clear cache for all providers
|
||||
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort functionality', () => {
|
||||
it('should use sort fields from active provider', () => {
|
||||
const algoliaFields = [
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||
]
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
const sortFields = gateway.getSortableFields()
|
||||
|
||||
expect(sortFields).toEqual(algoliaFields)
|
||||
})
|
||||
|
||||
it('should switch sort fields when provider changes', async () => {
|
||||
const algoliaFields = [
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||
]
|
||||
const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue(registryFields)
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Initially should use Algolia's sort fields
|
||||
expect(gateway.getSortableFields()).toEqual(algoliaFields)
|
||||
|
||||
// Force a search to trigger provider switch
|
||||
await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||
|
||||
// Now should use Registry's sort fields
|
||||
expect(gateway.getSortableFields()).toEqual(registryFields)
|
||||
})
|
||||
|
||||
it('should delegate getSortValue to active provider', () => {
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn().mockReturnValue(100),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn(),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
const pack = { id: '1', name: 'Test Pack' }
|
||||
|
||||
const value = gateway.getSortValue(pack, 'downloads')
|
||||
|
||||
expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
|
||||
pack,
|
||||
'downloads'
|
||||
)
|
||||
expect(value).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider recovery', () => {
|
||||
it('should use fallback provider when primary fails', async () => {
|
||||
const algoliaError = new Error('Algolia service unavailable')
|
||||
const registryResult = {
|
||||
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||
querySuggestions: []
|
||||
}
|
||||
|
||||
const mockAlgoliaProvider = {
|
||||
searchPacks: vi.fn().mockRejectedValue(algoliaError),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
const mockRegistryProvider = {
|
||||
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||
clearSearchCache: vi.fn(),
|
||||
getSortValue: vi.fn(),
|
||||
getSortableFields: vi.fn().mockReturnValue([])
|
||||
}
|
||||
|
||||
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||
mockRegistryProvider
|
||||
)
|
||||
|
||||
const gateway = useRegistrySearchGateway()
|
||||
|
||||
// Should fallback to Registry when Algolia fails
|
||||
const result = await gateway.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The gateway successfully handled the failure and returned results
|
||||
})
|
||||
})
|
||||
})
|
||||
202
src/services/keybindingService.escape.test.ts
Normal file
202
src/services/keybindingService.escape.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
useKeybindingStore
|
||||
} from '@/stores/keybindingStore'
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
dialogStack: []
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('keybindingService - Escape key handling', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Mock command store execute
|
||||
const commandStore = useCommandStore()
|
||||
mockCommandExecute = vi.fn()
|
||||
commandStore.execute = mockCommandExecute
|
||||
|
||||
// Reset dialog store mock to empty
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: []
|
||||
} as any)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('should register Escape key for ExitSubgraph command', () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
// Check that the Escape keybinding exists in core keybindings
|
||||
const escapeKeybinding = CORE_KEYBINDINGS.find(
|
||||
(kb) =>
|
||||
kb.combo.key === 'Escape' && kb.commandId === 'Comfy.Graph.ExitSubgraph'
|
||||
)
|
||||
expect(escapeKeybinding).toBeDefined()
|
||||
|
||||
// Check that it was registered in the store
|
||||
const registeredBinding = keybindingStore.getKeybinding(
|
||||
new KeyComboImpl({ key: 'Escape' })
|
||||
)
|
||||
expect(registeredBinding).toBeDefined()
|
||||
expect(registeredBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should execute ExitSubgraph command when Escape is pressed', async () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
// Mock event methods
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
|
||||
})
|
||||
|
||||
it('should not execute command when Escape is pressed with modifiers', async () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not execute command when typing in input field', async () => {
|
||||
const inputElement = document.createElement('input')
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [inputElement])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close dialogs when no keybinding is registered', async () => {
|
||||
// Remove the Escape keybinding
|
||||
const keybindingStore = useKeybindingStore()
|
||||
keybindingStore.unsetKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'Comfy.Graph.ExitSubgraph',
|
||||
combo: { key: 'Escape' }
|
||||
})
|
||||
)
|
||||
|
||||
// Create a mock dialog
|
||||
const dialog = document.createElement('dialog')
|
||||
dialog.open = true
|
||||
dialog.close = vi.fn()
|
||||
document.body.appendChild(dialog)
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(dialog.close).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(dialog)
|
||||
})
|
||||
|
||||
it('should allow user to rebind Escape key to different command', async () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
// Add a user keybinding for Escape to a different command
|
||||
keybindingStore.addUserKeybinding(
|
||||
new KeybindingImpl({
|
||||
commandId: 'Custom.Command',
|
||||
combo: { key: 'Escape' }
|
||||
})
|
||||
)
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(mockCommandExecute).toHaveBeenCalledWith('Custom.Command')
|
||||
expect(mockCommandExecute).not.toHaveBeenCalledWith(
|
||||
'Comfy.Graph.ExitSubgraph'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not execute Escape keybinding when dialogs are open', async () => {
|
||||
// Mock dialog store to have open dialogs
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: [{ key: 'test-dialog' }]
|
||||
} as any)
|
||||
|
||||
// Re-create keybinding service to pick up new mock
|
||||
keybindingService = useKeybindingService()
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [document.body])
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not call preventDefault or execute command
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mockCommandExecute).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
176
src/services/keybindingService.forwarding.test.ts
Normal file
176
src/services/keybindingService.forwarding.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// Mock the app and canvas using factory functions
|
||||
vi.mock('@/scripts/app', () => {
|
||||
return {
|
||||
app: {
|
||||
canvas: {
|
||||
processKey: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock stores
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
dialogStack: []
|
||||
}))
|
||||
}))
|
||||
|
||||
// Test utility for creating keyboard events with mocked methods
|
||||
function createTestKeyboardEvent(
|
||||
key: string,
|
||||
options: {
|
||||
target?: Element
|
||||
ctrlKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
} = {}
|
||||
): KeyboardEvent {
|
||||
const {
|
||||
target = document.body,
|
||||
ctrlKey = false,
|
||||
altKey = false,
|
||||
metaKey = false
|
||||
} = options
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
ctrlKey,
|
||||
altKey,
|
||||
metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
// Mock event methods
|
||||
event.preventDefault = vi.fn()
|
||||
event.composedPath = vi.fn(() => [target])
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
describe('keybindingService - Event Forwarding', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Mock command store execute
|
||||
const commandStore = useCommandStore()
|
||||
commandStore.execute = vi.fn()
|
||||
|
||||
// Reset dialog store mock to empty
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
dialogStack: []
|
||||
} as any)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('should forward Delete key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should forward to canvas processKey
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
// Should not execute any command
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward Backspace key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Backspace')
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in input field', async () => {
|
||||
const inputElement = document.createElement('input')
|
||||
const event = createTestKeyboardEvent('Delete', { target: inputElement })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward to canvas when in input field
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in textarea', async () => {
|
||||
const textareaElement = document.createElement('textarea')
|
||||
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas processKey is not available', async () => {
|
||||
// Temporarily replace processKey with undefined
|
||||
const originalProcessKey = vi.mocked(app.canvas).processKey
|
||||
vi.mocked(app.canvas).processKey = undefined as any
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
|
||||
// Restore processKey for other tests
|
||||
vi.mocked(app.canvas).processKey = originalProcessKey
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas is not available', async () => {
|
||||
// Temporarily set canvas to null
|
||||
const originalCanvas = vi.mocked(app).canvas
|
||||
vi.mocked(app).canvas = null as any
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
|
||||
// Restore canvas for other tests
|
||||
vi.mocked(app).canvas = originalCanvas
|
||||
})
|
||||
|
||||
it('should not forward non-canvas keys', async () => {
|
||||
const event = createTestKeyboardEvent('Enter')
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward Enter key
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward when modifier keys are pressed', async () => {
|
||||
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
// Should not forward when modifiers are pressed
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
35
src/services/mediaCacheService.test.ts
Normal file
35
src/services/mediaCacheService.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMediaCache } from './mediaCacheService'
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
global.URL = {
|
||||
createObjectURL: vi.fn(() => 'blob:mock-url'),
|
||||
revokeObjectURL: vi.fn()
|
||||
} as any
|
||||
|
||||
describe('mediaCacheService', () => {
|
||||
describe('URL reference counting', () => {
|
||||
it('should handle URL acquisition for non-existent cache entry', () => {
|
||||
const { acquireUrl } = useMediaCache()
|
||||
|
||||
const url = acquireUrl('non-existent.jpg')
|
||||
expect(url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle URL release for non-existent cache entry', () => {
|
||||
const { releaseUrl } = useMediaCache()
|
||||
|
||||
// Should not throw error
|
||||
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should provide acquireUrl and releaseUrl methods', () => {
|
||||
const cache = useMediaCache()
|
||||
|
||||
expect(typeof cache.acquireUrl).toBe('function')
|
||||
expect(typeof cache.releaseUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
442
src/services/newUserService.test.ts
Normal file
442
src/services/newUserService.test.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
vi.mock('@/config/version', () => ({
|
||||
__COMFYUI_FRONTEND_VERSION__: '1.24.0'
|
||||
}))
|
||||
|
||||
//@ts-expect-error Define global for the test
|
||||
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
|
||||
|
||||
import type { newUserService as NewUserServiceType } from '@/services/newUserService'
|
||||
|
||||
describe('newUserService', () => {
|
||||
let service: ReturnType<typeof NewUserServiceType>
|
||||
let mockSettingStore: any
|
||||
let newUserService: typeof NewUserServiceType
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.resetModules()
|
||||
|
||||
const module = await import('@/services/newUserService')
|
||||
newUserService = module.newUserService
|
||||
|
||||
service = newUserService()
|
||||
|
||||
mockSettingStore = {
|
||||
settingValues: {},
|
||||
get: vi.fn(),
|
||||
set: vi.fn()
|
||||
}
|
||||
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('checkIsNewUser logic', () => {
|
||||
it('should identify new user when all conditions are met', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify new user when settings exist but TutorialCompleted is undefined', async () => {
|
||||
mockSettingStore.settingValues = { 'some.setting': 'value' }
|
||||
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify existing user when tutorial is completed', async () => {
|
||||
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': true }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return true
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when workflow exists', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'workflow') return 'some-workflow'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when previous workflow exists', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.PreviousWorkflow') return 'some-previous-workflow'
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify new user when tutorial is explicitly false', async () => {
|
||||
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return false
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should identify existing user when has both settings and tutorial completed', async () => {
|
||||
mockSettingStore.settingValues = {
|
||||
'some.setting': 'value',
|
||||
'Comfy.TutorialCompleted': true
|
||||
}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return true
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
|
||||
it('should identify existing user when only one condition fails', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'workflow') return 'some-workflow'
|
||||
if (key === 'Comfy.PreviousWorkflow') return null
|
||||
return null
|
||||
})
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerInitCallback', () => {
|
||||
it('should execute callback immediately if new user is already determined', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
|
||||
await service.registerInitCallback(mockCallback)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should queue callbacks when user status is not determined', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await service.registerInitCallback(mockCallback)
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
expect(service.isNewUser()).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle callback errors gracefully', async () => {
|
||||
const mockCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Callback error'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
await service.registerInitCallback(mockCallback)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'New user initialization callback failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initializeIfNewUser', () => {
|
||||
it('should set installed version for new users', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.InstalledVersion',
|
||||
'1.24.0'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not set installed version for existing users', async () => {
|
||||
mockSettingStore.settingValues = { 'some.setting': 'value' }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return true
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute pending callbacks for new users', async () => {
|
||||
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await service.registerInitCallback(mockCallback1)
|
||||
await service.registerInitCallback(mockCallback2)
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledTimes(1)
|
||||
expect(mockCallback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not execute pending callbacks for existing users', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await service.registerInitCallback(mockCallback)
|
||||
|
||||
mockSettingStore.settingValues = { 'some.setting': 'value' }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return true
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle callback errors during initialization', async () => {
|
||||
const mockCallback = vi.fn().mockRejectedValue(new Error('Init error'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await service.registerInitCallback(mockCallback)
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'New user initialization callback failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not reinitialize if already determined', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should correctly determine new user status', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
// Before initialization, isNewUser should return null
|
||||
expect(service.isNewUser()).toBeNull()
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
// After initialization, isNewUser should return true for a new user
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
|
||||
// Should set the installed version for new users
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.InstalledVersion',
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNewUser', () => {
|
||||
it('should return null before determination', () => {
|
||||
expect(service.isNewUser()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return cached result after determination', async () => {
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockReturnValue(undefined)
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle settingStore.get returning false as not completed', async () => {
|
||||
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return false
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service.isNewUser()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multiple callback registrations after initialization', async () => {
|
||||
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
await service.registerInitCallback(mockCallback1)
|
||||
await service.registerInitCallback(mockCallback2)
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledTimes(1)
|
||||
expect(mockCallback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state sharing between instances', () => {
|
||||
it('should share state between multiple service instances', async () => {
|
||||
const service1 = newUserService()
|
||||
const service2 = newUserService()
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service1.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(service2.isNewUser()).toBe(true)
|
||||
expect(service1.isNewUser()).toBe(service2.isNewUser())
|
||||
})
|
||||
|
||||
it('should execute callbacks registered on different instances', async () => {
|
||||
const service1 = newUserService()
|
||||
const service2 = newUserService()
|
||||
|
||||
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await service1.registerInitCallback(mockCallback1)
|
||||
await service2.registerInitCallback(mockCallback2)
|
||||
|
||||
mockSettingStore.settingValues = {}
|
||||
mockSettingStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.TutorialCompleted') return undefined
|
||||
return undefined
|
||||
})
|
||||
mockLocalStorage.getItem.mockReturnValue(null)
|
||||
|
||||
await service1.initializeIfNewUser(mockSettingStore)
|
||||
|
||||
expect(mockCallback1).toHaveBeenCalledTimes(1)
|
||||
expect(mockCallback2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
330
src/services/nodeOrganizationService.test.ts
Normal file
330
src/services/nodeOrganizationService.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
|
||||
describe('nodeOrganizationService', () => {
|
||||
const createMockNodeDef = (overrides: any = {}) => {
|
||||
const mockNodeDef = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
category: 'test/subcategory',
|
||||
python_module: 'custom_nodes.MyPackage.nodes',
|
||||
api_node: false,
|
||||
nodeSource: {
|
||||
type: NodeSourceType.CustomNodes,
|
||||
className: 'comfy-custom',
|
||||
displayText: 'Custom',
|
||||
badgeText: 'C'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
|
||||
return mockNodeDef as ComfyNodeDefImpl
|
||||
}
|
||||
|
||||
describe('getGroupingStrategies', () => {
|
||||
it('should return all grouping strategies', () => {
|
||||
const strategies = nodeOrganizationService.getGroupingStrategies()
|
||||
expect(strategies).toHaveLength(3)
|
||||
expect(strategies.map((s) => s.id)).toEqual([
|
||||
'category',
|
||||
'module',
|
||||
'source'
|
||||
])
|
||||
})
|
||||
|
||||
it('should return immutable copy', () => {
|
||||
const strategies1 = nodeOrganizationService.getGroupingStrategies()
|
||||
const strategies2 = nodeOrganizationService.getGroupingStrategies()
|
||||
expect(strategies1).not.toBe(strategies2)
|
||||
expect(strategies1).toEqual(strategies2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGroupingStrategy', () => {
|
||||
it('should return strategy by id', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
expect(strategy).toBeDefined()
|
||||
expect(strategy?.id).toBe('category')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown id', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
|
||||
expect(strategy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingStrategies', () => {
|
||||
it('should return all sorting strategies', () => {
|
||||
const strategies = nodeOrganizationService.getSortingStrategies()
|
||||
expect(strategies).toHaveLength(2)
|
||||
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingStrategy', () => {
|
||||
it('should return strategy by id', () => {
|
||||
const strategy =
|
||||
nodeOrganizationService.getSortingStrategy('alphabetical')
|
||||
expect(strategy).toBeDefined()
|
||||
expect(strategy?.id).toBe('alphabetical')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown id', () => {
|
||||
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
|
||||
expect(strategy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('organizeNodes', () => {
|
||||
const mockNodes = [
|
||||
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
|
||||
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
|
||||
]
|
||||
|
||||
it('should organize nodes with default options', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes)
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should organize nodes with custom grouping', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
groupBy: 'module'
|
||||
})
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should organize nodes with custom sorting', () => {
|
||||
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
sortBy: 'alphabetical'
|
||||
})
|
||||
expect(tree).toBeDefined()
|
||||
expect(tree.children).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw error for unknown grouping strategy', () => {
|
||||
expect(() => {
|
||||
nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
groupBy: 'unknown'
|
||||
})
|
||||
}).toThrow('Unknown grouping strategy: unknown')
|
||||
})
|
||||
|
||||
it('should throw error for unknown sorting strategy', () => {
|
||||
expect(() => {
|
||||
nodeOrganizationService.organizeNodes(mockNodes, {
|
||||
sortBy: 'unknown'
|
||||
})
|
||||
}).toThrow('Unknown sorting strategy: unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGroupingIcon', () => {
|
||||
it('should return strategy icon', () => {
|
||||
const icon = nodeOrganizationService.getGroupingIcon('category')
|
||||
expect(icon).toBe('pi pi-folder')
|
||||
})
|
||||
|
||||
it('should return fallback icon for unknown strategy', () => {
|
||||
const icon = nodeOrganizationService.getGroupingIcon('unknown')
|
||||
expect(icon).toBe('pi pi-sort')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortingIcon', () => {
|
||||
it('should return strategy icon', () => {
|
||||
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
|
||||
expect(icon).toBe('pi pi-sort-alpha-down')
|
||||
})
|
||||
|
||||
it('should return fallback icon for unknown strategy', () => {
|
||||
const icon = nodeOrganizationService.getSortingIcon('unknown')
|
||||
expect(icon).toBe('pi pi-sort')
|
||||
})
|
||||
})
|
||||
|
||||
describe('grouping path extraction', () => {
|
||||
const mockNodeDef = createMockNodeDef()
|
||||
|
||||
it('category grouping should use category path', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
|
||||
})
|
||||
|
||||
it('module grouping should extract module path', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('module')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['MyPackage', 'TestNode'])
|
||||
})
|
||||
|
||||
it('source grouping should categorize by source type', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('source')
|
||||
const path = strategy?.getNodePath(mockNodeDef)
|
||||
expect(path).toEqual(['Custom nodes', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
describe('module grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('module')
|
||||
|
||||
it('should handle empty python_module', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: '' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['unknown_module', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle undefined python_module', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: undefined })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['unknown_module', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle modules with spaces in the name', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.My Package With Spaces.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle modules with special characters', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.my-package_v2.0.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['my-package_v2', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle deeply nested modules', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'custom_nodes.package.subpackage.module.nodes'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['package', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle core nodes module path', () => {
|
||||
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['core', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle non-standard module paths', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
python_module: 'some.other.module.path'
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('category grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
|
||||
it('should handle empty category', () => {
|
||||
const nodeDef = createMockNodeDef({ category: '' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['TestNode'])
|
||||
})
|
||||
|
||||
it('should handle undefined category', () => {
|
||||
const nodeDef = createMockNodeDef({ category: undefined })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['TestNode'])
|
||||
})
|
||||
|
||||
it('should handle category with trailing slash', () => {
|
||||
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle category with multiple consecutive slashes', () => {
|
||||
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('source grouping edge cases', () => {
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('source')
|
||||
|
||||
it('should handle API nodes', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
api_node: true,
|
||||
nodeSource: {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
displayText: 'Core',
|
||||
badgeText: 'C'
|
||||
}
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['API nodes', 'TestNode'])
|
||||
})
|
||||
|
||||
it('should handle unknown source type', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
nodeSource: {
|
||||
type: 'unknown' as any,
|
||||
className: 'unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
}
|
||||
})
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['Unknown', 'TestNode'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('node name edge cases', () => {
|
||||
it('should handle nodes with special characters in name', () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'Test/Node:With*Special<Chars>'
|
||||
})
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual([
|
||||
'test',
|
||||
'subcategory',
|
||||
'Test/Node:With*Special<Chars>'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle nodes with very long names', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const nodeDef = createMockNodeDef({ name: longName })
|
||||
const strategy = nodeOrganizationService.getGroupingStrategy('category')
|
||||
const path = strategy?.getNodePath(nodeDef)
|
||||
expect(path).toEqual(['test', 'subcategory', longName])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting comparison', () => {
|
||||
it('original sort should keep order', () => {
|
||||
const strategy = nodeOrganizationService.getSortingStrategy('original')
|
||||
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
|
||||
const nodeB = createMockNodeDef({ display_name: 'Apple' })
|
||||
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
|
||||
})
|
||||
|
||||
it('alphabetical sort should compare display names', () => {
|
||||
const strategy =
|
||||
nodeOrganizationService.getSortingStrategy('alphabetical')
|
||||
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
|
||||
const nodeB = createMockNodeDef({ display_name: 'Apple' })
|
||||
|
||||
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
|
||||
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
77
src/services/nodeSearchService.test.ts
Normal file
77
src/services/nodeSearchService.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { NodeSearchService } from '@/services/nodeSearchService'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const EXAMPLE_NODE_DEFS: ComfyNodeDefImpl[] = (
|
||||
[
|
||||
{
|
||||
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
|
||||
},
|
||||
{
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT'],
|
||||
batch_index: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 63
|
||||
}
|
||||
],
|
||||
length: [
|
||||
'INT',
|
||||
{
|
||||
default: 1,
|
||||
min: 1,
|
||||
max: 64
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'LatentFromBatch',
|
||||
display_name: 'Latent From Batch',
|
||||
description: '',
|
||||
python_module: 'nodes',
|
||||
category: 'latent/batch',
|
||||
output_node: false
|
||||
}
|
||||
] as ComfyNodeDef[]
|
||||
).map((nodeDef: ComfyNodeDef) => {
|
||||
const def = new ComfyNodeDefImpl(nodeDef)
|
||||
def['postProcessSearchScores'] = (s) => s
|
||||
return def
|
||||
})
|
||||
|
||||
describe('nodeSearchService', () => {
|
||||
it('searches with input filter', () => {
|
||||
const service = new NodeSearchService(EXAMPLE_NODE_DEFS)
|
||||
const inputFilter = service.inputTypeFilter
|
||||
expect(
|
||||
service.searchNode('L', [{ filterDef: inputFilter, value: 'LATENT' }])
|
||||
).toHaveLength(1)
|
||||
// Wildcard should match all.
|
||||
expect(
|
||||
service.searchNode('L', [{ filterDef: inputFilter, value: '*' }])
|
||||
).toHaveLength(2)
|
||||
expect(service.searchNode('L')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
373
src/services/providers/algoliaSearchProvider.test.ts
Normal file
373
src/services/providers/algoliaSearchProvider.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
// Mock global Algolia constants
|
||||
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
|
||||
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
|
||||
|
||||
// Mock algoliasearch
|
||||
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
|
||||
liteClient: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useAlgoliaSearchProvider', () => {
|
||||
let mockSearchClient: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create mock search client
|
||||
mockSearchClient = {
|
||||
search: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clear the module-level cache between tests
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
provider.clearSearchCache()
|
||||
})
|
||||
|
||||
describe('searchPacks', () => {
|
||||
it('should search for packs and convert results', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{
|
||||
hits: [
|
||||
{
|
||||
objectID: 'algolia-1',
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
description: 'A test pack',
|
||||
publisher_id: 'publisher-1',
|
||||
total_install: 500,
|
||||
create_time: '2024-01-01T00:00:00Z',
|
||||
update_time: '2024-01-15T00:00:00Z',
|
||||
repository_url: 'https://github.com/test/pack',
|
||||
license: 'MIT',
|
||||
status: 'active',
|
||||
latest_version: '1.0.0',
|
||||
latest_version_status: 'published',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||
}
|
||||
]
|
||||
},
|
||||
{ hits: [] } // Query suggestions
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [
|
||||
{
|
||||
query: 'test',
|
||||
indexName: 'nodes_index',
|
||||
attributesToRetrieve: expect.any(Array),
|
||||
hitsPerPage: 10,
|
||||
page: 0
|
||||
},
|
||||
{
|
||||
query: 'test',
|
||||
indexName: 'nodes_index_query_suggestions'
|
||||
}
|
||||
],
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toHaveLength(1)
|
||||
expect(result.nodePacks[0]).toEqual({
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
description: 'A test pack',
|
||||
repository: 'https://github.com/test/pack',
|
||||
license: 'MIT',
|
||||
downloads: 500,
|
||||
status: 'active',
|
||||
icon: 'https://example.com/icon.png',
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
comfy_node_extract_status: undefined
|
||||
},
|
||||
publisher: {
|
||||
id: 'publisher-1',
|
||||
name: 'publisher-1'
|
||||
},
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
comfy_nodes: ['LoadImage', 'SaveImage'],
|
||||
category: undefined,
|
||||
author: undefined,
|
||||
tags: undefined,
|
||||
github_stars: undefined,
|
||||
supported_os: undefined,
|
||||
supported_comfyui_version: undefined,
|
||||
supported_comfyui_frontend_version: undefined,
|
||||
supported_accelerators: undefined,
|
||||
banner_url: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should include query suggestions when query is long enough', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{ hits: [] }, // Main results
|
||||
{
|
||||
hits: [
|
||||
{ query: 'test query', popularity: 10 },
|
||||
{ query: 'test pack', popularity: 5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// Should make 2 requests (main + suggestions)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [
|
||||
expect.objectContaining({ indexName: 'nodes_index' }),
|
||||
expect.objectContaining({
|
||||
indexName: 'nodes_index_query_suggestions'
|
||||
})
|
||||
],
|
||||
strategy: 'none'
|
||||
})
|
||||
|
||||
expect(result.querySuggestions).toEqual([
|
||||
{ query: 'test query', popularity: 10 },
|
||||
{ query: 'test pack', popularity: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it('should not query suggestions for short queries', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
await provider.searchPacks('a', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// Should only make 1 request (no suggestions)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||
requests: [expect.objectContaining({ indexName: 'nodes_index' })],
|
||||
strategy: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('should cache search results', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }, { hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const params = { pageSize: 10, pageNumber: 0 }
|
||||
|
||||
// First call
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call with same params should use cache
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Different params should make new request
|
||||
await provider.searchPacks('test', { ...params, pageNumber: 1 })
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle missing objectID by using id field', async () => {
|
||||
const mockAlgoliaResults = {
|
||||
results: [
|
||||
{
|
||||
hits: [
|
||||
{
|
||||
id: 'pack-id-only',
|
||||
name: 'Pack without objectID',
|
||||
// ... other required fields
|
||||
publisher_id: 'pub',
|
||||
total_install: 0,
|
||||
comfy_nodes: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{ hits: [] }
|
||||
]
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks[0].id).toBe('pack-id-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSearchCache', () => {
|
||||
it('should clear the cache', async () => {
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [{ hits: [] }, { hits: [] }]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const params = { pageSize: 10, pageNumber: 0 }
|
||||
|
||||
// Populate cache
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Clear cache
|
||||
provider.clearSearchCache()
|
||||
|
||||
// Same search should hit API again
|
||||
await provider.searchPacks('test', params)
|
||||
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortValue', () => {
|
||||
const testPack = {
|
||||
id: '1',
|
||||
name: 'Test Pack',
|
||||
downloads: 100,
|
||||
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
},
|
||||
created_at: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
|
||||
it('should return correct values for each sort field', () => {
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
|
||||
).toBe(100)
|
||||
expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
|
||||
'Test Pack'
|
||||
)
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
|
||||
).toBe('Publisher One')
|
||||
|
||||
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
|
||||
expect(
|
||||
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
|
||||
).toBe(createdTimestamp)
|
||||
|
||||
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||
expect(
|
||||
provider.getSortValue(testPack, SortableAlgoliaField.Updated)
|
||||
).toBe(updatedTimestamp)
|
||||
})
|
||||
|
||||
it('should handle missing values', () => {
|
||||
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
|
||||
).toBe('')
|
||||
expect(
|
||||
provider.getSortValue(
|
||||
incompletePack as any,
|
||||
SortableAlgoliaField.Created
|
||||
)
|
||||
).toBe(0)
|
||||
expect(
|
||||
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortableFields', () => {
|
||||
it('should return all Algolia sort fields', () => {
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const fields = provider.getSortableFields()
|
||||
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
id: SortableAlgoliaField.Downloads,
|
||||
label: 'Downloads',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Created,
|
||||
label: 'Created',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Updated,
|
||||
label: 'Updated',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
id: SortableAlgoliaField.Publisher,
|
||||
label: 'Publisher',
|
||||
direction: 'asc'
|
||||
},
|
||||
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should memoize toRegistryPack conversions', async () => {
|
||||
const mockHit = {
|
||||
objectID: 'algolia-1',
|
||||
id: 'pack-1',
|
||||
name: 'Test Pack',
|
||||
publisher_id: 'pub1',
|
||||
total_install: 100,
|
||||
comfy_nodes: []
|
||||
}
|
||||
|
||||
mockSearchClient.search.mockResolvedValue({
|
||||
results: [
|
||||
{ hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
|
||||
{ hits: [] }
|
||||
]
|
||||
})
|
||||
|
||||
const provider = useAlgoliaSearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
// All 3 results should be the same object reference due to memoization
|
||||
expect(result.nodePacks[0]).toBe(result.nodePacks[1])
|
||||
expect(result.nodePacks[1]).toBe(result.nodePacks[2])
|
||||
})
|
||||
})
|
||||
})
|
||||
186
src/services/providers/registrySearchProvider.test.ts
Normal file
186
src/services/providers/registrySearchProvider.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
|
||||
// Mock the store
|
||||
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||
useComfyRegistryStore: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useComfyRegistrySearchProvider', () => {
|
||||
const mockSearchCall = vi.fn()
|
||||
const mockSearchClear = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup store mock
|
||||
vi.mocked(useComfyRegistryStore).mockReturnValue({
|
||||
search: {
|
||||
call: mockSearchCall,
|
||||
clear: mockSearchClear
|
||||
}
|
||||
} as any)
|
||||
})
|
||||
|
||||
describe('searchPacks', () => {
|
||||
it('should search for packs by name', async () => {
|
||||
const mockResults = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Test Pack 1' },
|
||||
{ id: '2', name: 'Test Pack 2' }
|
||||
]
|
||||
}
|
||||
mockSearchCall.mockResolvedValue(mockResults)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0,
|
||||
restrictSearchableAttributes: ['name', 'description']
|
||||
})
|
||||
|
||||
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||
search: 'test',
|
||||
comfy_node_search: undefined,
|
||||
limit: 10,
|
||||
page: 1
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should search for packs by node names', async () => {
|
||||
const mockResults = {
|
||||
nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
|
||||
}
|
||||
mockSearchCall.mockResolvedValue(mockResults)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('LoadImage', {
|
||||
pageSize: 20,
|
||||
pageNumber: 1,
|
||||
restrictSearchableAttributes: ['comfy_nodes']
|
||||
})
|
||||
|
||||
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||
search: undefined,
|
||||
comfy_node_search: 'LoadImage',
|
||||
limit: 20,
|
||||
page: 2
|
||||
})
|
||||
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||
})
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockSearchCall.mockResolvedValue({ nodes: [] })
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('nonexistent', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle null results', async () => {
|
||||
mockSearchCall.mockResolvedValue(null)
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle results without nodes property', async () => {
|
||||
mockSearchCall.mockResolvedValue({})
|
||||
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const result = await provider.searchPacks('test', {
|
||||
pageSize: 10,
|
||||
pageNumber: 0
|
||||
})
|
||||
|
||||
expect(result.nodePacks).toEqual([])
|
||||
expect(result.querySuggestions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSearchCache', () => {
|
||||
it('should delegate to store search.clear', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
provider.clearSearchCache()
|
||||
|
||||
expect(mockSearchClear).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortValue', () => {
|
||||
const testPack = {
|
||||
id: '1',
|
||||
name: 'Test Pack',
|
||||
downloads: 100,
|
||||
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
createdAt: '2024-01-15T10:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
it('should return download count for downloads field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
|
||||
})
|
||||
|
||||
it('should return pack name for name field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
|
||||
})
|
||||
|
||||
it('should return publisher name for publisher field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
|
||||
})
|
||||
|
||||
it('should return timestamp for updated field', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
|
||||
})
|
||||
|
||||
it('should handle missing values gracefully', () => {
|
||||
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
|
||||
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
|
||||
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
|
||||
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 0 for unknown sort fields', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortableFields', () => {
|
||||
it('should return supported sort fields', () => {
|
||||
const provider = useComfyRegistrySearchProvider()
|
||||
const fields = provider.getSortableFields()
|
||||
|
||||
expect(fields).toEqual([
|
||||
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
|
||||
{ id: 'name', label: 'Name', direction: 'asc' },
|
||||
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
|
||||
{ id: 'updated', label: 'Updated', direction: 'desc' }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user