import { describe, expect, it, vi } from 'vitest'; import type { EditorProjectLayerSnapshot, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { CANVAS_WORLD_ORIGIN, canvasAssetKindOrNull, 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('hydrates complete V2 generation inputs without coercing values or requiring labels', () => { expect( generationInputsOrNull({ version: 2, action: 'video.generate', fields: [ { id: 'durationSeconds', title: '时长', value: 8 }, { id: 'webSearchEnabled', title: '联网搜索', value: false }, ], references: [ { id: 'reference', title: '参考视频', refType: 'asset', refId: 'asset-video-reference', }, ], }), ).toEqual({ version: 2, action: 'video.generate', fields: [ { id: 'durationSeconds', title: '时长', value: 8 }, { id: 'webSearchEnabled', title: '联网搜索', value: false }, ], references: [ { id: 'reference', title: '参考视频', refType: 'asset', refId: 'asset-video-reference', }, ], }); }); it('ignores unknown nested V2 properties during non-strict resource hydration', () => { expect( generationInputsOrNull({ version: 2, action: 'image.generate', futureTopLevelProperty: true, fields: [ { id: 'prompt', title: '生成提示词', value: '未来配方', futureFieldProperty: 'ignored', }, ], references: [ { id: 'reference', title: '参考图', label: '未来素材', refType: 'asset', refId: 'asset-future', futureReferenceProperty: 1, }, ], }), ).toEqual({ version: 2, action: 'image.generate', fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }], references: [ { id: 'reference', title: '参考图', label: '未来素材', refType: 'asset', refId: 'asset-future', }, ], }); }); it('fails malformed V2 closed while preserving legacy hydration', () => { expect( generationInputsOrNull({ version: 2, fields: [{ title: '生成提示词', value: '不得降级' }], references: [], }), ).toBeNull(); expect( generationInputsOrNull({ fields: [{ title: '生成提示词', value: '旧版配方' }], references: [], }), ).toEqual({ fields: [{ title: '生成提示词', value: '旧版配方' }], references: [], }); expect( generationInputsOrNull({ fields: [{ title: '生成提示词', value: '仅字段旧版配方' }], }), ).toEqual({ fields: [{ title: '生成提示词', value: '仅字段旧版配方' }], references: [], }); }); it('preserves an invalid versioned resource marker instead of falling back to layout legacy inputs', () => { const hydrated = hydrateLayer( { layerId: 'layer-invalid-versioned-recipe', resourceId: 'resource-invalid-versioned-recipe', title: '损坏配方生成图', src: '/layout/fallback.png', x: 0, y: 0, width: 320, height: 320, originalWidth: 320, originalHeight: 320, zIndex: 1, sourceType: 'generated', generationInputs: { fields: [{ title: '生成提示词', value: '不得当作旧版恢复' }], references: [], }, }, new Map([ [ 'resource-invalid-versioned-recipe', { imageSrc: '/resource/generated.png', sourceType: 'generated', generationInputs: { version: 3, action: 'image.generate', fields: [], references: [], }, }, ], ]), ); expect(hydrated).toMatchObject({ generationInputs: null, generationInputsHydrationState: 'invalid-versioned', }); }); 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: null, sourceResourceId: 'resource-generated', }, ], }); expect(library.assets[0]).toMatchObject({ id: 'asset-generated', sourceResourceId: 'resource-generated', assetKind: null, }); }); it('restores V2 recipes from the persisted asset library after refresh', () => { const generationInputs = { version: 2 as const, action: 'video.generate' as const, fields: [ { id: 'durationSeconds', title: '时长', value: 8 }, { id: 'webSearchEnabled', title: '联网搜索', value: true }, ], references: [ { id: 'reference', title: '参考视频', refType: 'asset' as const, refId: 'asset-reference', }, ], }; const library = normalizeAssetLibrary({ folders: [], assets: [ { assetId: 'asset-v2', folderId: 'project', label: 'V2 视频', imageSrc: '/generated/video.mp4', width: 1280, height: 720, sourceType: 'generated', assetKind: 'video', generationInputs, }, ], }); expect(library.assets[0]?.generationInputs).toEqual(generationInputs); }); 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('restores V2 recipes from project resources after refresh', () => { const generationInputs = { version: 2 as const, action: 'video.generate' as const, fields: [ { id: 'durationSeconds', title: '时长', value: 8 }, { id: 'webSearchEnabled', title: '联网搜索', value: false }, ], references: [ { id: 'reference', title: '参考视频', refType: 'project-resource' as const, refId: 'resource-reference', }, ], }; const hydrated = hydrateLayer( { layerId: 'layer-v2', resourceId: 'resource-v2', title: 'V2 视频', x: 0, y: 0, width: 1280, height: 720, originalWidth: 1280, originalHeight: 720, zIndex: 1, sourceType: 'generated', }, new Map([ [ 'resource-v2', { imageSrc: '/generated/video.mp4', assetKind: 'video', generationInputs, }, ], ]), ); expect(hydrated?.generationInputs).toEqual(generationInputs); }); 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: null }], ]), { onAssetKindOverrideFallback }, ); expect(hydrated).toMatchObject({ id: 'layer-image-with-action-label', src: '/read/image.png', mediaType: 'image', resourceAssetKind: null, assetKindOverride: null, assetKind: null, }); expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({ layerId: 'layer-image-with-action-label', resourceId: 'resource-image', resourceAssetKind: null, rejectedAssetKindOverride: 'character-animation', }); }); it('uses the same media-family compatibility matrix for layer labels', () => { expect(canvasAssetKindOrNull('image')).toBeNull(); expect(isCanvasAssetKindOverrideCompatible(null, 'character')).toBe(true); expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true); expect( isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'), ).toBe(true); expect(isCanvasAssetKindOverrideCompatible(null, 'video')).toBe(false); expect( isCanvasAssetKindOverrideCompatible(null, '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 }); // 中文注释:自动时长的 requestedDurationSeconds 是「没有请求时长」,显式 null 与整个键 // 缺失同义。服务端读边界的内联媒体脱敏曾把这个 null 连键删掉,只认 null 会让整份元数据 // 被判非法,音频信息页连 fields 一起空掉。 const autoSoundEffect = { ...soundEffect, durationMode: 'auto' as const, requestedDurationSeconds: null, }; const autoSoundEffectWithoutRequestedDuration: Record = { ...autoSoundEffect, }; delete autoSoundEffectWithoutRequestedDuration.requestedDurationSeconds; for (const validAutoSoundEffect of [ autoSoundEffect, autoSoundEffectWithoutRequestedDuration, ]) { expect( generationInputsOrNull({ fields: [{ title: '用户描述', value: '金币落地' }], references: [], soundEffect: validAutoSoundEffect, }), ).toEqual({ fields: [{ title: '用户描述', value: '金币落地' }], references: [], soundEffect: autoSoundEffect, }); } for (const invalidSoundEffect of [ { ...soundEffect, schemaVersion: 1 }, { ...soundEffect, model: 'audio1.0' }, { ...soundEffect, userPrompt: ' 金币落地' }, { ...soundEffect, userPrompt: '\uFEFF金币落地,轻快可爱' }, { ...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('hydrates a character-animation preview video through its source resource', () => { const frames = [ { imageSrc: '/generated/sequence/frame-1.png', objectKey: 'generated/sequence/frame-1.png', width: 480, height: 480, }, { imageSrc: '/generated/sequence/frame-2.png', objectKey: 'generated/sequence/frame-2.png', width: 480, height: 480, }, ]; const hydrated = hydrateLayer( { layerId: 'layer-sequence', resourceId: 'resource-sequence', title: '角色动作', sourceType: 'generated', }, new Map([ [ 'resource-sequence', { resourceId: 'resource-sequence', imageSrc: frames[0]!.imageSrc, objectKey: frames[0]!.objectKey, assetKind: 'character-animation', imageSequenceFrames: frames, imageSequenceDurationMs: 4_000, taskId: 'animation-task-1', sourceResourceId: 'resource-preview-video', }, ], [ 'resource-preview-video', { resourceId: 'resource-preview-video', imageSrc: '/generated/video/preview.mp4', objectKey: 'generated/video/preview.mp4', assetKind: 'video', taskId: 'animation-task-1', }, ], ]), ); expect(hydrated).toMatchObject({ assetKind: 'character-animation', mediaType: 'image-sequence', previewVideoPath: 'generated/video/preview.mp4', imageSequenceFrames: frames, imageSequenceDurationMs: 4_000, }); }); it('does not hydrate an older action reference as a generated preview video', () => { const frames = [ { imageSrc: '/generated/sequence/frame-1.png', width: 480, height: 480 }, { imageSrc: '/generated/sequence/frame-2.png', width: 480, height: 480 }, ]; const hydrated = hydrateLayer( { layerId: 'layer-sequence', resourceId: 'resource-sequence', title: '角色动作', sourceType: 'generated', }, new Map([ [ 'resource-sequence', { imageSrc: frames[0]!.imageSrc, assetKind: 'character-animation', imageSequenceFrames: frames, imageSequenceDurationMs: 4_000, taskId: 'animation-task-2', sourceResourceId: 'resource-action-video', }, ], [ 'resource-action-video', { imageSrc: '/generated/source/action.mp4', objectKey: 'generated/source/action.mp4', assetKind: 'video', taskId: 'source-task-1', }, ], ]), ); expect(hydrated?.previewVideoPath).toBeNull(); }); 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, soundModel: 'eleven_text_to_sound_v2', soundDurationMode: 'auto', soundDurationSeconds: 7.3, soundLoop: true, 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: '金币掉落叮当声', soundModel: 'eleven_text_to_sound_v2', soundDurationMode: 'auto', soundDurationSeconds: 7.3, soundLoop: true, 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', width: 600, height: 800, }, ], }; 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', width: 600, height: 800, }); }); it('restores scene generation dialog fields', () => { expect( hydrateCanvasGenerationDialog({ id: 'generation-dialog-scene', mode: 'scene', prompt: '海边车站', status: 'idle', sceneStylePreset: 'custom', sceneCustomStyle: '90年代复古像素风', imageModel: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', }), ).toMatchObject({ id: 'generation-dialog-scene', mode: 'scene', prompt: '海边车站', sceneStylePreset: 'custom', sceneCustomStyle: '90年代复古像素风', imageModel: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', }); }); it('round-trips independent character animation AI and conversion drafts', () => { const dialog: CanvasGenerationDialogState = { id: 'dialog-character-animation-drafts', mode: 'character-animation', prompt: '保留 AI 提示词', status: 'idle', characterAnimationWorkflow: 'video-conversion', characterAnimationBackgroundColor: 'green', characterAnimationResolution: '480p', characterAnimationConversionResolution: '720p', generationReferences: [ { id: 'motion', label: '动作视频', src: 'users/user-1/motion.mp4', objectKey: 'users/user-1/motion.mp4', mediaType: 'video', resourceId: 'resource-motion', }, ], characterAnimationAppearanceReferences: [ { id: 'appearance', label: '角色外观', src: 'users/user-1/appearance.png', objectKey: 'users/user-1/appearance.png', resourceId: 'resource-appearance', }, ], characterAnimationConversionVideoReference: { id: 'conversion-source', label: '直接转换源视频', src: 'users/user-1/source.mov', objectKey: 'users/user-1/source.mov', mediaType: 'video', resourceId: 'resource-conversion-source', }, }; const layout = serializeCanvasLayout({ layers: [], canvasGenerationDialogs: [dialog], }); const { generationDialogs } = splitCanvasLayoutItems( layout, new Map([ [ 'resource-motion', { imageSrc: 'users/user-1/motion.mp4', objectKey: 'users/user-1/motion.mp4', }, ], [ 'resource-appearance', { imageSrc: 'users/user-1/appearance.png', objectKey: 'users/user-1/appearance.png', }, ], [ 'resource-conversion-source', { imageSrc: 'users/user-1/source.mov', objectKey: 'users/user-1/source.mov', }, ], ]), ); expect(generationDialogs[0]).toMatchObject({ prompt: '保留 AI 提示词', characterAnimationWorkflow: 'video-conversion', characterAnimationBackgroundColor: 'green', characterAnimationResolution: '480p', characterAnimationConversionResolution: '720p', generationReferences: [ expect.objectContaining({ objectKey: 'users/user-1/motion.mp4', mediaType: 'video', }), ], characterAnimationAppearanceReferences: [ expect.objectContaining({ objectKey: 'users/user-1/appearance.png', }), ], characterAnimationConversionVideoReference: expect.objectContaining({ objectKey: 'users/user-1/source.mov', mediaType: 'video', }), }); }); 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 legacy perfect-pixel snapshots with empty string metadata recoverable', () => { const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata'; const operation = buildPerfectPixelOperation(dialogId); operation.request.generationInputs = { fields: [{ title: '', value: '' }], references: [ { title: '', label: '', refType: 'asset', refId: '', }, ], }; const hydrated = hydrateCanvasGenerationDialog({ id: dialogId, mode: 'quick-edit', prompt: '完美像素', status: 'pending-confirmation', composerOpen: false, perfectPixelOperation: operation, }); expect(hydrated?.perfectPixelOperation).toEqual(operation); expect(hydrated).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('migrates restored legacy ui specs to the icon spec flow', () => { expect( hydrateCanvasGenerationDialog({ id: 'generation-dialog-legacy-ui-spec', mode: 'spec', prompt: '', status: 'idle', specType: 'ui', })?.specType, ).toBe('icon'); }); 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 => ({ 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, ): 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); }); }); });