import { describe, expect, it } from 'vitest'; import type { CanvasHistorySnapshot, CanvasLayer, } from './ImageCanvasEditorTypes'; import { canRestoreCanvasHistorySnapshotWithoutContentLoss, formatCanvasHistoryAction, isProtectedCanvasHistoryAction, mergeCanvasHistorySnapshotForRestore, } from './ImageCanvasHistoryModel'; function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', resourceId: 'resource-1', title: '图片', src: '/image-1.png', x: 0, y: 0, width: 100, height: 100, originalWidth: 100, originalHeight: 100, zIndex: 1, sourceType: 'uploaded', ...overrides, }; } function createSnapshot(layers: CanvasLayer[]): CanvasHistorySnapshot { return { layers, viewport: { x: 0, y: 0, scale: 1 }, generateDialog: null, inactiveGenerateDialogs: [], selectedLayerId: null, selectedLayerIds: [], }; } describe('ImageCanvasHistoryModel', () => { it('allows restoring position and restoring a previously deleted layer', () => { expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([createLayer({ x: 80 })]), target: createSnapshot([createLayer({ x: 10 })]), }), ).toBe(true); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([createLayer({ resourceId: 'resource-new' })]), target: createSnapshot([createLayer({ resourceId: 'resource-old' })]), }), ).toBe(true); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([]), target: createSnapshot([createLayer()]), }), ).toBe(true); }); it('blocks removing, hiding, or replacing current canvas content', () => { const current = createSnapshot([createLayer()]); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target: createSnapshot([]), }), ).toBe(false); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target: createSnapshot([createLayer({ hidden: true })]), }), ).toBe(false); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target: createSnapshot([ createLayer({ resourceId: 'resource-old', src: '/old.png' }), ]), }), ).toBe(false); }); it('ignores asynchronously filled relation ids for unchanged media content', () => { const targetLayer = createLayer({ resourceId: 'local-resource', sourceResourceId: undefined, sourceAssetId: undefined, objectKey: 'uploads/same.png', assetObjectId: 'asset-object-same', x: 10, }); const currentLayer = createLayer({ resourceId: 'persisted-resource', sourceResourceId: 'source-resource-late', sourceAssetId: 'source-asset-late', objectKey: 'uploads/same.png', assetObjectId: 'asset-object-same', x: 80, }); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([currentLayer]), target: createSnapshot([targetLayer]), }), ).toBe(true); }); it('restores only reversible layer fields while preserving current content metadata', () => { const targetLayer = createLayer({ resourceId: 'local-resource', title: '旧标题', src: '/signed/old.png', thumbnailSrc: '/signed/old-thumbnail.png', x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, zIndex: 1, prompt: '旧提示词', objectKey: 'uploads/stable.png', assetObjectId: 'asset-object-stable', sourceResourceId: undefined, sourceAssetId: undefined, groupId: 'target-group', assetKind: 'spec', hidden: false, locked: false, flipX: true, flipY: false, }); const currentLayer = createLayer({ resourceId: 'persisted-resource', title: '当前标题', src: '/signed/current.png', thumbnailSrc: '/signed/current-thumbnail.png', x: 90, y: 80, width: 640, height: 480, originalWidth: 640, originalHeight: 480, zIndex: 9, prompt: '当前提示词', objectKey: 'uploads/stable.png', assetObjectId: 'asset-object-stable', sourceResourceId: 'source-resource-late', sourceAssetId: 'source-asset-late', groupId: 'current-group', assetKind: 'ui-design', hidden: true, locked: true, flipX: false, flipY: true, }); const merged = mergeCanvasHistorySnapshotForRestore({ current: createSnapshot([currentLayer]), target: createSnapshot([targetLayer]), }); expect(merged.layers).toEqual([ { ...currentLayer, x: targetLayer.x, y: targetLayer.y, zIndex: targetLayer.zIndex, groupId: targetLayer.groupId, assetKind: targetLayer.assetKind, hidden: targetLayer.hidden, locked: targetLayer.locked, flipX: targetLayer.flipX, flipY: targetLayer.flipY, }, ]); }); it('preserves the current asset kind for layers outside the history action target', () => { const targetLayer = createLayer({ assetKind: 'icon-spritesheet', x: 10 }); const currentLayer = createLayer({ assetKind: 'character', x: 90 }); const merged = mergeCanvasHistorySnapshotForRestore({ current: createSnapshot([currentLayer]), target: createSnapshot([targetLayer]), assetKindLayerIds: new Set(), }); expect(merged.layers[0]).toMatchObject({ assetKind: 'character', x: 10, }); }); it('restores the complete target layer after that layer was deleted', () => { const targetLayer = createLayer({ resourceId: 'deleted-resource', title: '被删除图片', x: 10, y: 20, width: 320, height: 240, }); const merged = mergeCanvasHistorySnapshotForRestore({ current: createSnapshot([]), target: createSnapshot([targetLayer]), }); expect(merged.layers).toEqual([targetLayer]); }); it('uses stable media references while blocking actual media replacement', () => { expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([ createLayer({ src: '/signed/current.png', objectKey: 'uploads/stable.png', }), ]), target: createSnapshot([ createLayer({ src: '/signed/expired.png', objectKey: 'uploads/stable.png', }), ]), }), ).toBe(true); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([createLayer({ src: '/current.png' })]), target: createSnapshot([createLayer({ src: '/target.png' })]), }), ).toBe(false); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([ createLayer({ objectKey: 'uploads/current.png' }), ]), target: createSnapshot([ createLayer({ objectKey: 'uploads/target.png' }), ]), }), ).toBe(false); }); it('blocks replacing an image sequence frame', () => { const frame = { imageSrc: '/frames/0.png', objectKey: 'frames/0.png', width: 100, height: 100, }; expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: createSnapshot([ createLayer({ mediaType: 'image-sequence', imageSequenceFrames: [ { ...frame, objectKey: 'frames/replacement.png' }, ], }), ]), target: createSnapshot([ createLayer({ mediaType: 'image-sequence', imageSequenceFrames: [frame], }), ]), }), ).toBe(false); }); it('blocks removing a current generation result from the target snapshot', () => { const current = createSnapshot([]); current.inactiveGenerateDialogs = [ { id: 'generation-1', mode: 'generate', prompt: '生成结果', status: 'idle', }, ]; expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target: createSnapshot([]), }), ).toBe(false); const target = createSnapshot([]); target.generateDialog = { id: 'generation-1', mode: 'generate', prompt: '生成结果', status: 'idle', }; expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target }), ).toBe(true); }); it('protects an edit draft and preserves its latest unsaved state', () => { const current = createSnapshot([createLayer({ x: 80 })]); current.generateDialog = { id: 'edit-dialog:layer-1', mode: 'edit', prompt: '当前未保存提示词', status: 'idle', composerOpen: true, sourceLayerId: 'layer-1', imageModel: 'gpt-image-2', }; const targetWithoutDraft = createSnapshot([createLayer({ x: 10 })]); expect( canRestoreCanvasHistorySnapshotWithoutContentLoss({ current, target: targetWithoutDraft, }), ).toBe(false); const targetWithOlderDraft = createSnapshot([createLayer({ x: 10 })]); targetWithOlderDraft.generateDialog = { ...current.generateDialog, prompt: '旧提示词', }; const merged = mergeCanvasHistorySnapshotForRestore({ current, target: targetWithOlderDraft, }); expect(merged.generateDialog).toMatchObject({ id: 'edit-dialog:layer-1', prompt: '当前未保存提示词', composerOpen: true, }); }); it('restores only position for an existing generation dialog', () => { const target = createSnapshot([]); target.generateDialog = { id: 'generation-1', mode: 'character-animation', prompt: '旧提示词', status: 'idle', imageSize: '1K', generationReferences: [{ id: 'old', label: '旧参考图', src: '/old.png' }], placeholder: { x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }; const current = createSnapshot([]); current.generateDialog = { id: 'generation-1', mode: 'character-animation', prompt: '当前提示词', status: 'generating', imageSize: '2K', generatedLayerId: 'generated-layer-1', generationReferences: [ { id: 'current', label: '当前参考图', src: '/current.png' }, ], generationStartedAt: 100, placeholder: { x: 90, y: 80, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }; const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); expect(merged.generateDialog).toMatchObject({ id: 'generation-1', prompt: '当前提示词', status: 'generating', imageSize: '2K', generatedLayerId: 'generated-layer-1', generationStartedAt: 100, generationReferences: [ { id: 'current', label: '当前参考图', src: '/current.png' }, ], placeholder: { x: 10, y: 20, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }); }); it('uses the target placeholder when the current dialog has none', () => { const target = createSnapshot([]); target.generateDialog = { id: 'generation-1', mode: 'generate', prompt: '旧提示词', status: 'idle', placeholder: { x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }; const current = createSnapshot([]); current.generateDialog = { id: 'generation-1', mode: 'generate', prompt: '当前提示词', status: 'generating', imageSize: '2K', }; const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); expect(merged.generateDialog).toMatchObject({ prompt: '当前提示词', status: 'generating', imageSize: '2K', placeholder: target.generateDialog.placeholder, }); }); it('keeps the target active state while preserving current dialog progress', () => { const target = createSnapshot([]); target.generateDialog = { id: 'generation-a', mode: 'generate', prompt: 'A 旧提示词', status: 'idle', composerOpen: true, placeholder: { x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }; target.inactiveGenerateDialogs = [ { id: 'generation-b', mode: 'icon', prompt: 'B 旧提示词', status: 'idle', composerOpen: false, placeholder: { x: 30, y: 40, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, ]; const current = createSnapshot([]); current.generateDialog = { id: 'generation-b', mode: 'icon', prompt: 'B 当前提示词', status: 'generating', composerOpen: true, imageSize: '2K', placeholder: { x: 90, y: 80, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }; current.inactiveGenerateDialogs = [ { id: 'generation-a', mode: 'generate', prompt: 'A 当前提示词', status: 'generating', composerOpen: false, imageSize: '2K', placeholder: { x: 70, y: 60, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }, ]; const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); expect(merged.generateDialog).toMatchObject({ id: 'generation-a', prompt: 'A 当前提示词', status: 'generating', composerOpen: true, imageSize: '2K', placeholder: { x: 10, y: 20, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }); expect(merged.inactiveGenerateDialogs).toEqual([ expect.objectContaining({ id: 'generation-b', prompt: 'B 当前提示词', status: 'generating', composerOpen: false, imageSize: '2K', placeholder: { x: 30, y: 40, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }), ]); }); it('keeps the current placeholder when the target dialog has none', () => { const target = createSnapshot([]); target.generateDialog = { id: 'generation-1', mode: 'generate', prompt: '旧提示词', status: 'idle', composerOpen: true, }; const current = createSnapshot([]); current.generateDialog = { id: 'generation-1', mode: 'generate', prompt: '当前提示词', status: 'generating', composerOpen: false, imageSize: '2K', placeholder: { x: 90, y: 80, width: 640, height: 480, originalWidth: 640, originalHeight: 480, }, }; const merged = mergeCanvasHistorySnapshotForRestore({ current, target }); expect(merged.generateDialog).toMatchObject({ prompt: '当前提示词', status: 'generating', composerOpen: true, imageSize: '2K', placeholder: current.generateDialog.placeholder, }); }); it('restores the complete target dialog after that dialog was deleted', () => { const target = createSnapshot([]); target.generateDialog = { id: 'generation-deleted', mode: 'generate', prompt: '被删除的生成框', status: 'idle', generationReferences: [ { id: 'reference-1', label: '参考图', src: '/reference.png' }, ], placeholder: { x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }; const merged = mergeCanvasHistorySnapshotForRestore({ current: createSnapshot([]), target, }); expect(merged.generateDialog).toEqual(target.generateDialog); }); it('formats action names without duplicating the 操作 suffix', () => { expect(formatCanvasHistoryAction({ type: 'replace-image' })).toBe( '替换图片', ); expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe( true, ); expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( '完美像素', ); expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( true, ); expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false); }); });