mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 17:40:09 +00:00
Add more testing
This commit is contained in:
200
src/platform/assets/composables/useOutputStacks.test.ts
Normal file
200
src/platform/assets/composables/useOutputStacks.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveOutputAssetItems: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
|
||||
resolveOutputAssetItems: mocks.resolveOutputAssetItems
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolveFn, rejectFn) => {
|
||||
resolve = resolveFn
|
||||
reject = rejectFn
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'parent.png',
|
||||
tags: [],
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
user_metadata: {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: 'node-1',
|
||||
subfolder: 'outputs'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useOutputStacks', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('expands stacks and exposes children as selectable assets', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const childA = createAsset({
|
||||
id: 'child-a',
|
||||
name: 'child-a.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
const childB = createAsset({
|
||||
id: 'child-b',
|
||||
name: 'child-b.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
|
||||
|
||||
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
|
||||
useOutputStacks({ assets: ref([parent]) })
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ promptId: 'prompt-1' }),
|
||||
{
|
||||
createdAt: parent.created_at,
|
||||
excludeOutputKey: parent.name
|
||||
}
|
||||
)
|
||||
expect(isStackExpanded(parent)).toBe(true)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
parent.id,
|
||||
childA.id,
|
||||
childB.id
|
||||
])
|
||||
expect(assetItems.value[1]).toMatchObject({
|
||||
asset: childA,
|
||||
isChild: true
|
||||
})
|
||||
expect(assetItems.value[2]).toMatchObject({
|
||||
asset: childB,
|
||||
isChild: true
|
||||
})
|
||||
expect(selectableAssets.value).toEqual([parent, childA, childB])
|
||||
})
|
||||
|
||||
it('collapses an expanded stack when toggled again', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const child = createAsset({
|
||||
id: 'child',
|
||||
name: 'child.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
})
|
||||
|
||||
it('ignores assets without stack metadata', async () => {
|
||||
const asset = createAsset({
|
||||
id: 'no-meta',
|
||||
name: 'no-meta.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([asset])
|
||||
})
|
||||
|
||||
await toggleStack(asset)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(isStackExpanded(asset)).toBe(false)
|
||||
expect(assetItems.value).toHaveLength(1)
|
||||
expect(assetItems.value[0].asset).toMatchObject(asset)
|
||||
})
|
||||
|
||||
it('does not expand when no children are resolved', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
})
|
||||
|
||||
it('does not expand when resolving children throws', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
|
||||
new Error('resolve failed')
|
||||
)
|
||||
|
||||
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
await toggleStack(parent)
|
||||
|
||||
expect(isStackExpanded(parent)).toBe(false)
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('guards against duplicate loads while a stack is resolving', async () => {
|
||||
const parent = createAsset({ id: 'parent', name: 'parent.png' })
|
||||
const child = createAsset({
|
||||
id: 'child',
|
||||
name: 'child.png',
|
||||
user_metadata: undefined
|
||||
})
|
||||
const deferred = createDeferred<AssetItem[]>()
|
||||
|
||||
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
|
||||
|
||||
const { assetItems, toggleStack } = useOutputStacks({
|
||||
assets: ref([parent])
|
||||
})
|
||||
|
||||
const firstToggle = toggleStack(parent)
|
||||
const secondToggle = toggleStack(parent)
|
||||
|
||||
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferred.resolve([child])
|
||||
|
||||
await firstToggle
|
||||
await secondToggle
|
||||
|
||||
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
|
||||
parent.id,
|
||||
child.id
|
||||
])
|
||||
})
|
||||
})
|
||||
123
src/platform/assets/utils/outputAssetUtil.test.ts
Normal file
123
src/platform/assets/utils/outputAssetUtil.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import { resolveOutputAssetItems } from './outputAssetUtil'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getJobDetail: vi.fn(),
|
||||
getPreviewableOutputsFromJobDetail: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/jobOutputCache', () => ({
|
||||
getJobDetail: mocks.getJobDetail,
|
||||
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
|
||||
}))
|
||||
|
||||
type OutputOverrides = Partial<{
|
||||
filename: string
|
||||
subfolder: string
|
||||
nodeId: string
|
||||
url: string
|
||||
}>
|
||||
|
||||
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
return {
|
||||
filename: 'file.png',
|
||||
subfolder: 'sub',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/file.png',
|
||||
...overrides
|
||||
} as ResultItemImpl
|
||||
}
|
||||
|
||||
describe('resolveOutputAssetItems', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('maps outputs and excludes a filename', async () => {
|
||||
const outputA = createOutput({
|
||||
filename: 'a.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/a.png'
|
||||
})
|
||||
const outputB = createOutput({
|
||||
filename: 'b.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/b.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5,
|
||||
outputCount: 2,
|
||||
allOutputs: [outputA, outputB]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
excludeOutputKey: 'b.png'
|
||||
})
|
||||
|
||||
expect(mocks.getJobDetail).not.toHaveBeenCalled()
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'prompt-1-1-a.png',
|
||||
name: 'a.png',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/a.png'
|
||||
})
|
||||
)
|
||||
expect(results[0].user_metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
executionTimeInSeconds: 12.5
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('loads full outputs when metadata indicates more outputs', async () => {
|
||||
const previewOutput = createOutput({
|
||||
filename: 'preview.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/preview.png'
|
||||
})
|
||||
const fullOutput = createOutput({
|
||||
filename: 'full.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/full.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
promptId: 'prompt-2',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 3,
|
||||
allOutputs: [previewOutput]
|
||||
}
|
||||
const jobDetail = { id: 'job-1' }
|
||||
|
||||
mocks.getJobDetail.mockResolvedValue(jobDetail)
|
||||
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
|
||||
fullOutput,
|
||||
previewOutput
|
||||
])
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
expect(results.map((asset) => asset.name)).toEqual([
|
||||
'full.png',
|
||||
'preview.png'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -180,6 +180,87 @@ describe('jobOutputCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPreviewableOutputsFromJobDetail', () => {
|
||||
it('returns empty array when job detail or outputs are missing', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
|
||||
expect(getPreviewableOutputsFromJobDetail(undefined)).toEqual([])
|
||||
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-empty',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0
|
||||
}
|
||||
|
||||
expect(getPreviewableOutputsFromJobDetail(jobDetail)).toEqual([])
|
||||
})
|
||||
|
||||
it('maps previewable outputs and skips animated/text entries', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-previewable',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0,
|
||||
outputs: {
|
||||
'node-1': {
|
||||
images: [
|
||||
{ filename: 'image.png', subfolder: '', type: 'output' },
|
||||
{ filename: 'image.webp', subfolder: '', type: 'temp' }
|
||||
],
|
||||
animated: [true],
|
||||
text: 'hello'
|
||||
},
|
||||
'node-2': {
|
||||
video: [{ filename: 'clip.mp4', subfolder: '', type: 'output' }],
|
||||
audio: [{ filename: 'sound.mp3', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result.map((item) => item.filename).sort()).toEqual(
|
||||
['image.png', 'image.webp', 'clip.mp4', 'sound.mp3'].sort()
|
||||
)
|
||||
|
||||
const image = result.find((item) => item.filename === 'image.png')
|
||||
const video = result.find((item) => item.filename === 'clip.mp4')
|
||||
|
||||
expect(image).toBeInstanceOf(ResultItemImpl)
|
||||
expect(image?.nodeId).toBe('node-1')
|
||||
expect(image?.mediaType).toBe('images')
|
||||
expect(video?.nodeId).toBe('node-2')
|
||||
expect(video?.mediaType).toBe('video')
|
||||
})
|
||||
|
||||
it('filters non-previewable outputs and non-object items', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-filter',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0,
|
||||
outputs: {
|
||||
'node-3': {
|
||||
images: [{ filename: 'valid.png', subfolder: '', type: 'output' }],
|
||||
text: ['not-object'],
|
||||
unknown: [{ filename: 'data.bin', subfolder: '', type: 'output' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
|
||||
expect(result.map((item) => item.filename)).toEqual(['valid.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getJobDetail', () => {
|
||||
it('fetches and caches job detail', async () => {
|
||||
const { getJobDetail } = await import('@/services/jobOutputCache')
|
||||
|
||||
Reference in New Issue
Block a user