import { describe, expect, it } from 'vitest'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; import { centerViewportOnPlacement, chooseGenerationPlacement, } from './ImageCanvasGenerationPlacementModel'; const canvasSize = { width: 900, height: 640 }; const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 }; const frame = { x: 0, y: 0, width: 420, height: 420, originalWidth: 2048, originalHeight: 2048, }; function layer(overrides: Partial): CanvasLayer { return { id: 'layer-1', resourceId: 'resource-1', title: '图片', src: '/image.png', x: 0, y: 0, width: 100, height: 100, originalWidth: 100, originalHeight: 100, zIndex: 1, sourceType: 'uploaded', ...overrides, }; } function generationDialog( overrides: Partial, ): CanvasGenerationDialogState { return { id: 'dialog-1', mode: 'generate', prompt: '', status: 'idle', placeholder: { x: 0, y: 0, width: 100, height: 100, originalWidth: 2048, originalHeight: 2048, }, ...overrides, }; } describe('ImageCanvasGenerationPlacementModel', () => { it('places an empty-canvas generation frame at the current viewport center', () => { const placement = chooseGenerationPlacement({ canvasSize, viewport, frame, layers: [], generationDialogs: [], }); expect(placement).toEqual({ x: 10, y: -60, width: 420, height: 420, originalWidth: 2048, originalHeight: 2048, }); }); it('keeps the nearest non-overlapping gap from visible layers and generation placeholders', () => { const placement = chooseGenerationPlacement({ canvasSize, viewport, frame: { ...frame, width: 100, height: 100 }, layers: [ layer({ id: 'center', x: 170, y: 110, width: 120, height: 120 }), layer({ id: 'right', x: 322, y: 110, width: 100, height: 120 }), layer({ id: 'hidden', hidden: true, x: 18, y: 118, width: 100, height: 100, }), ], generationDialogs: [ { id: 'dialog-left', mode: 'generate', prompt: '', status: 'idle', placeholder: { x: 18, y: 118, width: 100, height: 100, originalWidth: 2048, originalHeight: 2048, }, }, ], }); expect(placement.x).toBe(170); expect(placement.y).toBe(-22); }); it('does not treat hidden layers as generation placement blockers', () => { const placement = chooseGenerationPlacement({ canvasSize, viewport, frame: { ...frame, width: 100, height: 100 }, layers: [ layer({ id: 'hidden-center', hidden: true, x: 170, y: 110, width: 100, height: 100, }), ], generationDialogs: [], }); expect(placement.x).toBe(170); expect(placement.y).toBe(100); }); it('chooses the nearest empty slot when multiple blockers surround the viewport center', () => { const placement = chooseGenerationPlacement({ canvasSize: { width: 1000, height: 1000 }, viewport: { x: 500, y: 500, scale: 1 }, frame: { ...frame, width: 100, height: 100 }, layers: [ layer({ id: 'left-blocker', x: -148, y: -68, width: 96, height: 136, }), layer({ id: 'top-blocker', x: -68, y: -148, width: 136, height: 96, }), ], generationDialogs: [], }); expect(placement.x).toBe(-20); expect(placement.y).toBe(-20); }); it('avoids existing generation placeholders when choosing a new placement', () => { const placement = chooseGenerationPlacement({ canvasSize, viewport, frame: { ...frame, width: 100, height: 100 }, layers: [], generationDialogs: [ generationDialog({ id: 'existing-placeholder', placeholder: { x: 170, y: 110, width: 100, height: 100, originalWidth: 2048, originalHeight: 2048, }, }), ], }); expect(placement.x).toBe(170); expect(placement.y).toBe(-22); }); it('focuses the viewport on the chosen placement without changing zoom', () => { const zoomedViewport = { ...viewport, scale: 1.75 }; const nextViewport = centerViewportOnPlacement({ canvasSize, viewport: zoomedViewport, placement: { x: 170, y: 262, width: 100, height: 100, originalWidth: 2048, originalHeight: 2048, }, }); expect(nextViewport.scale).toBe(zoomedViewport.scale); expect(nextViewport).toEqual({ x: 65, y: -226, scale: 1.75 }); }); });