Skip to content

Commit 0b08685

Browse files
marklundinCopilot
andauthored
Pointer event tests (#207)
* Add tests for usePicker hook functionality - Introduced a new test file for the usePicker hook, validating its behavior with pointer events, canvas resizing, and entity registration. - Ensured that the picker does not prepare or select when no pointer events are present, and correctly responds to pointer interactions when events are registered. - Added checks for proper unsubscription of update handlers on component unmount and verified that the picker resizes appropriately on canvas size changes. * Implement changeset to ensure Picker is not running unnecessarily in the @playcanvas/react package. * Update packages/lib/src/utils/picker.tsx Co-authored-by: Copilot <[email protected]> * linting fixes * removing pointer event --------- Co-authored-by: Copilot <[email protected]>
1 parent 1e8d7e6 commit 0b08685

File tree

3 files changed

+240
-3
lines changed

3 files changed

+240
-3
lines changed

.changeset/tough-teams-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@playcanvas/react": patch
3+
---
4+
5+
Ensure Picker is not running unnecessarily
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import { vi, describe, it, expect } from 'vitest';
4+
import { usePicker } from './picker.tsx';
5+
import * as pc from 'playcanvas';
6+
import { Entity } from '../Entity.tsx';
7+
import { AppContext, ParentContext } from '../hooks/index.ts';
8+
import { PointerEventsContext } from '../contexts/pointer-events-context.tsx';
9+
10+
/**
11+
* Creates a test canvas with a mock bounding client rect
12+
*/
13+
const createTestCanvas = () => {
14+
const canvas = document.createElement('canvas');
15+
canvas.width = 800;
16+
canvas.height = 600;
17+
canvas.getBoundingClientRect = () => ({
18+
left: 0,
19+
top: 0,
20+
width: 800,
21+
height: 600,
22+
right: 800,
23+
bottom: 600,
24+
x: 0,
25+
y: 0,
26+
toJSON: () => ({}),
27+
});
28+
return canvas;
29+
};
30+
31+
/**
32+
* Creates a mock app with an update handler that collects update callbacks
33+
*/
34+
const createAppWithUpdateCollector = (canvas: HTMLCanvasElement) => {
35+
const updateHandlers: Array<() => void> = [];
36+
const app = ({
37+
on: (evt: string, cb: () => void) => { if (evt === 'update') updateHandlers.push(cb); },
38+
off: vi.fn(),
39+
scene: {},
40+
root: { findComponents: vi.fn().mockReturnValue([{ priority: 1, renderTarget: null }]) },
41+
graphicsDevice: { canvas, width: canvas.width, height: canvas.height, maxPixelRatio: 1 },
42+
} as unknown) as pc.AppBase;
43+
return { app, updateHandlers };
44+
};
45+
46+
/**
47+
* Creates a mock Picker with spies for its methods
48+
* @returns An object containing the spies for the Picker methods
49+
*/
50+
const mockPicker = () => {
51+
const prepareSpy = vi.fn();
52+
const getSelectionSpy = vi.fn().mockResolvedValue([null]);
53+
const resizeSpy = vi.fn();
54+
const pickerCtorSpy = vi
55+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
56+
.spyOn(pc as any, 'Picker')
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
.mockImplementation((): any => ({
59+
prepare: prepareSpy,
60+
getSelectionAsync: getSelectionSpy,
61+
resize: resizeSpy,
62+
}));
63+
return { prepareSpy, getSelectionSpy, resizeSpy, pickerCtorSpy };
64+
};
65+
66+
/**
67+
* Renders a harness that attaches the canvas to the DOM and uses the usePicker hook
68+
*/
69+
const renderHarness = (app: pc.AppBase, canvas: HTMLCanvasElement, pointerEvents: Set<string>) => {
70+
const Harness = () => {
71+
const elRef = useRef<HTMLDivElement | null>(null);
72+
useEffect(() => { elRef.current?.appendChild(canvas); }, []);
73+
usePicker(app, canvas, pointerEvents);
74+
return <div ref={elRef} />;
75+
};
76+
return render(<Harness />);
77+
};
78+
79+
describe('usePicker', () => {
80+
it('does not prepare or select when no pointer events are added', async () => {
81+
const { prepareSpy, getSelectionSpy, pickerCtorSpy } = mockPicker();
82+
const canvas = createTestCanvas();
83+
const { app, updateHandlers } = createAppWithUpdateCollector(canvas);
84+
const pointerEvents = new Set<string>();
85+
86+
renderHarness(app, canvas, pointerEvents);
87+
88+
// 1) No listeners: frame update should not prepare
89+
updateHandlers.forEach((fn) => fn());
90+
expect(prepareSpy).not.toHaveBeenCalled();
91+
expect(getSelectionSpy).not.toHaveBeenCalled();
92+
93+
// 2) No listeners: interaction should not prepare
94+
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10 });
95+
expect(prepareSpy).not.toHaveBeenCalled();
96+
expect(getSelectionSpy).not.toHaveBeenCalled();
97+
98+
// 3) Add a listener: simulate pointer move, then frame update should prepare
99+
pointerEvents.add('some-mocked-event-listener');
100+
fireEvent.pointerMove(canvas, { clientX: 12, clientY: 18 });
101+
updateHandlers.forEach((fn) => fn());
102+
expect(prepareSpy).toHaveBeenCalled();
103+
104+
// 4) Interaction should also prepare and try to select
105+
fireEvent.pointerDown(canvas, { clientX: 15, clientY: 20 });
106+
expect(getSelectionSpy).toHaveBeenCalled();
107+
108+
// 5) Remove the last listener: subsequent frames/interactions skip prepare
109+
pointerEvents.clear();
110+
prepareSpy.mockClear();
111+
getSelectionSpy.mockClear();
112+
113+
updateHandlers.forEach((fn) => fn());
114+
fireEvent.pointerDown(canvas, { clientX: 5, clientY: 5 });
115+
expect(prepareSpy).not.toHaveBeenCalled();
116+
expect(getSelectionSpy).not.toHaveBeenCalled();
117+
118+
// Ensure our Picker constructor was invoked once for the memoized instance
119+
expect(pickerCtorSpy).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it('resizes picker on canvas resize and unsubscribes on unmount', async () => {
123+
const { resizeSpy } = mockPicker();
124+
125+
// Capture ResizeObserver callbacks
126+
const observedCallbacks: Array<() => void> = [];
127+
const RO = vi.fn().mockImplementation((cb: () => void) => ({
128+
observe: vi.fn(() => observedCallbacks.push(cb)),
129+
unobserve: vi.fn(),
130+
disconnect: vi.fn(),
131+
}));
132+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = RO as unknown;
133+
134+
const canvas = createTestCanvas();
135+
const { app, updateHandlers } = createAppWithUpdateCollector(canvas);
136+
const pointerEvents = new Set<string>();
137+
138+
const { unmount } = renderHarness(app, canvas, pointerEvents);
139+
140+
// Trigger ResizeObserver
141+
observedCallbacks.forEach((cb) => cb());
142+
expect(resizeSpy).toHaveBeenCalledWith(800, 600);
143+
144+
// Unmount should unsubscribe update
145+
expect(updateHandlers.length).toBe(1);
146+
const registeredUpdate = updateHandlers[0];
147+
unmount();
148+
expect(app.off).toHaveBeenCalledWith('update', registeredUpdate);
149+
});
150+
151+
it('Entity registers/unregisters pointer events and picker responds only when present', async () => {
152+
const { prepareSpy, pickerCtorSpy } = mockPicker();
153+
154+
// Create a real mocked PlayCanvas application (from test setup)
155+
const app = new pc.Application(document.createElement('canvas'), {
156+
graphicsDevice: new pc.NullGraphicsDevice(document.createElement('canvas')),
157+
touch: new pc.TouchDevice(document.createElement('canvas')),
158+
mouse: new pc.Mouse(document.createElement('canvas')),
159+
});
160+
161+
// Use the application's actual canvas for usePicker
162+
const appCanvas = (app.graphicsDevice as unknown as { canvas: HTMLCanvasElement }).canvas;
163+
Object.assign(appCanvas, {
164+
width: 800,
165+
height: 600,
166+
getBoundingClientRect: () => ({
167+
left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({})
168+
}),
169+
});
170+
171+
// Provide a camera stub so picking path runs
172+
vi.spyOn(app.root, 'findComponents').mockReturnValue([{ priority: 1, renderTarget: null }] as unknown as pc.CameraComponent[]);
173+
174+
const pointerEvents = new Set<string>();
175+
176+
const Harness = ({ withEntity }: { withEntity: boolean }) => {
177+
const elRef = useRef<HTMLDivElement | null>(null);
178+
useEffect(() => { elRef.current?.appendChild(appCanvas); }, []);
179+
usePicker(app as unknown as pc.AppBase, appCanvas, pointerEvents);
180+
return (
181+
<div ref={elRef}>
182+
<AppContext.Provider value={app}>
183+
<PointerEventsContext.Provider value={pointerEvents}>
184+
<ParentContext.Provider value={app.root}>
185+
{withEntity ? (
186+
<Entity onPointerDown={() => { /* no-op */ }} />
187+
) : null}
188+
</ParentContext.Provider>
189+
</PointerEventsContext.Provider>
190+
</AppContext.Provider>
191+
</div>
192+
);
193+
};
194+
195+
const { rerender, unmount } = render(<Harness withEntity={false} />);
196+
197+
// No entity with handlers → pointerEvents empty → no prepare on update
198+
expect(pointerEvents.size).toBe(0);
199+
fireEvent.pointerMove(appCanvas, { clientX: 10, clientY: 10 });
200+
app.fire('update');
201+
expect(prepareSpy).not.toHaveBeenCalled();
202+
203+
// Mount entity with a pointer handler → picker should prepare on update
204+
rerender(<Harness withEntity={true} />);
205+
expect(pointerEvents.size).toBe(1);
206+
fireEvent.pointerMove(appCanvas, { clientX: 20, clientY: 20 });
207+
app.fire('update');
208+
expect(prepareSpy).toHaveBeenCalled();
209+
210+
// Remove entity → pointerEvents cleared → picker no longer prepares
211+
prepareSpy.mockClear();
212+
rerender(<Harness withEntity={false} />);
213+
expect(pointerEvents.size).toBe(0);
214+
fireEvent.pointerMove(appCanvas, { clientX: 30, clientY: 30 });
215+
app.fire('update');
216+
expect(prepareSpy).not.toHaveBeenCalled();
217+
218+
expect(pickerCtorSpy).toHaveBeenCalledTimes(1);
219+
unmount();
220+
});
221+
});
222+
223+

packages/lib/src/utils/picker.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ const getEntityAtPointerEvent = async (app : AppBase, picker: Picker, rect: DOMR
5252
// Calculate position relative to canvas
5353
const x = e.clientX - rect.left;
5454
const y = e.clientY - rect.top;
55+
56+
// Ignore events outside the canvas bounds to avoid unnecessary picker work
57+
if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
58+
return null;
59+
}
5560

5661
// Scale calculation using PlayCanvas's DPR
5762
const scaleX = canvas.width / (rect.width * app.graphicsDevice.maxPixelRatio);
@@ -85,7 +90,7 @@ export const usePicker = (app: AppBase | null, el: HTMLElement | null, pointerEv
8590
return new Picker(app, app.graphicsDevice.width, app.graphicsDevice.height);
8691
}, [app]);
8792

88-
// Watch for the canvas to resize. Neccesary for correct picking
93+
// Watch for the canvas to resize. Necessary for correct picking
8994
useEffect(() => {
9095
const resizeObserver = new ResizeObserver(() => {
9196
canvasRectRef.current = app ? app.graphicsDevice.canvas.getBoundingClientRect() : null;
@@ -106,7 +111,11 @@ export const usePicker = (app: AppBase | null, el: HTMLElement | null, pointerEv
106111
}, [picker])
107112

108113
const onFrameUpdate = useCallback(async () => {
109-
if (pointerEvents.size === 0) return;
114+
if (pointerEvents.size === 0) {
115+
// No listeners: clear hover state to avoid stale pointerout on re-enable
116+
activeEntity.current = null;
117+
return;
118+
}
110119

111120
const e : PointerEvent | null = pointerDetails.current;
112121
if (!picker || !app || !e) return null;
@@ -176,5 +185,5 @@ export const usePicker = (app: AppBase | null, el: HTMLElement | null, pointerEv
176185
el.removeEventListener('pointermove', onPointerMove);
177186
app.off('update', onFrameUpdate);
178187
};
179-
}, [app, el, onInteractionEvent, pointerEvents]);
188+
}, [app, el, onInteractionEvent, onPointerMove, onFrameUpdate]);
180189
}

0 commit comments

Comments
 (0)