Files
Genarrative/src/components/image-editor/ImageCanvasEditorModel.test.ts
T
kdletters a7c0b1e771 修复画布素材删除级联
保留素材库 sourceResourceId 并写入画布图层

删除素材时用 sourceResourceId 匹配项目资源图层

补充级联删除持久化回归测试和排障记录
2026-06-24 15:01:13 +08:00

803 lines
22 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
canvasDisplayScaleToViewportScale,
canvasDisplayViewportToViewport,
CANVAS_WORLD_ORIGIN,
createLayerFromAsset,
formatCanvasDisplayScalePercent,
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveSnappedLayerPosition,
serializeCanvasLayout,
serializeLayer,
splitCanvasLayoutItems,
viewportScaleToCanvasDisplayScale,
viewportToCanvasDisplayViewport,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
EditorAsset,
} from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('maps viewport scale to the user-facing canvas zoom scale', () => {
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
expect(canvasDisplayScaleToViewportScale(0.05)).toBe(0.025);
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
expect(formatCanvasDisplayScalePercent(0.025)).toBe('5%');
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
expect(
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
).toEqual({ x: 10, y: 20, scale: 0.5 });
expect(
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
).toEqual({ x: 10, y: 20, scale: 1 });
});
it('normalizes valid canvas background hex values and rejects invalid input', () => {
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
expect(normalizeCanvasBackgroundHex('white')).toBeNull();
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
});
it('keeps only one default asset folder when normalizing the persisted library', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'project-duplicate',
label: '旧项目素材',
sortOrder: 1,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
expect(library.folders).toHaveLength(1);
expect(library.folders[0]?.id).toBe('project');
});
it('infers MP3 and MP4 asset media types from persisted paths', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-audio',
folderId: 'project',
label: '胜利音效.mp3',
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
width: 420,
height: 120,
sourceType: 'uploaded',
objectKey:
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
},
{
assetId: 'asset-video',
folderId: 'project',
label: '开场动画.mp4',
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
width: 560,
height: 315,
sourceType: 'uploaded',
objectKey:
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
},
],
});
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
expect(library.assets[1]).toMatchObject({ mediaType: 'video' });
expect(
createLayerFromAsset(
library.assets[0]!,
1,
{ x: 0, y: 0, scale: 1 },
{ x: 300, y: 200 },
),
).toMatchObject({
mediaType: 'audio',
assetKind: 'sound-effect',
});
});
it('creates a layer from an account asset at the requested screen point', () => {
const asset: EditorAsset = {
id: 'asset-1',
label: '角色草图',
src: 'data:image/png;base64,one',
width: 640,
height: 480,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
sourceResourceId: 'resource-source-1',
};
const layer = createLayerFromAsset(
asset,
3,
{ x: 20, y: 40, scale: 2 },
{ x: 420, y: 340 },
);
expect(layer).toMatchObject({
id: 'layer-asset-1-3',
title: '角色草图',
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
sourceAssetId: 'asset-1',
sourceResourceId: 'resource-source-1',
});
expect(layer.x).toBe(-18);
expect(layer.y).toBe(12);
});
it('preserves source resource ids from the persisted asset library', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-generated',
folderId: 'project',
label: '生成素材',
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
width: 640,
height: 640,
sourceType: 'generated',
sourceResourceId: 'resource-generated',
},
],
});
expect(library.assets[0]).toMatchObject({
id: 'asset-generated',
sourceResourceId: 'resource-generated',
});
});
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: 'data:image/png;base64,heavy',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
objectKey: 'generated/object.png',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
references: [],
},
locked: true,
};
const snapshot = serializeLayer(layer);
expect(snapshot).not.toHaveProperty('src');
expect(snapshot).not.toHaveProperty('assetKind');
expect(snapshot).not.toHaveProperty('generationInputs');
const hydrated = hydrateLayer(
snapshot,
new Map([
[
'resource-generated',
{
imageSrc: '/read/generated.png',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
references: [],
},
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-generated',
src: '/read/generated.png',
sourceType: 'generated',
assetKind: 'character',
objectKey: 'generated/object.png',
locked: true,
});
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('hydrates object metadata from project resources when the saved layer is lean', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-uploaded',
resourceId: 'resource-uploaded',
title: '上传图',
x: 10,
y: 20,
width: 640,
height: 360,
originalWidth: 640,
originalHeight: 360,
zIndex: 2,
sourceType: 'uploaded',
},
new Map([
[
'resource-uploaded',
{
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
assetObjectId: 'asset-object-uploaded',
sourceResourceId: 'resource-source',
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-uploaded',
src: '/generated-character-drafts/editor/asset-library/image.png',
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
assetObjectId: 'asset-object-uploaded',
sourceResourceId: 'resource-source',
});
});
it('hydrates manually selected publication material tags from saved layout', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-publication',
resourceId: 'resource-publication',
title: '宣发图',
x: 10,
y: 20,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 5,
sourceType: 'generated',
assetKind: 'publication-material',
},
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
);
expect(hydrated).toMatchObject({
id: 'layer-publication',
assetKind: 'publication-material',
});
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-audio',
resourceId: 'resource-audio',
title: '游戏音效',
x: 10,
y: 20,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
zIndex: 5,
sourceType: 'generated',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
},
new Map([
[
'resource-audio',
{
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
durationSeconds: 2.4,
},
],
]),
);
expect(hydrated).toMatchObject({
id: 'layer-audio',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
durationSeconds: 2.4,
});
});
it('serializes and hydrates image sequence frames for character animation layers', () => {
const layer: CanvasLayer = {
id: 'layer-action',
resourceId: 'resource-action',
title: '角色动作',
src: '/generated-character-drafts/editor/frame01.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-character-drafts/editor/frame01.png',
objectKey: 'generated-character-drafts/editor/frame01.png',
assetObjectId: 'assetobj-frame-01',
width: 1024,
height: 1024,
},
{
frameIndex: 2,
imageSrc: '/generated-character-drafts/editor/frame02.png',
objectKey: 'generated-character-drafts/editor/frame02.png',
assetObjectId: 'assetobj-frame-02',
width: 1024,
height: 1024,
},
],
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
x: 10,
y: 20,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 6,
sourceType: 'generated',
assetKind: 'character-animation',
durationSeconds: 4,
};
const snapshot = serializeLayer(layer);
expect(snapshot.thumbnailSrc).toBe(
'/generated-character-drafts/editor/frame01.png',
);
expect(snapshot.mediaType).toBe('image-sequence');
expect(snapshot.imageSequenceFrames).toHaveLength(2);
const hydrated = hydrateLayer(
snapshot,
new Map([
[
'resource-action',
{
imageSrc: layer.src,
assetKind: 'character-animation',
},
],
]),
);
expect(hydrated).toMatchObject({
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: layer.imageSequenceFrames,
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
assetKind: 'character-animation',
durationSeconds: 4,
});
});
it('restores audio generation dialogs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
y: 120,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
};
const { generationDialogs } = splitCanvasLayoutItems(
serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
}),
);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
});
});
it('restores publication material generator inputs from saved layout', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-publication',
mode: 'publication',
prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '马戏团午夜惊魂',
gameCategories: '非对称对抗',
gameDescription: '找到钥匙,开门逃离马戏团',
},
publicationReferences: [
{
id: 'publication-reference-1',
label: '首图参考',
src: 'data:image/png;base64,publication-ref',
resourceId: 'resource-publication-reference',
},
],
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
generatedLayerId: 'layer-publication',
placeholder: {
x: 100,
y: 120,
width: 1280,
height: 720,
originalWidth: 1280,
originalHeight: 720,
},
};
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
});
expect(JSON.stringify(layout)).not.toContain('data:image');
expect(JSON.stringify(layout)).toContain(
'ref:project-resource:resource-publication-reference',
);
const { generationDialogs } = splitCanvasLayoutItems(
layout,
new Map([
[
'resource-publication-reference',
{
imageSrc: '/read/publication-reference.png',
objectKey: 'generated-character-drafts/editor/reference.png',
assetObjectId: 'asset-object-publication-reference',
},
],
]),
);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-publication',
mode: 'publication',
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '马戏团午夜惊魂',
gameCategories: '非对称对抗',
gameDescription: '找到钥匙,开门逃离马戏团',
},
imageModel: 'gpt-image-2',
generatedLayerId: 'layer-publication',
});
expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({
id: 'publication-reference-1',
label: '首图参考',
src: '/read/publication-reference.png',
objectKey: 'generated-character-drafts/editor/reference.png',
assetObjectId: 'asset-object-publication-reference',
resourceId: 'resource-publication-reference',
});
});
it('serializes generation dialogs beside layers and splits them on load', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: 'data:image/png;base64,heavy',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
};
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
generationReferences: [
{
id: 'reference-1',
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-reference-1',
},
],
};
const layout = serializeCanvasLayout({
layers: [layer],
canvasGenerationDialogs: [dialog],
});
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
),
).not.toContain('data:image');
expect(
JSON.stringify(
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
),
).toContain('ref:project-resource:resource-reference-1');
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
layout,
new Map([
[
'resource-reference-1',
{
imageSrc: '/read/reference-1.png',
objectKey: 'generated/reference-1.png',
},
],
]),
);
expect(layerItems).toHaveLength(1);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]).toMatchObject({
id: 'generation-dialog-9',
mode: 'generate',
prompt: '刷新后要继续保留',
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
placeholder: {
x: 100,
y: 120,
width: 420,
},
});
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
label: '参考图',
src: '/read/reference-1.png',
resourceId: 'resource-reference-1',
objectKey: 'generated/reference-1.png',
});
});
it('snaps moving layers to nearby canvas and layer guides', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
resourceId: 'resource-moving',
title: '移动图',
src: 'data:image/png;base64,moving',
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
};
const anchorLayer: CanvasLayer = {
...movingLayer,
id: 'anchor',
resourceId: 'resource-anchor',
x: 300,
y: 240,
zIndex: 2,
};
const snapped = resolveSnappedLayerPosition(
movingLayer,
CANVAS_WORLD_ORIGIN - 47,
238,
[movingLayer, anchorLayer],
1,
);
expect(snapped.x).toBe(CANVAS_WORLD_ORIGIN - movingLayer.width / 2);
expect(snapped.y).toBe(anchorLayer.y);
expect(snapped.guide).toEqual({
vertical: CANVAS_WORLD_ORIGIN,
horizontal: anchorLayer.y,
});
});
it('snaps moving layers to equal horizontal and vertical spacing', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
resourceId: 'resource-moving',
title: '移动图',
src: 'data:image/png;base64,moving',
x: 0,
y: 0,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
};
const firstLayer: CanvasLayer = {
...movingLayer,
id: 'first',
resourceId: 'resource-first',
x: 100,
y: 160,
zIndex: 2,
};
const secondLayer: CanvasLayer = {
...movingLayer,
id: 'second',
resourceId: 'resource-second',
x: 260,
y: 160,
zIndex: 3,
};
const topLayer: CanvasLayer = {
...movingLayer,
id: 'top',
resourceId: 'resource-top',
x: 520,
y: 120,
zIndex: 4,
};
const bottomLayer: CanvasLayer = {
...movingLayer,
id: 'bottom',
resourceId: 'resource-bottom',
x: 520,
y: 300,
zIndex: 5,
};
const horizontalSnap = resolveSnappedLayerPosition(
movingLayer,
417,
160,
[movingLayer, firstLayer, secondLayer],
1,
);
expect(horizontalSnap.x).toBe(420);
expect(horizontalSnap.guide).toEqual({
vertical: 460,
horizontal: 160,
});
const verticalSnap = resolveSnappedLayerPosition(
movingLayer,
520,
207,
[movingLayer, topLayer, bottomLayer],
1,
);
expect(verticalSnap.y).toBe(210);
expect(verticalSnap.guide).toEqual({
vertical: 520,
horizontal: 250,
});
});
it('keeps equal-spacing snap focused on nearby overlapping layers', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
resourceId: 'resource-moving',
title: '移动图',
src: 'data:image/png;base64,moving',
x: 0,
y: 0,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
};
const firstLayer: CanvasLayer = {
...movingLayer,
id: 'first',
resourceId: 'resource-first',
x: 100,
y: 160,
zIndex: 2,
};
const secondLayer: CanvasLayer = {
...movingLayer,
id: 'second',
resourceId: 'resource-second',
x: 260,
y: 160,
zIndex: 3,
};
const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({
...movingLayer,
id: `offscreen-${index}`,
resourceId: `resource-offscreen-${index}`,
x: 2400 + index * 96,
y: 1600,
zIndex: 10 + index,
}));
const snapped = resolveSnappedLayerPosition(
movingLayer,
417,
160,
[movingLayer, ...offscreenLayers, firstLayer, secondLayer],
1,
);
expect(snapped.x).toBe(420);
expect(snapped.guide).toEqual({
vertical: 460,
horizontal: 160,
});
});
});