301 lines
8.6 KiB
TypeScript
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);
|
|
});
|
|
});
|