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