/** * InteractionDispatcher Tests (Task A2). * Prüft Event-Routing gegen ein Fake-Werkzeug ohne echte Registry: * Reihenfolge down→move→up, ESC/Rechtsklick cancel, move nur bei aktiver Session, * unbekanntes Tool → kein Routing. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { InteractionDispatcher } from '../src/interaction/dispatcher'; import type { ToolExtensionV2 } from '../src/plugins/types'; import type { ZoomPanController } from '../src/canvas/ZoomPanController'; function makeFakeZoomPan(): ZoomPanController { return { screenToWorld: (sx: number, sy: number) => ({ x: sx * 2, y: sy * 3 }), } as unknown as ZoomPanController; } function makeRecordingTool(id = 'fake'): { tool: ToolExtensionV2; log: string[] } { const log: string[] = []; const tool: ToolExtensionV2 = { manifest: { id, label: 'Fake', icon: 'f', ribbonTab: 'tools' }, optionsSchema: [], handlers: { down: () => log.push('down'), move: () => log.push('move'), up: () => log.push('up'), key: () => false, cancel: () => log.push('cancel'), }, }; return { tool, log }; } function fire(canvas: HTMLCanvasElement, type: string, opts: Partial = {}): PointerEvent { const e = new PointerEvent(type, { bubbles: true, clientX: 100, clientY: 50, button: 0, ...opts, }); if (type === 'pointerdown') canvas.dispatchEvent(e); else window.dispatchEvent(e); return e; } function fireKey(key: string): KeyboardEvent { const e = new KeyboardEvent('keydown', { key, bubbles: true }); window.dispatchEvent(e); return e; } describe('InteractionDispatcher', () => { let canvas: HTMLCanvasElement; afterEach(() => { canvas?.remove(); }); function setup(tool?: ToolExtensionV2) { canvas = document.createElement('canvas'); document.body.appendChild(canvas); const dispatcher = new InteractionDispatcher(canvas, { zoomPan: makeFakeZoomPan(), toolLookup: tool ? () => tool : undefined, }); return { dispatcher }; } it('routet down→move→up in Reihenfolge an das aktive Tool', () => { const { tool, log } = makeRecordingTool(); const { dispatcher } = setup(tool); dispatcher.setActive('fake'); fire(canvas, 'pointerdown'); fire(window, 'pointermove'); fire(window, 'pointerup'); expect(log).toEqual(['down', 'move', 'up']); expect(dispatcher.getActive()?.manifest.id).toBe('fake'); }); it('ignoriert move/up ohne vorheriges down (Session-Guard)', () => { const { tool, log } = makeRecordingTool(); const { dispatcher } = setup(tool); dispatcher.setActive('fake'); fire(window, 'pointermove'); // keine Session aktiv fire(window, 'pointerup'); expect(log).toEqual([]); }); it('ESC ruft cancel und beendet die Session', () => { const { tool, log } = makeRecordingTool(); const { dispatcher } = setup(tool); dispatcher.setActive('fake'); fire(canvas, 'pointerdown'); fireKey('Escape'); expect(log).toEqual(['down', 'cancel']); fire(window, 'pointermove'); // Session beendet → kein move mehr expect(log).toEqual(['down', 'cancel']); }); it('Rechtsklick bricht ab, ohne down durchzureichen', () => { const { tool, log } = makeRecordingTool(); const { dispatcher } = setup(tool); dispatcher.setActive('fake'); fire(canvas, 'pointerdown', { button: 2 }); expect(log).toEqual(['cancel']); }); it('setActive(null) deaktiviert Routing komplett', () => { const { tool, log } = makeRecordingTool(); const { dispatcher } = setup(tool); dispatcher.setActive('fake'); dispatcher.setActive(null); fire(canvas, 'pointerdown'); fire(window, 'pointermove'); expect(log).toEqual([]); }); it('liefert Weltkoordinaten aus zoomPan.screenToWorld', () => { let received: { x: number; y: number } | null = null; const tool: ToolExtensionV2 = { manifest: { id: 'geo', label: 'G', icon: 'g', ribbonTab: 't' }, optionsSchema: [], handlers: { down: (e) => (received = { ...e.world }) }, }; const { dispatcher } = setup(tool); dispatcher.setActive('geo'); fire(canvas, 'pointerdown', { clientX: 100, clientY: 50 }); // fake screenToWorld: x*2, y*3 → (200, 150) expect(received).toEqual({ x: 200, y: 150 }); }); });