Files
Genarrative/src/components/image-editor/ImageCanvasUploadModel.test.ts
kdletters d8bd371c69 抽出素材上传生命周期模型
扩展 ImageCanvasUploadModel 承载素材上传占位、进度、失败和持久化回写状态迁移

精简 useImageCanvasUploadWorkflow 中的资产与图层状态补丁逻辑

补充上传模型单测覆盖生命周期状态和图层绑定

更新 TRACKING.md 记录第四十六执行批次与验证结果
2026-06-17 20:36:11 +08:00

463 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
CanvasLayer,
EditorAssetFolder,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
applyGenerationReferenceUpload,
applyMeasuredUploadAssetSize,
applyPersistedUploadAsset,
applyUploadAssetCreateFailure,
applyUploadAssetCreatePending,
applyUploadAssetReadFailure,
applyUploadAssetReadSuccess,
bindUploadLayerToPersistedAsset,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
expandUploadFolder,
resizeUploadCanvasLayerToImage,
resolveUploadFolderId,
setFailedGenerationIdle,
} from './ImageCanvasUploadModel';
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
...overrides,
};
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-upload-1',
resourceId: 'local-resource-upload-1',
title: '上传图片',
src: 'data:image/png;base64,upload',
x: -160,
y: -107.5,
width: 420,
height: 315,
originalWidth: 420,
originalHeight: 315,
zIndex: 11,
sourceType: 'uploaded',
sourceAssetId: 'upload-1',
...overrides,
};
}
function createUploadingAsset(
overrides: Partial<ReturnType<typeof createUploadingAssetPlaceholder>> = {},
) {
return {
...createUploadingAssetPlaceholder({
uploadIndex: 1,
fileName: '上传图片.png',
folderId: 'project',
}),
...overrides,
};
}
function createDialog(
overrides: Partial<GenerateDialogState> = {},
): GenerateDialogState {
return {
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
...overrides,
};
}
describe('ImageCanvasUploadModel', () => {
afterEach(() => {
vi.useRealTimers();
});
it('resolves upload folder ids with project fallback', () => {
const folders = [
createFolder(),
createFolder({ id: 'characters', label: '角色素材' }),
];
expect(
resolveUploadFolderId({
assetFolders: folders,
requestedFolderId: 'characters',
activeUploadFolderId: 'project',
}),
).toBe('characters');
expect(
resolveUploadFolderId({
assetFolders: folders,
requestedFolderId: 'missing',
activeUploadFolderId: 'characters',
}),
).toBe('project');
});
it('creates uploading asset placeholders', () => {
expect(
createUploadingAssetPlaceholder({
uploadIndex: 7,
fileName: 'hero.png',
folderId: 'characters',
}),
).toEqual({
id: 'upload-7',
label: 'hero.png',
src: '',
width: 420,
height: 315,
folderId: 'characters',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: false,
uploadStatus: 'uploading',
uploadProgress: 8,
uploadMessage: '准备上传',
});
});
it('opens the target upload folder without changing other folders', () => {
expect(
expandUploadFolder({
folders: [
createFolder({ id: 'project', collapsed: true }),
createFolder({ id: 'characters', label: '角色素材', collapsed: true }),
],
folderId: 'characters',
}),
).toEqual([
createFolder({ id: 'project', collapsed: true }),
createFolder({ id: 'characters', label: '角色素材', collapsed: false }),
]);
});
it('applies upload asset lifecycle patches', () => {
const asset = createUploadingAsset();
const otherAsset = createUploadingAsset({
id: 'upload-other',
label: '其他图片.png',
});
expect(
applyUploadAssetReadSuccess({
assets: [asset, otherAsset],
uploadAssetId: asset.id,
imageSrc: 'data:image/png;base64,read',
}),
).toMatchObject([
{
id: 'upload-1',
src: 'data:image/png;base64,read',
uploadProgress: 42,
uploadMessage: '读取图片',
},
{ id: 'upload-other', label: '其他图片.png' },
]);
expect(
applyUploadAssetCreatePending({
assets: [asset],
uploadAssetId: asset.id,
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'uploading',
uploadProgress: 68,
uploadMessage: '上传中',
},
]);
expect(
applyUploadAssetReadFailure({
assets: [asset],
uploadAssetId: asset.id,
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '读取失败',
},
]);
expect(
applyUploadAssetCreateFailure({
assets: [asset],
uploadAssetId: asset.id,
message: '请先登录',
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '请先登录',
},
]);
});
it('replaces upload placeholders with persisted assets and clears upload state', () => {
expect(
applyPersistedUploadAsset({
assets: [createUploadingAsset()],
uploadAssetId: 'upload-1',
persistedAsset: {
assetId: 'asset-1',
folderId: 'characters',
label: '角色.png',
imageSrc: 'data:image/png;base64,persisted',
width: 1024,
height: 768,
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
}),
).toEqual([
{
id: 'asset-1',
label: '角色.png',
src: 'data:image/png;base64,persisted',
width: 1024,
height: 768,
folderId: 'characters',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
objectKey: 'object-key',
assetObjectId: 'asset-object',
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
},
]);
});
it('updates measured asset size after the browser loads the uploaded image', () => {
expect(
applyMeasuredUploadAssetSize({
assets: [createUploadingAsset()],
uploadAssetId: 'upload-1',
originalWidth: 1600,
originalHeight: 900,
}),
).toMatchObject([
{
id: 'upload-1',
width: 1600,
height: 900,
},
]);
});
it('binds uploaded canvas layers to persisted asset metadata', () => {
expect(
bindUploadLayerToPersistedAsset({
layers: [createLayer({ objectKey: 'local-object' })],
layerId: 'layer-upload-1',
persistedAsset: {
assetId: 'asset-1',
folderId: 'project',
label: '上传图片.png',
imageSrc: 'data:image/png;base64,persisted',
width: 420,
height: 315,
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
}),
).toMatchObject([
{
id: 'layer-upload-1',
sourceAssetId: 'asset-1',
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
]);
});
it('creates uploaded generation references with fallback labels', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
expect(
createUploadedGenerationReference({
idPrefix: 'upload-character-reference',
index: 2,
fileName: '',
fallbackLabel: '参考图3',
imageSrc: 'data:image/png;base64,ref',
}),
).toEqual({
id: 'upload-character-reference-1781697600000-2',
label: '参考图3',
src: 'data:image/png;base64,ref',
});
});
it('resets failed generation dialogs when reference uploads are applied', () => {
expect(
setFailedGenerationIdle(
createDialog({ status: 'failed', errorMessage: '旧错误' }),
),
).toMatchObject({ status: 'idle', errorMessage: undefined });
expect(
setFailedGenerationIdle(createDialog({ status: 'generating' })),
).toMatchObject({ status: 'generating', errorMessage: '旧错误' });
});
it('applies uploaded references to matching generation dialogs', () => {
const reference = {
id: 'ref-a',
label: '参考图',
src: 'data:image/png;base64,ref',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'spec' }),
target: 'spec-reference',
references: [reference],
}),
).toMatchObject({
mode: 'spec',
status: 'idle',
specReference: reference,
errorMessage: undefined,
});
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'character' }),
target: 'character-spec',
references: [reference],
}),
).toMatchObject({
mode: 'character',
status: 'idle',
characterSpecReference: reference,
});
expect(
applyGenerationReferenceUpload({
dialog: createDialog({ mode: 'icon' }),
target: 'icon-spec',
references: [reference],
}),
).toMatchObject({
mode: 'icon',
status: 'idle',
iconSpecReference: reference,
});
});
it('appends character reference uploads without changing unmatched dialogs', () => {
const previousReference = {
id: 'ref-old',
label: '旧参考图',
src: 'data:image/png;base64,old',
};
const nextReference = {
id: 'ref-new',
label: '新参考图',
src: 'data:image/png;base64,new',
};
expect(
applyGenerationReferenceUpload({
dialog: createDialog({
mode: 'character',
characterReferences: [previousReference],
}),
target: 'character-reference',
references: [nextReference],
}),
).toMatchObject({
mode: 'character',
status: 'idle',
characterReferences: [previousReference, nextReference],
});
const iconDialog = createDialog({ mode: 'icon' });
expect(
applyGenerationReferenceUpload({
dialog: iconDialog,
target: 'character-reference',
references: [nextReference],
}),
).toBe(iconDialog);
});
it('creates upload canvas layers centered on the target canvas point', () => {
const layer = createUploadCanvasLayer({
uploadIndex: 1,
fileName: '画布素材.png',
imageSrc: 'data:image/png;base64,canvas',
canvasPoint: { x: 110, y: 120 },
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
});
expect(layer).toMatchObject({
id: 'layer-upload-1',
resourceId: 'local-resource-upload-1',
title: '画布素材.png',
x: -160,
y: -107.5,
width: 420,
height: 315,
originalWidth: 420,
originalHeight: 315,
zIndex: 11,
sourceAssetId: 'upload-1',
});
});
it('uses viewport center fallback for invalid upload points', () => {
const layer = createUploadCanvasLayer({
uploadIndex: 2,
fileName: '',
imageSrc: 'data:image/png;base64,canvas',
canvasPoint: { x: Number.NaN, y: Number.POSITIVE_INFINITY },
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
});
expect(layer.title).toBe('上传图片');
expect(layer.x).toBe(10);
expect(layer.y).toBe(-7.5);
});
it('resizes upload canvas layers around the same canvas point', () => {
const resizedLayer = resizeUploadCanvasLayerToImage({
layer: createLayer(),
originalWidth: 1536,
originalHeight: 1024,
canvasPoint: { x: 110, y: 120 },
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
});
expect(resizedLayer).toMatchObject({
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
x: -718,
y: -462,
});
});
});