import { describe, expect, it } from 'vitest'; import { CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveSnappedLayerPosition, serializeCanvasLayout, serializeLayer, splitCanvasLayoutItems, viewportScaleToCanvasDisplayScale, viewportToCanvasDisplayViewport, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, EditorAsset, } from './ImageCanvasEditorTypes'; describe('ImageCanvasEditorModel', () => { it('maps viewport scale to the user-facing canvas zoom scale', () => { expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25); expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5); expect(canvasDisplayScaleToViewportScale(2)).toBe(1); expect(canvasDisplayScaleToViewportScale(0.05)).toBe(0.025); expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5); expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1); expect(viewportScaleToCanvasDisplayScale(1)).toBe(2); expect(formatCanvasDisplayScalePercent(0.025)).toBe('5%'); expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%'); expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%'); expect( viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }), ).toEqual({ x: 10, y: 20, scale: 0.5 }); expect( canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }), ).toEqual({ x: 10, y: 20, scale: 1 }); }); it('normalizes valid canvas background hex values and rejects invalid input', () => { expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc'); expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc'); expect(normalizeCanvasBackgroundHex('white')).toBeNull(); expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull(); }); it('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('infers MP3 and MP4 asset media types from persisted paths', () => { const library = normalizeAssetLibrary({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ { assetId: 'asset-audio', folderId: 'project', label: '胜利音效.mp3', imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', width: 420, height: 120, sourceType: 'uploaded', objectKey: 'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', }, { assetId: 'asset-video', folderId: 'project', label: '开场动画.mp4', imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', thumbnailSrc: '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', width: 560, height: 315, sourceType: 'uploaded', objectKey: 'generated-character-drafts/editor/asset-library/video/开场动画.mp4', }, ], }); expect(library.assets[0]).toMatchObject({ mediaType: 'audio' }); expect(library.assets[1]).toMatchObject({ mediaType: 'video', 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: 'sound-effect', }); }); 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', sourceResourceId: 'resource-generated', }, ], }); expect(library.assets[0]).toMatchObject({ id: 'asset-generated', sourceResourceId: 'resource-generated', }); }); it('serializes and hydrates canvas layer metadata without embedding image payloads', () => { const layer: CanvasLayer = { id: 'layer-generated', resourceId: 'resource-generated', title: '生成图', src: 'data:image/png;base64,heavy', x: 10, y: 20, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, zIndex: 9, sourceType: 'generated', objectKey: 'generated/object.png', model: 'birefnet', sourceResourceId: 'resource-provider-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '骑士' }], references: [], }, locked: true, }; const snapshot = serializeLayer(layer); expect(snapshot).not.toHaveProperty('src'); expect(snapshot).not.toHaveProperty('assetKind'); expect(snapshot).not.toHaveProperty('generationInputs'); const hydrated = hydrateLayer( snapshot, new Map([ [ 'resource-generated', { imageSrc: '/read/generated.png', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '骑士' }], references: [], }, 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', assetKind: 'character', objectKey: 'generated/object.png', model: 'gpt-image-2', locked: true, }); expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); 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 manually selected publication material tags from saved layout', () => { const hydrated = hydrateLayer( { layerId: 'layer-publication', resourceId: 'resource-publication', title: '宣发图', x: 10, y: 20, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 1024, zIndex: 5, sourceType: 'generated', assetKind: 'publication-material', }, new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]), ); expect(hydrated).toMatchObject({ id: 'layer-publication', assetKind: 'publication-material', }); }); it('hydrates audio media type and sound effect asset kind from saved layout', () => { const hydrated = hydrateLayer( { layerId: 'layer-audio', resourceId: 'resource-audio', title: '游戏音效', x: 10, y: 20, width: 420, height: 120, originalWidth: 420, originalHeight: 120, zIndex: 5, sourceType: 'generated', mediaType: 'audio', assetKind: 'sound-effect', durationSeconds: 2.4, }, new Map([ [ 'resource-audio', { imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', durationSeconds: 2.4, }, ], ]), ); expect(hydrated).toMatchObject({ id: 'layer-audio', src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', assetKind: 'sound-effect', durationSeconds: 2.4, }); }); it('serializes and hydrates image sequence frames for character animation layers', () => { const layer: CanvasLayer = { id: 'layer-action', resourceId: 'resource-action', title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', assetObjectId: 'assetobj-frame-01', width: 1024, height: 1024, }, { frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', assetObjectId: 'assetobj-frame-02', width: 1024, height: 1024, }, ], previewVideoPath: '/generated-character-drafts/editor/preview.mp4', x: 10, y: 20, width: 420, height: 420, originalWidth: 1024, originalHeight: 1024, zIndex: 6, sourceType: 'generated', assetKind: 'character-animation', durationSeconds: 4, }; const snapshot = serializeLayer(layer); expect(snapshot.thumbnailSrc).toBe( '/generated-character-drafts/editor/frame01.png', ); expect(snapshot.mediaType).toBe('image-sequence'); expect(snapshot.imageSequenceFrames).toHaveLength(2); const hydrated = hydrateLayer( snapshot, new Map([ [ 'resource-action', { imageSrc: layer.src, assetKind: 'character-animation', }, ], ]), ); expect(hydrated).toMatchObject({ mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: layer.imageSequenceFrames, previewVideoPath: '/generated-character-drafts/editor/preview.mp4', assetKind: 'character-animation', durationSeconds: 4, }); }); it('restores audio generation dialogs from saved layout', () => { const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-audio', mode: 'audio-sound-effect', prompt: '金币掉落叮当声', status: 'idle', composerOpen: true, soundDurationSeconds: 8, generatedLayerId: 'layer-audio', placeholder: { x: 100, y: 120, width: 420, height: 120, originalWidth: 420, originalHeight: 120, }, }; const { generationDialogs } = splitCanvasLayoutItems( serializeCanvasLayout({ layers: [], canvasGenerationDialogs: [dialog], }), ); expect(generationDialogs).toHaveLength(1); expect(generationDialogs[0]).toMatchObject({ id: 'generation-dialog-audio', mode: 'audio-sound-effect', prompt: '金币掉落叮当声', soundDurationSeconds: 8, generatedLayerId: 'layer-audio', }); }); it('restores publication material generator inputs from saved layout', () => { const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-publication', mode: 'publication', prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', status: 'idle', composerOpen: true, publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '马戏团午夜惊魂', gameCategories: '非对称对抗', gameDescription: '找到钥匙,开门逃离马戏团', }, publicationReferences: [ { id: 'publication-reference-1', label: '首图参考', src: 'data:image/png;base64,publication-ref', resourceId: 'resource-publication-reference', }, ], imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', generatedLayerId: 'layer-publication', placeholder: { x: 100, y: 120, width: 1280, height: 720, originalWidth: 1280, originalHeight: 720, }, }; const layout = serializeCanvasLayout({ layers: [], canvasGenerationDialogs: [dialog], }); expect(JSON.stringify(layout)).not.toContain('data:image'); expect(JSON.stringify(layout)).toContain( 'ref:project-resource:resource-publication-reference', ); const { generationDialogs } = splitCanvasLayoutItems( layout, new Map([ [ 'resource-publication-reference', { imageSrc: '/read/publication-reference.png', objectKey: 'generated-character-drafts/editor/reference.png', assetObjectId: 'asset-object-publication-reference', }, ], ]), ); expect(generationDialogs).toHaveLength(1); expect(generationDialogs[0]).toMatchObject({ id: 'generation-dialog-publication', mode: 'publication', publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '马戏团午夜惊魂', gameCategories: '非对称对抗', gameDescription: '找到钥匙,开门逃离马戏团', }, imageModel: 'gpt-image-2', generatedLayerId: 'layer-publication', }); expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({ id: 'publication-reference-1', label: '首图参考', src: '/read/publication-reference.png', objectKey: 'generated-character-drafts/editor/reference.png', assetObjectId: 'asset-object-publication-reference', resourceId: 'resource-publication-reference', }); }); it('serializes generation dialogs beside layers and splits them on load', () => { const layer: CanvasLayer = { id: 'layer-generated', resourceId: 'resource-generated', title: '生成图', src: 'data:image/png;base64,heavy', x: 10, y: 20, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, zIndex: 9, sourceType: 'generated', }; const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-9', mode: 'generate', prompt: '刷新后要继续保留', status: 'generating', composerOpen: false, generatedLayerId: 'layer-generated', imageModel: 'gpt-image-2', 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('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, }); }); });