|
| 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 | + |
0 commit comments