fleeting-garden/src/game-loop/pointer-input.test.ts
2026-05-17 14:12:01 +01:00

301 lines
8.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
type PointerListener = (event: PointerEvent) => void;
const makePointerEvent = (
type: string,
event: Partial<PointerEvent> = {}
): PointerEvent =>
({
buttons: 1,
clientX: 10,
clientY: 20,
isTrusted: true,
pointerId: 1,
pointerType: 'mouse',
pressure: 0.5,
timeStamp: 100,
type,
...event,
}) as PointerEvent;
const toPoint = (point: ArrayLike<number>): Array<number> => Array.from(point);
class FakeCanvas {
public readonly capturedPointerIds: Array<number> = [];
public readonly releasedPointerIds: Array<number> = [];
public width = 300;
public height = 200;
private readonly listeners = new Map<string, Set<PointerListener>>();
public addEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type) ?? new Set<PointerListener>();
const pointerListener =
typeof listener === 'function'
? listener
: (event: Event) => listener.handleEvent(event);
listeners.add(pointerListener as PointerListener);
this.listeners.set(type, listeners);
}
public removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject
): void {
const listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(listener as PointerListener);
}
public dispatchPointerEvent(type: string, event: Partial<PointerEvent> = {}): void {
const pointerEvent = makePointerEvent(type, event);
this.listeners.get(type)?.forEach((listener) => listener(pointerEvent));
}
public getBoundingClientRect(): DOMRect {
return {
bottom: this.height,
height: this.height,
left: 0,
right: this.width,
toJSON: () => ({}),
top: 0,
width: this.width,
x: 0,
y: 0,
} as DOMRect;
}
public setPointerCapture(pointerId: number): void {
this.capturedPointerIds.push(pointerId);
}
public releasePointerCapture(pointerId: number): void {
this.releasedPointerIds.push(pointerId);
}
}
const makeSwipePipeline = () => ({
addSwipeSegment: vi.fn(),
clearSwipes: vi.fn(),
});
const createPointerInput = async () => {
const { GardenPointerInput } = await import('./pointer-input');
const { settings: runtimeSettings } = await import('../settings');
const canvas = new FakeCanvas();
const audio = {
beginGesture: vi.fn(),
endGesture: vi.fn(),
start: vi.fn(),
stroke: vi.fn(),
};
const brushPipeline = makeSwipePipeline();
const eraserAgentPipeline = makeSwipePipeline();
const eraserTexturePipeline = makeSwipePipeline();
const eraserPreview = {
isPointerInsideCanvas: vi.fn(() => true),
setEraseMode: vi.fn(),
setPointerHoveringCanvas: vi.fn(),
update: vi.fn(),
};
const onStartDrawing = vi.fn();
const onEraseGestureEnded = vi.fn();
const spawnStrokeAgents = vi.fn();
const input = new GardenPointerInput({
audio,
brushPipeline,
canvas: canvas as unknown as HTMLCanvasElement,
eraserAgentPipeline,
eraserPreview,
eraserTexturePipeline,
getDevicePixelRatio: () => 1,
getMirrorSegmentCount: () => 1,
onEraseGestureEnded,
onStartDrawing,
spawnStrokeAgents,
} as unknown as ConstructorParameters<typeof GardenPointerInput>[0]);
input.attach();
return {
audio,
brushPipeline,
canvas,
input,
onStartDrawing,
runtimeSettings,
spawnStrokeAgents,
};
};
describe('GardenPointerInput drawing startup', () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('localStorage', {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('allows pointer drawing immediately', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 7 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 7,
timeStamp: 120,
});
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(2);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(2);
expect(canvas.capturedPointerIds).toEqual([7]);
});
it('starts drawing from a fresh pointerdown', async () => {
const { audio, brushPipeline, canvas, onStartDrawing, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
expect(onStartDrawing).toHaveBeenCalledTimes(1);
expect(audio.start).toHaveBeenCalledWith(expect.anything(), { userGesture: true });
expect(audio.beginGesture).toHaveBeenCalledTimes(1);
expect(audio.stroke).not.toHaveBeenCalled();
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(1);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(1);
expect(canvas.capturedPointerIds).toEqual([9]);
});
it('flushes the delayed smoothed stroke tail on pointerup', async () => {
const { brushPipeline, canvas } = await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointerup', {
clientX: 60,
clientY: 80,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(3);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][0])).toEqual([10, 20]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[1][1])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][0])).toEqual([35, 50]);
expect(toPoint(brushPipeline.addSwipeSegment.mock.calls[2][1])).toEqual([60, 80]);
});
it('uses coalesced pointer samples for smoother brush segments', async () => {
const { audio, brushPipeline, canvas, spawnStrokeAgents } =
await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
audio.stroke.mockClear();
brushPipeline.addSwipeSegment.mockClear();
spawnStrokeAgents.mockClear();
canvas.dispatchPointerEvent('pointermove', {
clientX: 40,
clientY: 20,
getCoalescedEvents: () => [
makePointerEvent('pointermove', {
clientX: 20,
clientY: 20,
pointerId: 9,
timeStamp: 110,
}),
makePointerEvent('pointermove', {
clientX: 30,
clientY: 20,
pointerId: 9,
timeStamp: 115,
}),
makePointerEvent('pointermove', {
clientX: 40,
clientY: 20,
pointerId: 9,
timeStamp: 120,
}),
],
pointerId: 9,
timeStamp: 120,
});
expect(audio.stroke).toHaveBeenCalledTimes(3);
expect(spawnStrokeAgents).toHaveBeenCalledTimes(3);
expect(brushPipeline.addSwipeSegment.mock.calls.length).toBeGreaterThan(3);
});
it('passes normalized audio geometry context with stroke events', async () => {
const { audio, canvas } = await createPointerInput();
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9, timeStamp: 100 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 40,
clientY: 50,
pointerId: 9,
timeStamp: 150,
});
expect(audio.stroke).toHaveBeenCalledWith(
expect.objectContaining({
canvasSize: [300, 200],
elapsedSeconds: 0.05,
from: expect.anything(),
isErasing: false,
to: expect.anything(),
})
);
const stroke = audio.stroke.mock.calls[0][0];
expect(toPoint(stroke.from)).toEqual([10, 20]);
expect(toPoint(stroke.to)).toEqual([40, 50]);
});
it('caps curve tessellation with the brush curve resolution setting', async () => {
const { brushPipeline, canvas, runtimeSettings } = await createPointerInput();
runtimeSettings.brushCurveResolution = 2;
runtimeSettings.brushSize = 1;
canvas.dispatchPointerEvent('pointerdown', { pointerId: 9 });
canvas.dispatchPointerEvent('pointermove', {
clientX: 10,
clientY: 60,
pointerId: 9,
timeStamp: 120,
});
canvas.dispatchPointerEvent('pointermove', {
clientX: 60,
clientY: 60,
pointerId: 9,
timeStamp: 140,
});
expect(brushPipeline.addSwipeSegment).toHaveBeenCalledTimes(4);
});
});