import { describe, expect, it } from 'vitest'; import type { EditorAsset, GenerateDialogState, } from './ImageCanvasEditorTypes'; import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel'; import { appendLimitedImageReferences, createGenerationInputReference, } from './ImageCanvasGenerationModel'; import { PROJECT_ASSET_PICKER_CATEGORY_OPTIONS, projectAssetMatchesPickerFilter, projectAssetPickerCategory, projectAssetPickerReferences, projectAssetPickerUsesThumbnail, } from './projectAssetReferencePickerModel'; function createAsset(overrides: Partial): EditorAsset { return { id: 'asset-1', label: '素材 1', src: 'https://example.test/asset-1.png', mediaType: 'image', width: 64, height: 64, folderId: 'folder-1', sourceKind: 'uploaded', sourceType: 'uploaded', persisted: true, ...overrides, }; } describe('参考图弹窗素材筛选与快照', () => { it('derives the category from the asset media type', () => { expect(projectAssetPickerCategory({ mediaType: 'image' })).toBe('image'); expect(projectAssetPickerCategory({ mediaType: 'video' })).toBe('video'); expect(projectAssetPickerCategory({ mediaType: 'audio' })).toBe('audio'); expect(projectAssetPickerCategory({})).toBe('image'); // 序列帧单列一类:它不是静态图片,参考图链路也不接受它(见下一条用例)。 expect(projectAssetPickerCategory({ mediaType: 'image-sequence' })).toBe( 'image-sequence', ); expect( PROJECT_ASSET_PICKER_CATEGORY_OPTIONS.map((item) => item.id), ).toEqual(['all', 'image', 'video', 'audio', 'image-sequence']); }); it('keeps image-sequence assets out of the image category', () => { const sequence = createAsset({ mediaType: 'image-sequence' }); expect( projectAssetMatchesPickerFilter(sequence, { category: 'image', query: '', }), ).toBe(false); expect( projectAssetMatchesPickerFilter(sequence, { category: 'image-sequence', query: '', }), ).toBe(true); expect( projectAssetMatchesPickerFilter(sequence, { category: 'all', query: '' }), ).toBe(true); // 缩略图口径跟分类走:序列帧的 src 也是图片字节,照旧画出来。 expect(projectAssetPickerUsesThumbnail('image-sequence')).toBe(true); expect(projectAssetPickerUsesThumbnail('image')).toBe(true); expect(projectAssetPickerUsesThumbnail('video')).toBe(false); expect(projectAssetPickerUsesThumbnail('audio')).toBe(false); }); it('参考图链路只接受图片:序列帧引用会被 appendLimitedImageReferences 过滤掉', () => { // 这条用例把「分类展示」与「参考图接受口径」的边界显式钉住:序列帧可以看见、 // 可以选中(与音频 / 视频一致),但不会作为参考图进入提交数据。 const sequence = createAsset({ id: 'asset-sequence', mediaType: 'image-sequence', label: '勇者挥手', }); const references = projectAssetPickerReferences( [sequence], ['asset-sequence'], ); expect(references[0]?.mediaType).toBe('image-sequence'); expect(appendLimitedImageReferences(undefined, references, 4)).toEqual([]); }); it('stacks the category filter with the search query', () => { const asset = createAsset({ label: '小镇背景', assetKind: 'scene' }); expect( projectAssetMatchesPickerFilter(asset, { category: 'all', query: '' }), ).toBe(true); expect( projectAssetMatchesPickerFilter(asset, { category: 'image', query: '小镇', }), ).toBe(true); expect( projectAssetMatchesPickerFilter(asset, { category: 'image', query: 'scene', }), ).toBe(true); expect( projectAssetMatchesPickerFilter(asset, { category: 'audio', query: '小镇', }), ).toBe(false); expect( projectAssetMatchesPickerFilter(asset, { category: 'all', query: '主角', }), ).toBe(false); }); it('builds references that carry the asset id snapshot', () => { const assets = [ createAsset({ id: 'asset-hero', label: '主角立绘', objectKey: 'oss/hero.png', }), createAsset({ id: 'asset-bgm', label: '背景音乐', mediaType: 'audio', sourceResourceId: 'resource-bgm', }), ]; const references = projectAssetPickerReferences(assets, [ 'asset-hero', 'asset-bgm', 'asset-missing', ]); expect(references).toEqual([ { id: 'project-asset-asset-hero', label: '主角立绘', src: 'oss/hero.png', mediaType: 'image', objectKey: 'oss/hero.png', resourceId: null, sourceAssetId: 'asset-hero', }, { id: 'project-asset-asset-bgm', label: '背景音乐', src: 'https://example.test/asset-1.png', mediaType: 'audio', resourceId: 'resource-bgm', sourceAssetId: 'asset-bgm', }, ]); }); it('records the selected asset ids as the submitted task reference snapshot', () => { const assets = [ createAsset({ id: 'asset-hero', label: '主角立绘' }), createAsset({ id: 'asset-town', label: '小镇背景' }), ]; const references = projectAssetPickerReferences(assets, [ 'asset-hero', 'asset-town', ]); const snapshot = references.flatMap((reference) => createGenerationInputReference('参考图', reference), ); expect(snapshot).toEqual([ { title: '参考图', label: '主角立绘', refType: 'asset', refId: 'asset-hero', }, { title: '参考图', label: '小镇背景', refType: 'asset', refId: 'asset-town', }, ]); }); it('returns no references and dedupes repeated ids', () => { const assets = [createAsset({ id: 'asset-hero', label: '主角立绘' })]; expect(projectAssetPickerReferences(assets, [])).toEqual([]); expect( projectAssetPickerReferences(assets, ['asset-hero', 'asset-hero']), ).toHaveLength(1); }); it('replaces the library-sourced references and keeps canvas-picked ones', () => { const assets = [ createAsset({ id: 'asset-hero', label: '主角立绘' }), createAsset({ id: 'asset-town', label: '小镇背景' }), ]; const dialog = { mode: 'generate', prompt: '画一张封面', status: 'idle', generationReferences: [ { id: 'canvas-layer-1', label: '画布点选', src: 'https://example.test/canvas.png', mediaType: 'image', }, { id: 'project-asset-asset-old', label: '已移除素材', src: 'https://example.test/old.png', mediaType: 'image', sourceAssetId: 'asset-old', }, ], } satisfies GenerateDialogState; const next = replaceProjectAssetPickerReferences( dialog, projectAssetPickerReferences(assets, ['asset-hero', 'asset-town']), ); expect( next?.generationReferences?.map((reference) => reference.id), ).toEqual([ 'canvas-layer-1', 'project-asset-asset-hero', 'project-asset-asset-town', ]); expect(next?.composerOpen).toBe(true); }); it('leaves dialogs outside the reference-composer modes untouched', () => { const dialog = { mode: 'video', prompt: '', status: 'idle', } satisfies GenerateDialogState; expect(replaceProjectAssetPickerReferences(dialog, [])).toBe(dialog); expect(replaceProjectAssetPickerReferences(null, [])).toBeNull(); }); });