import { describe, expect, it } from 'vitest'; import type { CanvasGenerationDialogState, CanvasLayer, CharacterAnimationPanelState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { isCanvasGenerationComposerVisible, resolveCharacterAnimationPanelStyle, resolveGenerationAnchor, resolveGenerationComposerStyle, resolveIconComposerStyle, resolveQuickEditFocusViewport, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, resolveViewportForOverlayVisibility, } from './ImageCanvasOverlayModel'; function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-a', resourceId: 'resource-a', title: '图层A', src: 'data:image/png;base64,layer', x: 100, y: 80, width: 240, height: 120, originalWidth: 240, originalHeight: 120, zIndex: 1, sourceType: 'uploaded', ...overrides, }; } function createDialog( overrides: Partial = {}, ): CanvasGenerationDialogState { return { id: 'dialog-a', mode: 'generate', prompt: '', status: 'idle', placeholder: { x: 300, y: 200, width: 360, height: 260, originalWidth: 1024, originalHeight: 1024, }, ...overrides, }; } describe('ImageCanvasOverlayModel', () => { it('anchors generation composer to generated layers before placeholders', () => { const generatedLayer = createLayer({ x: 40, y: 60, width: 320, height: 180 }); const dialog = createDialog({ generatedLayerId: generatedLayer.id }); const anchor = resolveGenerationAnchor({ dialog, generatedLayer }); expect(anchor).toBe(generatedLayer); expect( resolveGenerationComposerStyle({ dialog, anchor, viewport: { x: 10, y: 20, scale: 2 }, }), ).toEqual({ left: 410, top: 510, }); }); it('hides generation composer while generating or explicitly closed', () => { const placeholder = createDialog().placeholder ?? null; expect( resolveGenerationComposerStyle({ dialog: createDialog({ status: 'generating' }), anchor: placeholder, viewport: { x: 0, y: 0, scale: 1 }, }), ).toBeNull(); expect( resolveGenerationComposerStyle({ dialog: createDialog({ composerOpen: false }), anchor: placeholder, viewport: { x: 0, y: 0, scale: 1 }, }), ).toBeNull(); }); it('keeps icon composer aligned to the base generation composer width', () => { expect( resolveIconComposerStyle({ dialog: createDialog({ mode: 'icon' }), composerStyle: { left: 100, top: 200 }, }), ).toEqual({ left: 100, top: 200, }); expect( resolveIconComposerStyle({ dialog: createDialog({ mode: 'generate' }), composerStyle: { left: 100, top: 200 }, }), ).toBeNull(); }); it('lets selected layer toolbar move past the horizontal canvas edges', () => { expect( resolveSelectedToolbarStyle({ selectedLayer: createLayer({ x: -500, y: -100 }), viewport: { x: 0, y: 0, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: -380, top: 10 }); expect( resolveSelectedToolbarStyle({ selectedLayer: createLayer({ x: 1200, y: 100 }), viewport: { x: 0, y: 0, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 1320, top: 88 }); }); it('fits tall quick edit sources above the reserved bottom composer area', () => { const sourceLayer = createLayer({ x: 100, y: 50, width: 360, height: 1600 }); const viewport = resolveQuickEditFocusViewport({ sourceLayer, canvasSize: { width: 900, height: 640 }, }); const sourceTop = viewport.y + sourceLayer.y * viewport.scale; const sourceBottom = sourceTop + sourceLayer.height * viewport.scale; expect(viewport.scale).toBeCloseTo(0.1925, 4); expect(sourceTop).toBeCloseTo(56, 2); expect(sourceBottom).toBeLessThanOrEqual(364); expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640); }); it('positions quick edit and character animation panels with viewport bounds', () => { const sourceLayer = createLayer({ x: 740, y: 460, width: 240, height: 180 }); const quickEditPanel: QuickEditPanelState = { sourceLayerId: sourceLayer.id, prompt: '', size: '1024x1024', model: 'gpt-image-2', status: 'idle', }; const characterPanel: CharacterAnimationPanelState = { sourceLayerId: sourceLayer.id, promptText: '', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, status: 'idle', }; expect( resolveQuickEditPanelStyle({ panel: quickEditPanel, sourceLayer, viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 880, top: 376 }); expect( resolveCharacterAnimationPanelStyle({ panel: characterPanel, sourceLayer, viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 536, top: 120 }); }); it('pans the viewport until an opened composer is inside the visible safe area', () => { expect( resolveViewportForOverlayVisibility({ viewport: { x: 10, y: 20, scale: 1 }, overlayBounds: { left: 210, top: 430, right: 690, bottom: 650, }, safeBounds: { left: 12, top: 12, right: 888, bottom: 560, }, }), ).toEqual({ x: 10, y: -70, scale: 1 }); }); it('pans sideways when a centered composer would leave the viewport', () => { expect( resolveViewportForOverlayVisibility({ viewport: { x: 0, y: 0, scale: 1 }, overlayBounds: { left: -80, top: 120, right: 420, bottom: 320, }, safeBounds: { left: 12, top: 12, right: 888, bottom: 560, }, }), ).toEqual({ x: 92, y: 0, scale: 1 }); }); it('centers an oversized composer within the available safe area', () => { expect( resolveViewportForOverlayVisibility({ viewport: { x: 0, y: 0, scale: 1 }, overlayBounds: { left: 20, top: 20, right: 700, bottom: 620, }, safeBounds: { left: 12, top: 12, right: 612, bottom: 512, }, }), ).toEqual({ x: -48, y: -58, scale: 1 }); }); it('recognizes canvas generation composer dialog modes', () => { expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'generate' }))).toBe( true, ); expect(isCanvasGenerationComposerVisible(createDialog({ mode: 'spec' }))).toBe( true, ); expect( isCanvasGenerationComposerVisible(createDialog({ mode: 'publication' })), ).toBe(true); expect( isCanvasGenerationComposerVisible({ mode: 'edit', prompt: '', status: 'idle', }), ).toBe(false); expect(isCanvasGenerationComposerVisible(null)).toBe(false); }); });