import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; type PointerListener = (event: PointerEvent) => void; const makePointerEvent = ( type: string, event: Partial = {} ): 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): Array => Array.from(point); class FakeCanvas { public readonly capturedPointerIds: Array = []; public readonly releasedPointerIds: Array = []; public width = 300; public height = 200; private readonly listeners = new Map>(); public addEventListener( type: string, listener: EventListenerOrEventListenerObject ): void { const listeners = this.listeners.get(type) ?? new Set(); 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 = {}): 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[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); }); });