Files
Genarrative/src/components/image-editor/projectAssetReferencePickerModel.test.ts
T
suzmii a9a5fba92b 修复 PR #316 review(前端 TS/TSX):图片编辑器素材弹窗
- ImageCanvasProjectAssetPickerDialog.tsx:打开时的重置 effect 只依赖 `open`,父级每次渲染重建 `selectedAssetIds` 不再清空进行中的搜索 / 筛选 / 选择
- ImageCanvasProjectAssetPickerDialog.tsx:素材卡媒体渲染抽成 `AssetCardMedia`,顺带去掉该处嵌套三元
- projectAssetReferencePickerModel.ts:`image-sequence` 单列一类(新增「序列帧」页签),不再混进「图片」——参考图链路只接受 `mediaType === 'image'`,混进去就是「按图片选中、按图片文案拒掉」;缩略图口径由 `projectAssetPickerUsesThumbnail` 收口
- 用例:父级重渲染不清空在选状态;序列帧分类归属与其被参考图链路过滤掉的事实
2026-09-12 20:49:37 +08:00

257 lines
7.6 KiB
TypeScript

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>): 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();
});
});