139 lines
4.3 KiB
TypeScript
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 });
|
||
|
|
});
|
||
|
|
});
|