Files
2026-08-26 21:14:46 +02:00

139 lines
4.3 KiB
TypeScript

/**
* 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<PointerEventInit> = {}): 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 });
});
});