From 0debd00b65b73658f7130c051049b78f341ccd9e Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 24 Jan 2026 07:14:02 -0800 Subject: [PATCH] Add more testing --- .../composables/useOutputStacks.test.ts | 200 ++++++++++++++++++ .../assets/utils/outputAssetUtil.test.ts | 123 +++++++++++ src/services/jobOutputCache.test.ts | 81 +++++++ 3 files changed, 404 insertions(+) create mode 100644 src/platform/assets/composables/useOutputStacks.test.ts create mode 100644 src/platform/assets/utils/outputAssetUtil.test.ts diff --git a/src/platform/assets/composables/useOutputStacks.test.ts b/src/platform/assets/composables/useOutputStacks.test.ts new file mode 100644 index 000000000..d770b3c75 --- /dev/null +++ b/src/platform/assets/composables/useOutputStacks.test.ts @@ -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 = { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolveFn, rejectFn) => { + resolve = resolveFn + reject = rejectFn + }) + return { promise, resolve, reject } +} + +function createAsset(overrides: Partial = {}): 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() + + 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 + ]) + }) +}) diff --git a/src/platform/assets/utils/outputAssetUtil.test.ts b/src/platform/assets/utils/outputAssetUtil.test.ts new file mode 100644 index 000000000..41779958b --- /dev/null +++ b/src/platform/assets/utils/outputAssetUtil.test.ts @@ -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' + ]) + }) +}) diff --git a/src/services/jobOutputCache.test.ts b/src/services/jobOutputCache.test.ts index 38e47e973..3b90ab2fe 100644 --- a/src/services/jobOutputCache.test.ts +++ b/src/services/jobOutputCache.test.ts @@ -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')