/** * CAD-14 Phase 4c: Dispatcher-native Pan/Wheel/Cursor/Snap. * Ersetzt die Legacy-InteractionEngine vollstaendig: * - Snap/Ortho werden auf Tool-Pointer-Events angewandt (falls Deps gesetzt) * - Pan bei Mittel-/Rechtsklick-Drag (auch ohne aktives Tool) * - Wheel-Zoom am Cursor * - Cursor-Meldung bei jedem Move (auch ohne Tool) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InteractionDispatcher } from '../src/interaction/dispatcher'; import type { ToolExtensionV2 } from '../src/plugins/types'; function makeCanvas(): HTMLCanvasElement { const c = document.createElement('canvas'); c.getBoundingClientRect = () => ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}) } as DOMRect); return c; } const zoomPan = { screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }), pan: vi.fn(), zoomAt: vi.fn(), }; function lineTool(): ToolExtensionV2 { return { manifest: { id: 'line', label: 'Linie', icon: 'L', ribbonTab: 'canvas', tags: ['basic'] }, optionsSchema: [], handlers: { down: vi.fn(), move: vi.fn(), up: vi.fn(), }, }; } function fireMouse(canvas: HTMLCanvasElement, type: string, opts: { button?: number; x?: number; y?: number } = {}) { const e = new MouseEvent(type, { button: opts.button ?? 0, clientX: opts.x ?? 0, clientY: opts.y ?? 0, bubbles: true }); canvas.dispatchEvent(e); } describe('CAD-14 Phase 4c: dispatcher native pan/wheel/cursor', () => { let canvas: HTMLCanvasElement; let onCursor: ReturnType; beforeEach(() => { canvas = makeCanvas(); onCursor = vi.fn(); }); it('Pan: Mittelklick-Drag verschiebt via zoomPan.pan (auch ohne Tool)', () => { const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor }); canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 1, clientX: 100, clientY: 100 })); window.dispatchEvent(new MouseEvent('pointermove', { clientX: 140, clientY: 120 })); window.dispatchEvent(new MouseEvent('pointerup', { button: 1, clientX: 140, clientY: 120 })); expect(zoomPan.pan).toHaveBeenCalled(); d.destroy(); }); it('Wheel: zoomAt am Cursor mit Faktor', () => { const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never }); canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, clientX: 300, clientY: 200, cancelable: true })); expect(zoomPan.zoomAt).toHaveBeenCalled(); d.destroy(); }); it('Cursor-Meldung: onCursorMoved feuert bei pointermove ohne aktives Tool', () => { const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, onCursorMoved: onCursor }); window.dispatchEvent(new MouseEvent('pointermove', { clientX: 50, clientY: 60 })); expect(onCursor).toHaveBeenCalledWith(50, 60); d.destroy(); }); it('Snap: down/up Positionen werden gesnappt (snap-Deps)', () => { const snap = vi.fn((x: number, y: number) => ({ x: Math.round(x / 10) * 10, y: Math.round(y / 10) * 10 })); const tool = lineTool(); const d = new InteractionDispatcher(canvas, { zoomPan: zoomPan as never, snap, toolLookup: () => tool } as never); d.setActive('line'); expect(d.getActive()).toBe(tool); canvas.dispatchEvent(new MouseEvent('pointerdown', { button: 0, clientX: 12, clientY: 12 })); window.dispatchEvent(new MouseEvent('pointermove', { clientX: 35, clientY: 35 })); window.dispatchEvent(new MouseEvent('pointerup', { button: 0, clientX: 35, clientY: 35 })); expect(tool.handlers.down).toHaveBeenCalled(); expect((tool.handlers.down as any).mock.calls[0][0].world).toEqual({ x: 10, y: 10 }); expect((tool.handlers.up as any).mock.calls[0][0].world).toEqual({ x: 40, y: 40 }); d.destroy(); }); });