task(A2): tool dispatcher behind flag
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user