import { describe, expect, it } from 'vitest'; import type { CanvasGenerationDialogState, CanvasLayer, CharacterAnimationPanelState, CropExpandPanelState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { isCanvasGenerationComposerVisible, resolveCharacterAnimationPanelStyle, resolveCropExpandPanelStyle, resolveGenerationAnchor, resolveGenerationComposerStyle, resolveIconComposerStyle, resolveQuickEditFocusViewport, resolveQuickEditPanelStyle, resolveSelectedToolbarStyle, } 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 generation panels from their anchors without clamping to 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', }; const cropExpandPanel: CropExpandPanelState = { sourceLayerId: sourceLayer.id, frame: { x: sourceLayer.x, y: sourceLayer.y, width: sourceLayer.width, height: sourceLayer.height, }, ratio: 'free', status: 'idle', }; expect( resolveQuickEditPanelStyle({ panel: quickEditPanel, sourceLayer, viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 880, top: 662 }); expect( resolveCropExpandPanelStyle({ panel: cropExpandPanel, sourceLayer, viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 1012, top: 470 }); expect( resolveCharacterAnimationPanelStyle({ panel: characterPanel, sourceLayer, viewport: { x: 20, y: 10, scale: 1 }, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ left: 1012, top: 470 }); }); 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); }); });