扩展 ImageCanvasUploadModel 承载素材上传占位、进度、失败和持久化回写状态迁移 精简 useImageCanvasUploadWorkflow 中的资产与图层状态补丁逻辑 补充上传模型单测覆盖生命周期状态和图层绑定 更新 TRACKING.md 记录第四十六执行批次与验证结果
463 lines
12 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|