cd80c085ff
统一前后端 SFX Prompt Unicode canonicalization、字符计数和 2048 边界 固化 52 个音效预设、追加规则及跨语言测试向量 演进共享音频请求响应、duration、Loop 和 V2 metadata 契约 增加 External model 输入矩阵纯函数并保持正式接线归属 T5 补齐前端提交、读取、深拷贝与请求边界回归测试 更新共享计划并标记 T1 完成且当前不可发布
2358 lines
72 KiB
TypeScript
2358 lines
72 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
EditorProjectLayerSnapshot,
|
|
EditorProjectSnapshot,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import {
|
|
CANVAS_WORLD_ORIGIN,
|
|
canvasDisplayScaleToViewportScale,
|
|
canvasDisplayViewportToViewport,
|
|
collectExpiredInlineGenerationDialogIds,
|
|
createLayerFromAsset,
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
dropDeadInlineGenerationPlaceholders,
|
|
formatCanvasDisplayScalePercent,
|
|
generationInputsOrNull,
|
|
hydrateCanvasGenerationDialog,
|
|
hydrateLayer,
|
|
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS,
|
|
isCanvasAssetKindOverrideCompatible,
|
|
normalizeAssetLibrary,
|
|
normalizeCanvasBackgroundHex,
|
|
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
|
resolveLayerResourceAssetKind,
|
|
resolveNextInlineGenerationDialogExpiryAt,
|
|
resolveSnappedLayerPosition,
|
|
serializeCanvasLayout,
|
|
serializeLayer,
|
|
splitCanvasLayoutItems,
|
|
viewportScaleToCanvasDisplayScale,
|
|
viewportToCanvasDisplayViewport,
|
|
} from './ImageCanvasEditorModel';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
EditorAsset,
|
|
PerfectPixelOperationSnapshot,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS } from './useImageCanvasGenerationWorkflow';
|
|
|
|
function buildPerfectPixelOperation(
|
|
dialogId: string,
|
|
): PerfectPixelOperationSnapshot {
|
|
return {
|
|
version: 1,
|
|
kind: 'perfect-pixel',
|
|
operationId: dialogId,
|
|
taskId: `pixel-art-snap-${dialogId}`,
|
|
request: {
|
|
sourceImageSrc: 'ref:project-resource:resource-source',
|
|
projectId: 'project-1',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '提示词', value: '像素角色' }],
|
|
references: [
|
|
{
|
|
title: '源图',
|
|
label: '角色原图',
|
|
refType: 'project-resource',
|
|
refId: 'resource-source',
|
|
},
|
|
],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '角色 · 完美像素',
|
|
canvasCompletion: {
|
|
dialogId,
|
|
title: '角色 · 完美像素',
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 320,
|
|
height: 320,
|
|
originalWidth: 640,
|
|
originalHeight: 640,
|
|
},
|
|
},
|
|
},
|
|
submittedAt: 1_700_000_000_000,
|
|
reconcileUntil: 1_700_000_075_000,
|
|
};
|
|
}
|
|
|
|
describe('ImageCanvasEditorModel', () => {
|
|
it('keeps the resource default kind separate from a layer override', () => {
|
|
const layer = {
|
|
id: 'layer-shared',
|
|
resourceId: 'resource-shared',
|
|
title: '共享素材',
|
|
src: '/shared.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
originalWidth: 100,
|
|
originalHeight: 100,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
resourceAssetKind: 'character',
|
|
assetKindOverride: 'icon',
|
|
assetKind: 'icon',
|
|
} satisfies CanvasLayer;
|
|
|
|
expect(resolveLayerResourceAssetKind(layer)).toBe('character');
|
|
expect(serializeLayer(layer)).toMatchObject({
|
|
resourceId: 'resource-shared',
|
|
assetKindOverride: 'icon',
|
|
});
|
|
expect(serializeLayer(layer)).not.toHaveProperty('assetKind');
|
|
});
|
|
|
|
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('serializes canvas background settings without treating them as layers', () => {
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [],
|
|
canvasBackgroundColor: ' #ABC ',
|
|
});
|
|
|
|
expect(layout).toEqual([
|
|
expect.objectContaining({
|
|
itemType: 'canvas-settings',
|
|
layerId: 'canvas-settings:default',
|
|
resourceId: 'canvas-settings:default',
|
|
canvasBackgroundColor: '#aabbcc',
|
|
}),
|
|
]);
|
|
|
|
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
|
splitCanvasLayoutItems(layout);
|
|
|
|
expect(layerItems).toEqual([]);
|
|
expect(generationDialogs).toEqual([]);
|
|
expect(canvasBackgroundColor).toBe('#aabbcc');
|
|
});
|
|
|
|
it('drops invalid canvas background settings from serialized layouts', () => {
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [],
|
|
canvasBackgroundColor: '#not-a-color',
|
|
});
|
|
|
|
expect(layout).toEqual([]);
|
|
expect(
|
|
splitCanvasLayoutItems(layout).canvasBackgroundColor,
|
|
).toBeUndefined();
|
|
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
|
});
|
|
|
|
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('uses media types published by the asset API', () => {
|
|
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',
|
|
assetKind: 'audio',
|
|
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',
|
|
thumbnailSrc:
|
|
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
|
|
width: 560,
|
|
height: 315,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'video',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
|
|
expect(library.assets[1]).toMatchObject({
|
|
mediaType: 'video',
|
|
thumbnailSrc:
|
|
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
|
|
});
|
|
expect(
|
|
createLayerFromAsset(
|
|
library.assets[0]!,
|
|
1,
|
|
{ x: 0, y: 0, scale: 1 },
|
|
{ x: 300, y: 200 },
|
|
),
|
|
).toMatchObject({
|
|
mediaType: 'audio',
|
|
assetKind: 'audio',
|
|
});
|
|
});
|
|
|
|
it('infers kinds for legacy persisted media assets from source extensions', () => {
|
|
const library = normalizeAssetLibrary({
|
|
folders: [],
|
|
assets: [
|
|
{
|
|
assetId: 'legacy-video',
|
|
folderId: 'project',
|
|
label: '旧开场动画.mp4',
|
|
imageSrc: '/legacy/opening.mp4?signature=video',
|
|
width: 1280,
|
|
height: 720,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
assetId: 'legacy-audio',
|
|
folderId: 'project',
|
|
label: '旧背景音乐',
|
|
imageSrc: '/api/assets/read',
|
|
objectKey: 'legacy/music.mp3?version=1',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'unrecognized-legacy-kind',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(library.assets[0]).toMatchObject({
|
|
mediaType: 'video',
|
|
assetKind: 'video',
|
|
});
|
|
expect(library.assets[1]).toMatchObject({
|
|
mediaType: 'audio',
|
|
assetKind: 'background-music',
|
|
});
|
|
expect(
|
|
library.assets.map((asset, index) =>
|
|
createLayerFromAsset(
|
|
asset,
|
|
index,
|
|
{ x: 0, y: 0, scale: 1 },
|
|
{ x: 300, y: 200 },
|
|
),
|
|
),
|
|
).toEqual([
|
|
expect.objectContaining({ mediaType: 'video', assetKind: 'video' }),
|
|
expect.objectContaining({
|
|
mediaType: 'audio',
|
|
assetKind: 'background-music',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('creates a cascaded layer from an account asset near 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',
|
|
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
|
|
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',
|
|
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
|
|
sourceAssetId: 'asset-1',
|
|
sourceResourceId: 'resource-source-1',
|
|
});
|
|
expect(layer.x).toBe(-18);
|
|
expect(layer.y).toBe(12);
|
|
});
|
|
|
|
it('centers a dropped asset exactly at the requested screen point', () => {
|
|
const asset: EditorAsset = {
|
|
id: 'asset-drop',
|
|
label: '投放素材',
|
|
src: 'data:image/png;base64,drop',
|
|
width: 640,
|
|
height: 480,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
};
|
|
|
|
const layer = createLayerFromAsset(
|
|
asset,
|
|
3,
|
|
{ x: 20, y: 40, scale: 2 },
|
|
{ x: 420, y: 340 },
|
|
{ applyCascadeOffset: false },
|
|
);
|
|
|
|
expect(layer.x + layer.width / 2).toBe(200);
|
|
expect(layer.y + layer.height / 2).toBe(150);
|
|
});
|
|
|
|
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',
|
|
assetKind: 'image',
|
|
sourceResourceId: 'resource-generated',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(library.assets[0]).toMatchObject({
|
|
id: 'asset-generated',
|
|
sourceResourceId: 'resource-generated',
|
|
});
|
|
});
|
|
|
|
it('round-trips an explicit character action into a movable sequence layer', () => {
|
|
const frames = [
|
|
{
|
|
imageSrc: '/generated/action/frame01.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
{
|
|
imageSrc: '/generated/action/frame02.png',
|
|
width: 192,
|
|
height: 256,
|
|
},
|
|
];
|
|
const library = normalizeAssetLibrary({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-action',
|
|
folderId: 'project',
|
|
label: '角色挥手',
|
|
imageSrc: frames[0]!.imageSrc,
|
|
width: 192,
|
|
height: 256,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: frames,
|
|
imageSequenceDurationMs: 4_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
const asset = library.assets[0] as EditorAsset;
|
|
const layer = createLayerFromAsset(
|
|
asset,
|
|
1,
|
|
{ x: 0, y: 0, scale: 1 },
|
|
{ x: 400, y: 300 },
|
|
{ applyCascadeOffset: false },
|
|
);
|
|
|
|
expect(asset.mediaType).toBe('image-sequence');
|
|
expect(layer).toMatchObject({
|
|
mediaType: 'image-sequence',
|
|
imageSequenceFrames: frames,
|
|
imageSequenceDurationMs: 4_000,
|
|
x: 304,
|
|
y: 172,
|
|
});
|
|
});
|
|
|
|
it('rejects an explicitly typed but incomplete character action', () => {
|
|
expect(() =>
|
|
createLayerFromAsset(
|
|
{
|
|
id: 'asset-corrupt-action',
|
|
label: '损坏动作',
|
|
src: '/generated/action/frame01.png',
|
|
mediaType: 'image-sequence',
|
|
width: 192,
|
|
height: 256,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'generated',
|
|
persisted: true,
|
|
assetKind: 'character-animation',
|
|
},
|
|
1,
|
|
{ x: 0, y: 0, scale: 1 },
|
|
{ x: 400, y: 300 },
|
|
),
|
|
).toThrow('缺少可用序列帧');
|
|
});
|
|
|
|
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',
|
|
resourcePersistenceState: 'registered',
|
|
objectKey: 'generated/object.png',
|
|
model: 'birefnet',
|
|
sourceResourceId: 'resource-provider-source',
|
|
resourceAssetKind: 'character',
|
|
assetKindOverride: null,
|
|
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.assetKindOverride).toBeNull();
|
|
expect(snapshot).not.toHaveProperty('generationInputs');
|
|
// 有资源行时,服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源
|
|
// 链推导出的展示值;回写它会与资源行原值冲突并让整次结构化保存 400。
|
|
expect(snapshot).not.toHaveProperty('model');
|
|
expect(snapshot).not.toHaveProperty('provider');
|
|
|
|
const hydrated = hydrateLayer(
|
|
snapshot,
|
|
new Map([
|
|
[
|
|
'resource-generated',
|
|
{
|
|
imageSrc: '/read/generated.png',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '骑士' }],
|
|
references: [],
|
|
},
|
|
model: 'birefnet',
|
|
sourceResourceId: 'resource-provider-source',
|
|
},
|
|
],
|
|
[
|
|
'resource-provider-source',
|
|
{
|
|
imageSrc: '/read/provider-source.png',
|
|
model: 'gpt-image-2',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-generated',
|
|
src: '/read/generated.png',
|
|
sourceType: 'generated',
|
|
resourceAssetKind: 'character',
|
|
assetKindOverride: null,
|
|
assetKind: 'character',
|
|
objectKey: 'generated/object.png',
|
|
model: 'gpt-image-2',
|
|
locked: true,
|
|
});
|
|
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
|
});
|
|
|
|
it('keeps model metadata on self-contained local sequences that have no resource row', () => {
|
|
// 中文注释:角色动画逐帧层用 local- 资源 id、image-sequence、无 objectKey,服务端
|
|
// normalize 走 resource == None 早退分支,只摘 assetKind 就把 item 原样写回——item_json
|
|
// 是这类图层元数据的唯一存储。停发 model 会让它在下一次保存后永久丢失。
|
|
const layer: CanvasLayer = {
|
|
id: 'layer-character-animation',
|
|
resourceId: 'local-resource-character-animation-1',
|
|
title: '角色动作',
|
|
src: '/generated/sequence/frame01.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
mediaType: 'image-sequence',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: '/generated/sequence/frame01.png',
|
|
width: 320,
|
|
height: 240,
|
|
},
|
|
] as unknown as CanvasLayer['imageSequenceFrames'],
|
|
model: 'seedance2.0-fast',
|
|
provider: 'ark',
|
|
};
|
|
|
|
const snapshot = serializeLayer(layer);
|
|
expect(snapshot.model).toBe('seedance2.0-fast');
|
|
expect(snapshot.provider).toBe('ark');
|
|
|
|
const hydrated = hydrateLayer(snapshot, new Map());
|
|
expect(hydrated?.model).toBe('seedance2.0-fast');
|
|
expect(serializeLayer(hydrated!).model).toBe('seedance2.0-fast');
|
|
});
|
|
|
|
it('keeps the resource sourceType across a structured layout round trip', () => {
|
|
const layer: CanvasLayer = {
|
|
id: 'layer-generated',
|
|
resourceId: 'resource-generated',
|
|
title: '生成图',
|
|
src: '/read/generated.png',
|
|
x: 0,
|
|
y: 0,
|
|
width: 512,
|
|
height: 512,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
};
|
|
const resources = new Map([
|
|
[
|
|
'resource-generated',
|
|
{ imageSrc: '/read/generated.png', sourceType: 'generated' },
|
|
],
|
|
]);
|
|
|
|
// 结构化保存会把校验通过的 sourceType 归还资源行并把图层列置空,读回的布局项没有这个键。
|
|
const { sourceType: _omitted, ...storedSnapshot } = serializeLayer(layer);
|
|
expect(storedSnapshot).not.toHaveProperty('sourceType');
|
|
|
|
const hydrated = hydrateLayer(storedSnapshot, resources);
|
|
expect(hydrated?.sourceType).toBe('generated');
|
|
// 再次保存必须仍然是 generated,否则服务端会判成「sourceType 与项目资源不一致」。
|
|
expect(serializeLayer(hydrated!).sourceType).toBe('generated');
|
|
});
|
|
|
|
it('distinguishes persisted self-contained sequences from unresolved local resources', () => {
|
|
const validSequence = {
|
|
layerId: 'layer-sequence',
|
|
resourceId: 'local-resource-sequence',
|
|
title: '历史动作',
|
|
src: '/generated/sequence/frame-1.png',
|
|
sourceType: 'generated',
|
|
mediaType: 'image-sequence',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: '/generated/sequence/frame-1.png',
|
|
objectKey: 'generated/sequence/frame-1.png',
|
|
width: 320,
|
|
height: 240,
|
|
},
|
|
],
|
|
thumbnailSrc: '/generated/sequence/thumbnail.png',
|
|
previewVideoPath: '/generated/sequence/preview.mp4',
|
|
};
|
|
|
|
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
|
|
mediaType: 'image-sequence',
|
|
resourcePersistenceState: 'self-contained-local',
|
|
});
|
|
expect(
|
|
hydrateLayer(
|
|
{
|
|
...validSequence,
|
|
imageSequenceFrames: [
|
|
{ ...validSequence.imageSequenceFrames[0], frameIndex: 0 },
|
|
],
|
|
},
|
|
new Map(),
|
|
),
|
|
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
|
|
expect(
|
|
hydrateLayer(
|
|
{
|
|
layerId: 'layer-unresolved',
|
|
resourceId: 'local-resource-unresolved',
|
|
title: '未登记图片',
|
|
src: '/generated/unresolved.png',
|
|
sourceType: 'uploaded',
|
|
},
|
|
new Map(),
|
|
),
|
|
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
|
|
});
|
|
|
|
it('derives audio and video media types from assetKind without persisting mediaType', () => {
|
|
for (const [mediaType, assetKind] of [
|
|
['video', 'video'],
|
|
['audio', 'background-music'],
|
|
] as const) {
|
|
const resourceId = `${mediaType}-resource`;
|
|
const resources = new Map([
|
|
[
|
|
resourceId,
|
|
{
|
|
imageSrc:
|
|
mediaType === 'video'
|
|
? '/legacy/cutscene.mp4'
|
|
: '/legacy/theme.mp3',
|
|
assetKind,
|
|
},
|
|
],
|
|
]);
|
|
const firstHydration = hydrateLayer(
|
|
{
|
|
layerId: `${mediaType}-layer`,
|
|
resourceId,
|
|
title: mediaType,
|
|
sourceType: 'uploaded',
|
|
assetKind,
|
|
},
|
|
resources,
|
|
);
|
|
|
|
expect(firstHydration?.mediaType).toBe(mediaType);
|
|
if (!firstHydration) {
|
|
throw new Error(`${mediaType} layer should hydrate`);
|
|
}
|
|
const savedSnapshot = serializeLayer(firstHydration);
|
|
expect(savedSnapshot.mediaType).toBe(mediaType);
|
|
expect(savedSnapshot.assetKind).toBeUndefined();
|
|
|
|
const secondHydration = hydrateLayer(savedSnapshot, resources);
|
|
expect(secondHydration?.mediaType).toBe(mediaType);
|
|
}
|
|
});
|
|
|
|
it('preserves legacy audio and video media types when assetKind is absent', () => {
|
|
for (const mediaType of ['video', 'audio'] as const) {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: `legacy-${mediaType}-layer`,
|
|
resourceId: `legacy-${mediaType}-resource`,
|
|
title: `旧${mediaType}`,
|
|
sourceType: 'uploaded',
|
|
mediaType,
|
|
},
|
|
new Map([
|
|
[
|
|
`legacy-${mediaType}-resource`,
|
|
{
|
|
imageSrc:
|
|
mediaType === 'video'
|
|
? '/legacy/cutscene.mp4'
|
|
: '/legacy/theme.mp3',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated?.mediaType).toBe(mediaType);
|
|
}
|
|
});
|
|
|
|
it('recovers the normal source model after the owner payload removes an internal model', () => {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-derived',
|
|
resourceId: 'resource-derived',
|
|
title: '去背景结果',
|
|
x: 10,
|
|
y: 20,
|
|
width: 1024,
|
|
height: 768,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 9,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-source',
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-derived',
|
|
{
|
|
imageSrc: '/read/derived.png',
|
|
sourceResourceId: 'resource-source',
|
|
},
|
|
],
|
|
[
|
|
'resource-source',
|
|
{
|
|
imageSrc: '/read/source.png',
|
|
model: 'gpt-image-2',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated?.model).toBe('gpt-image-2');
|
|
});
|
|
|
|
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 a layer override ahead of the shared resource default', () => {
|
|
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',
|
|
assetKindOverride: 'publication-material',
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-publication',
|
|
{ imageSrc: '/read/publication.png', assetKind: 'ui-design' },
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-publication',
|
|
resourceAssetKind: 'ui-design',
|
|
assetKindOverride: 'publication-material',
|
|
assetKind: 'publication-material',
|
|
});
|
|
});
|
|
|
|
it('falls back from an incompatible persisted override without dropping the layer', () => {
|
|
const onAssetKindOverrideFallback = vi.fn();
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-image-with-action-label',
|
|
resourceId: 'resource-image',
|
|
title: '普通图片',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
assetKindOverride: 'character-animation',
|
|
},
|
|
new Map([
|
|
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
|
|
]),
|
|
{ onAssetKindOverrideFallback },
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-image-with-action-label',
|
|
src: '/read/image.png',
|
|
mediaType: 'image',
|
|
resourceAssetKind: 'image',
|
|
assetKindOverride: null,
|
|
assetKind: 'image',
|
|
});
|
|
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
|
|
layerId: 'layer-image-with-action-label',
|
|
resourceId: 'resource-image',
|
|
resourceAssetKind: 'image',
|
|
rejectedAssetKindOverride: 'character-animation',
|
|
});
|
|
});
|
|
|
|
it('uses the same media-family compatibility matrix for layer labels', () => {
|
|
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
|
|
true,
|
|
);
|
|
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
|
|
expect(
|
|
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
|
|
).toBe(true);
|
|
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
|
|
expect(
|
|
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
|
|
).toBe(false);
|
|
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
|
|
false,
|
|
);
|
|
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
|
|
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
|
|
});
|
|
|
|
it('hydrates audio display duration only from resource generation inputs', () => {
|
|
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',
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-audio',
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
generationInputs: {
|
|
fields: [{ title: '时长', value: '3.5秒' }],
|
|
references: [],
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-audio',
|
|
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
generationInputs: {
|
|
fields: [{ title: '时长', value: '3.5秒' }],
|
|
references: [],
|
|
},
|
|
});
|
|
expect(hydrated).not.toHaveProperty('durationSeconds');
|
|
});
|
|
|
|
it('keeps only valid authoritative SFX V2 metadata in generation inputs', () => {
|
|
const soundEffect = {
|
|
schemaVersion: 2 as const,
|
|
userPrompt: '金币落地,轻快可爱',
|
|
actualPrompt: 'A bright, cute coin landing chime',
|
|
model: 'eleven_text_to_sound_v2' as const,
|
|
durationMode: 'manual' as const,
|
|
requestedDurationSeconds: 5,
|
|
actualDurationSeconds: 5.12,
|
|
loop: false,
|
|
};
|
|
expect(
|
|
generationInputsOrNull({ fields: [], references: [], soundEffect }),
|
|
).toEqual({ fields: [], references: [], soundEffect });
|
|
|
|
for (const invalidSoundEffect of [
|
|
{ ...soundEffect, schemaVersion: 1 },
|
|
{ ...soundEffect, model: 'audio1.0' },
|
|
{ ...soundEffect, userPrompt: ' 金币落地' },
|
|
{ ...soundEffect, actualDurationSeconds: 600.1 },
|
|
{
|
|
...soundEffect,
|
|
durationMode: 'auto',
|
|
requestedDurationSeconds: 5,
|
|
},
|
|
{
|
|
...soundEffect,
|
|
durationMode: 'manual',
|
|
requestedDurationSeconds: null,
|
|
},
|
|
]) {
|
|
expect(
|
|
generationInputsOrNull({
|
|
fields: [{ title: '用户描述', value: '金币落地' }],
|
|
references: [],
|
|
soundEffect: invalidSoundEffect,
|
|
}),
|
|
).toBeNull();
|
|
}
|
|
});
|
|
|
|
it('hydrates character animation sequence fields from the project resource', () => {
|
|
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: [
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
|
objectKey: 'generated-character-drafts/editor/frame01.png',
|
|
assetObjectId: 'assetobj-frame-01',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
|
objectKey: 'generated-character-drafts/editor/frame02.png',
|
|
assetObjectId: 'assetobj-frame-02',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
x: 10,
|
|
y: 20,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
zIndex: 6,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceDurationMs: 4_000,
|
|
};
|
|
|
|
const snapshot = serializeLayer(layer);
|
|
expect(snapshot.thumbnailSrc).toBeUndefined();
|
|
expect(snapshot.imageSequenceFrames).toBeUndefined();
|
|
expect(snapshot.imageSequenceDurationMs).toBeUndefined();
|
|
expect(snapshot.mediaType).toBeUndefined();
|
|
|
|
const hydrated = hydrateLayer(
|
|
snapshot,
|
|
new Map([
|
|
[
|
|
'resource-action',
|
|
{
|
|
imageSrc: layer.src,
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: layer.imageSequenceFrames,
|
|
imageSequenceDurationMs: 4_000,
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
mediaType: 'image-sequence',
|
|
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
|
imageSequenceFrames: layer.imageSequenceFrames,
|
|
assetKind: 'character-animation',
|
|
imageSequenceDurationMs: 4_000,
|
|
});
|
|
});
|
|
|
|
it('rejects an action layer when its project resource lacks formal sequence fields', () => {
|
|
const frames = [
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/legacy-frame01.png',
|
|
objectKey: 'generated-character-drafts/editor/legacy-frame01.png',
|
|
assetObjectId: 'assetobj-legacy-frame-01',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/legacy-frame02.png',
|
|
objectKey: 'generated-character-drafts/editor/legacy-frame02.png',
|
|
assetObjectId: 'assetobj-legacy-frame-02',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
];
|
|
const resources = new Map([
|
|
[
|
|
'resource-legacy-action',
|
|
{
|
|
imageSrc: frames[0]!.imageSrc,
|
|
assetKind: 'character-animation',
|
|
},
|
|
],
|
|
]);
|
|
const hydration = hydrateLayer(
|
|
{
|
|
layerId: 'layer-legacy-action',
|
|
resourceId: 'resource-legacy-action',
|
|
title: '历史角色动作',
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: frames,
|
|
imageSequenceDurationMs: 3_600,
|
|
previewVideoPath:
|
|
'/generated-character-drafts/editor/legacy-preview.mp4',
|
|
},
|
|
resources,
|
|
);
|
|
|
|
expect(hydration).toBeNull();
|
|
});
|
|
|
|
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',
|
|
style: 'pixelArt',
|
|
generationStartedAt: 1_771_400_000_000,
|
|
generationFinishedAt: 1_771_400_004_000,
|
|
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',
|
|
style: 'pixelArt',
|
|
generationStartedAt: 1_771_400_000_000,
|
|
generationFinishedAt: 1_771_400_004_000,
|
|
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('keeps the operation ledger out of the layout and restores it from the local ledger', () => {
|
|
const dialogId = 'dialog-perfect-pixel-round-trip';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'pending-confirmation',
|
|
composerOpen: false,
|
|
requiresLiveSession: true,
|
|
perfectPixelOperation: operation,
|
|
});
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: dialogId,
|
|
status: 'pending-confirmation',
|
|
perfectPixelOperationId: dialogId,
|
|
perfectPixelOperation: operation,
|
|
});
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
|
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
});
|
|
const serializedLayout = JSON.stringify(layout);
|
|
expect(serializedLayout).toContain('"perfectPixelOperationId"');
|
|
expect(serializedLayout).not.toContain('"perfectPixelOperation"');
|
|
expect(serializedLayout).not.toContain(operation.request.sourceImageSrc);
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
layout,
|
|
new Map(),
|
|
undefined,
|
|
new Map([[dialogId, operation]]),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(operation);
|
|
expect(generationDialogs[0]).not.toHaveProperty(
|
|
'perfectPixelOperationInvalid',
|
|
);
|
|
});
|
|
|
|
it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => {
|
|
// 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在
|
|
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
|
|
// 绝不能被判成失败。
|
|
const dialogId = 'dialog-perfect-pixel-settled';
|
|
const settledSnapshot = {
|
|
id: dialogId,
|
|
mode: 'quick-edit' as const,
|
|
prompt: '完美像素',
|
|
status: 'idle' as const,
|
|
composerOpen: false,
|
|
generatedLayerId: `layer-${dialogId}`,
|
|
perfectPixelOperationId: dialogId,
|
|
};
|
|
|
|
const hydrated = hydrateCanvasGenerationDialog(settledSnapshot);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: dialogId,
|
|
status: 'idle',
|
|
generatedLayerId: `layer-${dialogId}`,
|
|
});
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationId');
|
|
expect(hydrated?.errorMessage).toBeUndefined();
|
|
|
|
// 中文注释:标记的寿命必须与账本对齐——收口后不再写回布局,否则错误形状会一直堆积。
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
});
|
|
expect(JSON.stringify(layout)).not.toContain('"perfectPixelOperationId"');
|
|
});
|
|
|
|
it('heals a settled placeholder that a previous build wrote back as invalid', () => {
|
|
// 中文注释:上一版判据会把成功结果写成 failed + perfectPixelOperationInvalid 并落库。
|
|
// 这类已经被写脏的行必须在下一次 hydrate 时自愈,否则错误状态会自我固化。
|
|
const dialogId = 'dialog-perfect-pixel-poisoned';
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'failed',
|
|
composerOpen: false,
|
|
generatedLayerId: `layer-${dialogId}`,
|
|
perfectPixelOperationId: dialogId,
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照无效,禁止自动重试。',
|
|
});
|
|
|
|
expect(hydrated).toMatchObject({ id: dialogId, status: 'idle' });
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
|
expect(hydrated?.errorMessage).toBeUndefined();
|
|
});
|
|
|
|
it('drops the inline legacy ledger of an already settled placeholder without marking it', () => {
|
|
const dialogId = 'dialog-perfect-pixel-settled-legacy';
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [
|
|
{
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'idle',
|
|
composerOpen: false,
|
|
generatedLayerId: `layer-${dialogId}`,
|
|
perfectPixelOperation: buildPerfectPixelOperation(dialogId),
|
|
} as CanvasGenerationDialogState,
|
|
],
|
|
});
|
|
const serializedLayout = JSON.stringify(layout);
|
|
expect(serializedLayout).not.toContain('"perfectPixelOperation"');
|
|
expect(serializedLayout).not.toContain('"perfectPixelOperationId"');
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(layout);
|
|
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: dialogId,
|
|
status: 'idle',
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty(
|
|
'perfectPixelOperationInvalid',
|
|
);
|
|
});
|
|
|
|
it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => {
|
|
const dialogId = 'dialog-perfect-pixel-other-device';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
perfectPixelOperation: operation,
|
|
});
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
});
|
|
|
|
// 中文注释:换设备 / 清缓存 / 隐私模式——布局里标记还在,本机账本读不到。这是明确
|
|
// 设计:占位收口成可删除的失败态,绝不停在无从收口的处理中态。
|
|
const { generationDialogs } = splitCanvasLayoutItems(layout);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: dialogId,
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
|
|
});
|
|
|
|
it('ignores a local ledger entry whose id does not match the placeholder', () => {
|
|
const dialogId = 'dialog-perfect-pixel-ledger-mismatch';
|
|
const operation = buildPerfectPixelOperation('dialog-perfect-pixel-other');
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
[
|
|
{
|
|
itemType: 'generation-dialog',
|
|
layerId: `generation-dialog:${dialogId}`,
|
|
resourceId: `generation-dialog:${dialogId}`,
|
|
dialog: {
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
perfectPixelOperationId: dialogId,
|
|
},
|
|
} as unknown as EditorProjectLayerSnapshot,
|
|
],
|
|
new Map(),
|
|
undefined,
|
|
new Map([['dialog-perfect-pixel-other', operation]]),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: dialogId,
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
|
|
});
|
|
|
|
it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => {
|
|
vi.useFakeTimers();
|
|
const now = 1_700_000_010_000;
|
|
vi.setSystemTime(now);
|
|
try {
|
|
const dialogId = 'dialog-perfect-pixel-legacy-window';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const legacyOperation = {
|
|
...operation,
|
|
reconcileUntil: operation.submittedAt + 240_000,
|
|
};
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'pending-confirmation',
|
|
composerOpen: false,
|
|
perfectPixelOperation: legacyOperation,
|
|
});
|
|
const expectedOperation = {
|
|
...legacyOperation,
|
|
reconcileUntil:
|
|
operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
|
};
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: dialogId,
|
|
status: 'pending-confirmation',
|
|
perfectPixelOperation: expectedOperation,
|
|
});
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
}),
|
|
new Map(),
|
|
undefined,
|
|
new Map([[dialogId, expectedOperation]]),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: dialogId,
|
|
status: 'pending-confirmation',
|
|
perfectPixelOperation: expectedOperation,
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty(
|
|
'perfectPixelOperationInvalid',
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('fails closed instead of replaying an invalid perfect-pixel operation snapshot', () => {
|
|
const dialogId = 'dialog-perfect-pixel-invalid';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const invalidOperations: unknown[] = [
|
|
{ ...operation, version: 2 },
|
|
{ ...operation, operationId: 'another-dialog' },
|
|
{ ...operation, taskId: 'pixel-art-snap-another-dialog' },
|
|
{
|
|
...operation,
|
|
request: { ...operation.request, projectId: ' ' },
|
|
},
|
|
{
|
|
...operation,
|
|
request: {
|
|
...operation.request,
|
|
sourceImageSrc: 'data:image/png;base64,unsafe',
|
|
},
|
|
},
|
|
{
|
|
...operation,
|
|
request: {
|
|
...operation.request,
|
|
sourceImageSrc:
|
|
'https://oss.example.test/source.png?Expires=1700000000&Signature=temporary',
|
|
},
|
|
},
|
|
{
|
|
...operation,
|
|
request: {
|
|
...operation.request,
|
|
canvasCompletion: {
|
|
...operation.request.canvasCompletion,
|
|
dialogId: 'another-dialog',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...operation,
|
|
reconcileUntil: operation.submittedAt + 240_000 + 1,
|
|
},
|
|
{ ...operation, unknownFutureField: true },
|
|
];
|
|
|
|
for (const invalidOperation of invalidOperations) {
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'pending-confirmation',
|
|
perfectPixelOperation: invalidOperation,
|
|
});
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: dialogId,
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照无效,禁止自动重试。',
|
|
});
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperation');
|
|
}
|
|
});
|
|
|
|
it('preserves a legacy clock-skewed operation while capping its current observation window', () => {
|
|
vi.useFakeTimers();
|
|
const now = 1_700_000_000_000;
|
|
vi.setSystemTime(now);
|
|
try {
|
|
const dialogId = 'dialog-perfect-pixel-clock-skew';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const submittedAt = now + 180_000;
|
|
const expectedOperation = {
|
|
...operation,
|
|
submittedAt: now,
|
|
reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
|
};
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'pending-confirmation',
|
|
perfectPixelOperation: {
|
|
...operation,
|
|
submittedAt,
|
|
reconcileUntil: submittedAt + 240_000,
|
|
},
|
|
});
|
|
|
|
expect(hydrated?.perfectPixelOperation).toEqual(expectedOperation);
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
}),
|
|
new Map(),
|
|
undefined,
|
|
new Map([[dialogId, expectedOperation]]),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
|
|
expectedOperation,
|
|
);
|
|
expect(generationDialogs[0]).not.toHaveProperty(
|
|
'perfectPixelOperationInvalid',
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('round-trips the blocked marker after clearing an invalid operation snapshot', () => {
|
|
const dialogId = 'dialog-perfect-pixel-blocked';
|
|
const operation = buildPerfectPixelOperation(dialogId);
|
|
const failedClosed = hydrateCanvasGenerationDialog({
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
perfectPixelOperation: { ...operation, taskId: 'wrong-task' },
|
|
});
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [failedClosed as CanvasGenerationDialogState],
|
|
}),
|
|
);
|
|
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: dialogId,
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照无效,禁止自动重试。',
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
|
|
});
|
|
|
|
it('fails closed when pending-confirmation has no operation journal', () => {
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: 'dialog-perfect-pixel-orphaned-pending',
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'pending-confirmation',
|
|
});
|
|
|
|
expect(hydrated).toMatchObject({
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照无效,禁止自动重试。',
|
|
});
|
|
expect(hydrated).not.toHaveProperty('perfectPixelOperation');
|
|
});
|
|
|
|
it('defaults restored supported image styles to none and drops them from other modes', () => {
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-legacy',
|
|
mode: 'generate',
|
|
prompt: '旧任务',
|
|
status: 'idle',
|
|
})?.style,
|
|
).toBe('none');
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-unknown-style',
|
|
mode: 'character',
|
|
prompt: '未知风格',
|
|
status: 'idle',
|
|
style: 'futureStyle',
|
|
})?.style,
|
|
).toBe('none');
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-spec',
|
|
mode: 'spec',
|
|
prompt: '规范任务',
|
|
status: 'idle',
|
|
style: 'pixelArt',
|
|
})?.style,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('drops restored generator references owned by another user', () => {
|
|
const dialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-owner',
|
|
mode: 'generate',
|
|
prompt: '不应复用别人素材',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [
|
|
{
|
|
id: 'foreign-reference',
|
|
label: '别人账号的素材',
|
|
src: 'data:image/png;base64,foreign',
|
|
resourceId: 'resource-foreign',
|
|
},
|
|
],
|
|
};
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [dialog],
|
|
}),
|
|
new Map([
|
|
[
|
|
'resource-foreign',
|
|
{
|
|
imageSrc: '/read/foreign.png',
|
|
objectKey: 'generated/foreign.png',
|
|
ownerUserId: 'user-b',
|
|
},
|
|
],
|
|
]),
|
|
'user-a',
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]?.generationReferences).toEqual([]);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
it('keeps the legacy placeholder window above source preparation', () => {
|
|
// 中文注释:占位一旦挂上 perfectPixelOperationId 就退出 legacy requiresLiveSession 清理,
|
|
// 所以这个窗口只需覆盖标记写入之前的那一段——源图解析/直传。POST 与对账由账本自己保护。
|
|
expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan(
|
|
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS,
|
|
);
|
|
expect(
|
|
INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS -
|
|
PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS,
|
|
).toBeGreaterThanOrEqual(90_000);
|
|
});
|
|
|
|
describe('inline placeholder expiry helpers', () => {
|
|
const dialog = (
|
|
overrides: Partial<CanvasGenerationDialogState>,
|
|
): CanvasGenerationDialogState =>
|
|
({
|
|
id: 'dialog-1',
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
...overrides,
|
|
}) as CanvasGenerationDialogState;
|
|
|
|
it('reports an inline placeholder as expired only after the window elapses', () => {
|
|
// 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界
|
|
// 必须一致——正好到期不算过期,超过一毫秒才算。
|
|
const now = 1_700_000_000_000;
|
|
const live = [
|
|
dialog({
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 240_000,
|
|
}),
|
|
];
|
|
const stale = [
|
|
dialog({
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 240_001,
|
|
}),
|
|
];
|
|
|
|
expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]);
|
|
expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([
|
|
'dialog-1',
|
|
]);
|
|
});
|
|
|
|
it('never expires queue-backed or settled placeholders', () => {
|
|
// 中文注释:队列型占位的 job 在服务端继续跑,误清会让用户以为没发生而重复提交;
|
|
// 已终态的占位也不该被自动清掉,那是用户要自己处置的失败卡片。
|
|
const now = 1_700_000_000_000;
|
|
const dialogs = [
|
|
dialog({ id: 'queued', generationStartedAt: now - 999_999 }),
|
|
dialog({
|
|
id: 'settled',
|
|
requiresLiveSession: true,
|
|
status: 'failed',
|
|
generationStartedAt: now - 999_999,
|
|
}),
|
|
];
|
|
|
|
expect(collectExpiredInlineGenerationDialogIds(dialogs, now)).toEqual([]);
|
|
expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull();
|
|
});
|
|
|
|
it('never expires a generating placeholder backed by a perfect-pixel operation snapshot', () => {
|
|
const operationDialogId = 'operation-backed';
|
|
const operationBackedDialog = dialog({
|
|
id: operationDialogId,
|
|
requiresLiveSession: true,
|
|
generationStartedAt: 1,
|
|
perfectPixelOperation: buildPerfectPixelOperation(operationDialogId),
|
|
});
|
|
|
|
expect(
|
|
collectExpiredInlineGenerationDialogIds(
|
|
[operationBackedDialog],
|
|
1_700_000_000_000,
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
resolveNextInlineGenerationDialogExpiryAt([operationBackedDialog]),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('expires a live-session placeholder that carries no usable timestamp', () => {
|
|
// 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。
|
|
expect(
|
|
collectExpiredInlineGenerationDialogIds(
|
|
[dialog({ requiresLiveSession: true })],
|
|
1_700_000_000_000,
|
|
),
|
|
).toEqual(['dialog-1']);
|
|
});
|
|
|
|
it('resolves the earliest expiry so callers can arm a single timer', () => {
|
|
// 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。
|
|
const now = 1_700_000_000_000;
|
|
const dialogs = [
|
|
dialog({
|
|
id: 'later',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 10_000,
|
|
}),
|
|
dialog({
|
|
id: 'sooner',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 60_000,
|
|
}),
|
|
];
|
|
|
|
expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe(
|
|
now - 60_000 + 240_000,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('dropDeadInlineGenerationPlaceholders', () => {
|
|
const buildProject = (
|
|
layers: EditorProjectLayerSnapshot[],
|
|
): EditorProjectSnapshot => ({
|
|
projectId: 'project-1',
|
|
title: '画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers,
|
|
resources: [],
|
|
updatedAt: '2026-08-03T00:00:00.000Z',
|
|
});
|
|
|
|
const withCanvasMirror = (
|
|
project: EditorProjectSnapshot,
|
|
layers: EditorProjectLayerSnapshot[],
|
|
): EditorProjectSnapshot => ({
|
|
...project,
|
|
canvas: {
|
|
canvasId: 'canvas-1',
|
|
projectId: project.projectId,
|
|
title: project.title,
|
|
viewport: project.viewport,
|
|
layers,
|
|
updatedAt: project.updatedAt,
|
|
},
|
|
});
|
|
|
|
const buildDialogItem = (
|
|
id: string,
|
|
dialog: Record<string, unknown>,
|
|
): EditorProjectLayerSnapshot =>
|
|
({
|
|
itemType: 'generation-dialog',
|
|
layerId: `generation-dialog:${id}`,
|
|
resourceId: `generation-dialog:${id}`,
|
|
dialog: { id, mode: 'quick-edit', prompt: '完美像素', ...dialog },
|
|
}) as unknown as EditorProjectLayerSnapshot;
|
|
|
|
it('drops generating placeholders whose only owner was a dead session', () => {
|
|
const project = buildProject([
|
|
buildDialogItem('dead', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
}),
|
|
]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project);
|
|
|
|
expect(result.droppedCount).toBe(1);
|
|
expect(result.project.layers).toEqual([]);
|
|
});
|
|
|
|
it('keeps a live-session placeholder that is still inside the live window', () => {
|
|
// 中文注释:这是多标签页的核心场景。B 标签打开同一项目时会 hydrate 到 A 标签正在用的
|
|
// 活占位——原先的结构性判据(「从服务端读回来的必然属于已死会话」)在这里是假的,会被
|
|
// 当成孤儿剥离,再由 B 下一次布局保存以当前 revision 合法写回,把 A 的占位删掉。
|
|
const now = 1_700_000_000_000;
|
|
const project = buildProject([
|
|
buildDialogItem('live', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 30_000,
|
|
}),
|
|
]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
|
|
|
expect(result.droppedCount).toBe(0);
|
|
expect(result.project).toBe(project);
|
|
});
|
|
|
|
it('keeps a live-session placeholder right up to the window boundary', () => {
|
|
// 中文注释:窗口是「服务端最坏 90 秒 + 客户端 120 秒上限 + 余量」推出来的,边界必须是
|
|
// 包含式:正好 180 秒时操作仍可能刚刚收口,不能剥。
|
|
const now = 1_700_000_000_000;
|
|
const project = buildProject([
|
|
buildDialogItem('boundary', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 240_000,
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
dropDeadInlineGenerationPlaceholders(project, now).droppedCount,
|
|
).toBe(0);
|
|
});
|
|
|
|
it('drops a live-session placeholder once the window has elapsed', () => {
|
|
// 中文注释:超过窗口意味着客户端早已 abort 并把占位改成 failed 或移除,服务端也越过了
|
|
// 90 秒硬上限——此时还停在 generating 就确定是孤儿。
|
|
const now = 1_700_000_000_000;
|
|
const project = buildProject([
|
|
buildDialogItem('stale', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 240_001,
|
|
}),
|
|
]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
|
|
|
expect(result.droppedCount).toBe(1);
|
|
expect(result.project.layers).toEqual([]);
|
|
});
|
|
|
|
it('drops a dead legacy placeholder from both layout mirrors exactly once', () => {
|
|
const now = 1_700_000_000_000;
|
|
const stale = buildDialogItem('stale-mirrored', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 240_001,
|
|
});
|
|
const project = withCanvasMirror(buildProject([stale]), [stale]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
|
|
|
expect(result.droppedCount).toBe(1);
|
|
expect(result.project.layers).toEqual([]);
|
|
expect(result.project.canvas?.layers).toEqual([]);
|
|
});
|
|
|
|
it('drops a live-session placeholder that carries no usable timestamp', () => {
|
|
// 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏
|
|
// 只是退回引入时间窗之前的行为。
|
|
const now = 1_700_000_000_000;
|
|
for (const generationStartedAt of [
|
|
undefined,
|
|
Number.NaN,
|
|
'not-a-number',
|
|
]) {
|
|
const project = buildProject([
|
|
buildDialogItem('no-timestamp', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
...(generationStartedAt === undefined
|
|
? {}
|
|
: { generationStartedAt }),
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
dropDeadInlineGenerationPlaceholders(project, now).droppedCount,
|
|
).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('keeps generating placeholders backed by a durable job', () => {
|
|
// 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会
|
|
// 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。
|
|
const project = buildProject([
|
|
buildDialogItem('queued', { status: 'generating' }),
|
|
buildDialogItem('queued-explicit-false', {
|
|
status: 'generating',
|
|
requiresLiveSession: false,
|
|
}),
|
|
]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project);
|
|
|
|
expect(result.droppedCount).toBe(0);
|
|
expect(result.project).toBe(project);
|
|
});
|
|
|
|
it('keeps generating and pending placeholders backed by a perfect-pixel operation snapshot', () => {
|
|
const now = 1_700_000_000_000;
|
|
for (const status of ['generating', 'pending-confirmation'] as const) {
|
|
const dialogId = `operation-${status}`;
|
|
const operationBacked = buildDialogItem(dialogId, {
|
|
status,
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 999_999,
|
|
perfectPixelOperation: buildPerfectPixelOperation(dialogId),
|
|
});
|
|
const project = withCanvasMirror(buildProject([operationBacked]), [
|
|
operationBacked,
|
|
]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
|
|
|
expect(result.droppedCount).toBe(0);
|
|
expect(result.project).toBe(project);
|
|
}
|
|
});
|
|
|
|
it('preserves an invalid operation journal long enough for hydration to fail closed', () => {
|
|
const now = 1_700_000_000_000;
|
|
const invalidJournal = buildDialogItem('invalid-operation', {
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
generationStartedAt: now - 999_999,
|
|
perfectPixelOperation: {
|
|
...buildPerfectPixelOperation('invalid-operation'),
|
|
taskId: 'wrong-task',
|
|
},
|
|
});
|
|
const project = buildProject([invalidJournal]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project, now);
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
result.project.layers,
|
|
);
|
|
|
|
expect(result.droppedCount).toBe(0);
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: 'invalid-operation',
|
|
status: 'failed',
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照无效,禁止自动重试。',
|
|
});
|
|
expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation');
|
|
});
|
|
|
|
it('keeps settled inline placeholders and unrelated layout items', () => {
|
|
const settled = buildDialogItem('settled', {
|
|
status: 'failed',
|
|
requiresLiveSession: true,
|
|
});
|
|
const imageLayer = {
|
|
itemType: 'layer',
|
|
layerId: 'layer-1',
|
|
resourceId: 'resource-1',
|
|
} as unknown as EditorProjectLayerSnapshot;
|
|
const project = buildProject([settled, imageLayer]);
|
|
|
|
const result = dropDeadInlineGenerationPlaceholders(project);
|
|
|
|
expect(result.droppedCount).toBe(0);
|
|
expect(result.project.layers).toEqual([settled, imageLayer]);
|
|
});
|
|
|
|
it('round-trips the marker through hydration so reloads can still detect it', () => {
|
|
// 中文注释:hydrate 是白名单式的,漏掉这个字段会让标记在一次「加载→保存」后消失,
|
|
// 孤儿占位重新变得不可识别。
|
|
const hydrated = hydrateCanvasGenerationDialog({
|
|
id: 'dialog-1',
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
requiresLiveSession: true,
|
|
});
|
|
|
|
expect(hydrated?.requiresLiveSession).toBe(true);
|
|
|
|
const [item] = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
|
});
|
|
|
|
expect(
|
|
(item as unknown as { dialog: { requiresLiveSession?: boolean } })
|
|
.dialog.requiresLiveSession,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
});
|