/* @vitest-environment jsdom */ import { act, fireEvent, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; import { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; function createLayer(overrides: Partial): CanvasLayer { const id = overrides.id ?? 'layer-a'; return { id, resourceId: `resource-${id}`, title: id, src: `data:image/png;base64,${id}`, x: 100, y: 120, width: 200, height: 100, originalWidth: 200, originalHeight: 100, zIndex: 1, sourceType: 'uploaded', ...overrides, }; } function setElementBox( element: HTMLElement, box: { width: number; height: number; left?: number; top?: number }, ) { Object.defineProperty(element, 'clientWidth', { configurable: true, value: box.width, }); Object.defineProperty(element, 'clientHeight', { configurable: true, value: box.height, }); element.getBoundingClientRect = () => ({ x: box.left ?? 0, y: box.top ?? 0, left: box.left ?? 0, top: box.top ?? 0, right: (box.left ?? 0) + box.width, bottom: (box.top ?? 0) + box.height, width: box.width, height: box.height, toJSON: () => ({}), }) as DOMRect; } function createViewportLayers() { return [ createLayer({ id: 'one', x: 0, y: 0, width: 400, height: 300 }), createLayer({ id: 'two', x: 600, y: 100, width: 200, height: 200 }), ]; } function renderViewportControls({ captureCanvasHistory = vi.fn(), layers = createViewportLayers(), }: { captureCanvasHistory?: () => void; layers?: CanvasLayer[]; } = {}) { const viewportElement = document.createElement('div'); setElementBox(viewportElement, { width: 900, height: 640 }); document.body.append(viewportElement); const minimapElement = document.createElement('div'); minimapElement.className = 'genarrative-image-canvas__minimap'; setElementBox(minimapElement, { left: 20, top: 30, width: 160, height: 120, }); document.body.append(minimapElement); const canvasViewportRef = { current: viewportElement }; const hook = renderHook(() => useImageCanvasViewportControls({ canvasViewportRef, layers, captureCanvasHistory, }), ); return { ...hook, viewportElement, captureCanvasHistory, }; } function createMinimapDragState(minimapScale: number) { return { kind: 'minimap' as const, pointerId: 1, startClientX: 100, startClientY: 100, startViewport: { x: -100, y: -50, scale: 1 }, minimapScale, moved: true, }; } function expectViewport(viewport: CanvasViewport, expected: CanvasViewport) { expect(viewport.x).toBeCloseTo(expected.x); expect(viewport.y).toBeCloseTo(expected.y); expect(viewport.scale).toBeCloseTo(expected.scale); } describe('useImageCanvasViewportControls', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); it('owns canvas size, minimap model and canvas point helpers', () => { const { result } = renderViewportControls(); expect(result.current.canvasSize).toEqual({ width: 900, height: 640 }); expect(result.current.minimapModel?.layers).toHaveLength(2); expect(result.current.getCanvasDropPoint(260, 190)).toEqual({ x: 260, y: 190, }); const worldPoint = result.current.getCanvasPointFromClient(260, 190); expect(worldPoint.x).toBeCloseTo(1040); expect(worldPoint.y).toBeCloseTo(240); }); it('fits layers and zooms from the viewport center', () => { const captureCanvasHistory = vi.fn(); const { result } = renderViewportControls({ captureCanvasHistory }); act(() => { result.current.fitLayers(); }); expectViewport(result.current.viewport, { x: 50, y: 170, scale: 1, }); expect(captureCanvasHistory).toHaveBeenCalledTimes(1); act(() => { result.current.updateScaleFromCenter(2); }); expectViewport(result.current.viewport, { x: -350, y: 20, scale: 2, }); expect(captureCanvasHistory).toHaveBeenCalledTimes(2); }); it('animates, retargets from the current frame and lets manual input cancel the transition', () => { const rafCallbacks = new Map(); let nextFrameId = 0; vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { nextFrameId += 1; rafCallbacks.set(nextFrameId, callback); return nextFrameId; }); const cancelAnimationFrameSpy = vi .spyOn(window, 'cancelAnimationFrame') .mockImplementation((frameId) => { rafCallbacks.delete(frameId); }); vi.stubGlobal( 'matchMedia', vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia, ); const { result, viewportElement, captureCanvasHistory } = renderViewportControls(); const targetViewport = { x: 120, y: -80, scale: 1.25 }; const runNextFrame = (timestamp: number) => { const entry = rafCallbacks.entries().next().value as | [number, FrameRequestCallback] | undefined; expect(entry).toBeTruthy(); if (!entry) { return; } rafCallbacks.delete(entry[0]); entry[1](timestamp); }; act(() => { result.current.animateViewportTo(targetViewport); }); expectViewport(result.current.viewport, { x: -260, y: 70, scale: 0.5, }); act(() => runNextFrame(0)); act(() => runNextFrame(210)); expect(result.current.viewport.x).toBeGreaterThan(-260); expect(result.current.viewport.x).toBeLessThan(120); expect(result.current.viewport.scale).toBeGreaterThan(0.5); expect(result.current.viewport.scale).toBeLessThan(1.25); const retargetedViewport = { x: 420, y: 260, scale: 1.6 }; act(() => { result.current.animateViewportTo(retargetedViewport); }); expect(cancelAnimationFrameSpy).toHaveBeenCalled(); act(() => runNextFrame(300)); act(() => runNextFrame(720)); expectViewport(result.current.viewport, retargetedViewport); expect(captureCanvasHistory).not.toHaveBeenCalled(); act(() => { result.current.animateViewportTo({ x: 600, y: 400, scale: 2 }); }); act(() => runNextFrame(800)); act(() => { fireEvent.wheel(viewportElement, { deltaY: 40 }); }); expect(cancelAnimationFrameSpy).toHaveBeenCalled(); expect(rafCallbacks.size).toBe(0); }); it('finishes viewport animation immediately when reduced motion is requested', () => { const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame'); vi.stubGlobal( 'matchMedia', vi.fn(() => ({ matches: true })) as unknown as typeof window.matchMedia, ); const { result } = renderViewportControls(); const targetViewport = { x: 20, y: 30, scale: 0.8 }; act(() => { result.current.animateViewportTo(targetViewport); }); expectViewport(result.current.viewport, targetViewport); expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); }); it('keeps ordinary mouse wheel vertical and maps Shift wheel to horizontal', () => { const { result, viewportElement } = renderViewportControls(); act(() => { fireEvent.wheel(viewportElement, { deltaX: 0, deltaY: 120, clientX: 260, clientY: 190, }); }); expectViewport(result.current.viewport, { x: -260, y: -50, scale: 0.5, }); act(() => { fireEvent.wheel(viewportElement, { shiftKey: true, deltaX: 0, deltaY: 80, clientX: 260, clientY: 190, }); }); expectViewport(result.current.viewport, { x: -340, y: -50, scale: 0.5, }); }); it('does not pan the canvas when a generation prompt receives wheel input', () => { const { result, viewportElement } = renderViewportControls(); const prompt = document.createElement('textarea'); viewportElement.append(prompt); const initialViewport = result.current.viewport; const wheelEvent = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, clientX: 260, clientY: 190, }); act(() => { prompt.dispatchEvent(wheelEvent); }); expect(result.current.viewport).toEqual(initialViewport); expect(wheelEvent.defaultPrevented).toBe(false); }); it('handles native two-axis wheel pan, ctrl wheel zoom and minimap movement', () => { const rafCallbacks: FrameRequestCallback[] = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation( (callback: FrameRequestCallback) => { rafCallbacks.push(callback); return rafCallbacks.length; }, ); vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); const { result, viewportElement } = renderViewportControls(); act(() => { fireEvent.wheel(viewportElement, { deltaX: 40, deltaY: 120, clientX: 260, clientY: 190, }); }); expectViewport(result.current.viewport, { x: -300, y: -50, scale: 0.5, }); act(() => { fireEvent.wheel(viewportElement, { ctrlKey: true, deltaY: -120, clientX: 260, clientY: 190, }); }); expectViewport(result.current.viewport, { x: -356, y: -74, scale: 0.55, }); const beforeDrag = result.current.viewport; act(() => { result.current.updateViewportFromMinimapDrag( createMinimapDragState(result.current.minimapModel?.scale ?? 1), 104, 103, ); }); act(() => { rafCallbacks.shift()?.(performance.now()); }); expect(result.current.viewport).not.toEqual(beforeDrag); const beforeClick = result.current.viewport; act(() => { result.current.moveViewportFromMinimapPointer(100, 90); }); expect(result.current.viewport).not.toEqual(beforeClick); }); it('coalesces minimap drag viewport updates into one animation frame', () => { const rafCallbacks: FrameRequestCallback[] = []; const requestAnimationFrameSpy = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((callback: FrameRequestCallback) => { rafCallbacks.push(callback); return rafCallbacks.length; }); vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); const { result } = renderViewportControls(); const initialViewport = result.current.viewport; const dragState = createMinimapDragState( result.current.minimapModel?.scale ?? 1, ); act(() => { result.current.updateViewportFromMinimapDrag(dragState, 104, 103); result.current.updateViewportFromMinimapDrag(dragState, 112, 109); }); expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(result.current.viewport).toEqual(initialViewport); act(() => { rafCallbacks.shift()?.(performance.now()); }); const coalescedViewport = result.current.viewport; expect(coalescedViewport).not.toEqual(initialViewport); act(() => { result.current.updateViewportFromMinimapDrag(dragState, 104, 103); }); act(() => { rafCallbacks.shift()?.(performance.now()); }); expect(result.current.viewport).not.toEqual(coalescedViewport); }); it('flushes a pending minimap drag before the next animation frame', () => { const rafCallbacks: FrameRequestCallback[] = []; const cancelAnimationFrameSpy = vi .spyOn(window, 'cancelAnimationFrame') .mockImplementation(() => {}); vi.spyOn(window, 'requestAnimationFrame').mockImplementation( (callback: FrameRequestCallback) => { rafCallbacks.push(callback); return rafCallbacks.length; }, ); const { result } = renderViewportControls(); const initialViewport = result.current.viewport; const dragState = createMinimapDragState( result.current.minimapModel?.scale ?? 1, ); act(() => { result.current.updateViewportFromMinimapDrag(dragState, 104, 103); result.current.updateViewportFromMinimapDrag(dragState, 112, 109); }); act(() => { result.current.flushMinimapViewportDrag(); }); expect(cancelAnimationFrameSpy).toHaveBeenCalledTimes(1); const flushedViewport = result.current.viewport; expect(flushedViewport).not.toEqual(initialViewport); act(() => { rafCallbacks.shift()?.(performance.now()); }); expect(result.current.viewport).toEqual(flushedViewport); }); });