From b838d5bb5df464dcdd8813f9dc7112b471259468 Mon Sep 17 00:00:00 2001 From: Michael Bashurov Date: Sun, 23 Jul 2023 15:43:32 +0300 Subject: [PATCH] wip --- package.json | 1 + src/Controllers.test.tsx | 92 ++++++++++ src/Controllers.tsx | 3 +- src/XRController.tsx | 2 +- src/XRControllerModel.ts | 201 +++++++++++++++++++++ src/XRControllerModelFactory.ts | 204 +--------------------- src/XREvents.test.tsx | 11 +- src/mocks/XRControllerMock.ts | 4 +- src/mocks/XRControllerModelFactoryMock.ts | 25 +++ src/mocks/XRControllerModelMock.ts | 25 +++ src/mocks/{storeMock.ts => storeMock.tsx} | 9 +- src/testUtilsThree.tsx | 159 +++++++++++++++++ yarn.lock | 5 + 13 files changed, 524 insertions(+), 217 deletions(-) create mode 100644 src/Controllers.test.tsx create mode 100644 src/XRControllerModel.ts create mode 100644 src/mocks/XRControllerModelFactoryMock.ts create mode 100644 src/mocks/XRControllerModelMock.ts rename src/mocks/{storeMock.ts => storeMock.tsx} (77%) create mode 100644 src/testUtilsThree.tsx diff --git a/package.json b/package.json index f42545c..c95afc5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@react-three/drei": "^9.13.2", "@react-three/fiber": "^8.0.27", + "@react-three/test-renderer": "^8.2.0", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", "@types/react-test-renderer": "^18.0.0", diff --git a/src/Controllers.test.tsx b/src/Controllers.test.tsx new file mode 100644 index 0000000..1875e99 --- /dev/null +++ b/src/Controllers.test.tsx @@ -0,0 +1,92 @@ +import * as React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { createStoreMock, createStoreProvider } from './mocks/storeMock' +import { render } from './testUtilsThree' +import { Controllers } from './Controllers' +import { XRControllerMock } from './mocks/XRControllerMock' +import { XRControllerModel } from './XRControllerModel' +import { XRControllerModelFactoryMock } from './mocks/XRControllerModelFactoryMock' +// import { XRControllerModelMock } from './mocks/XRControllerModelMock' + +// vi.mock('./XRControllerModel', async () => { +// const { XRControllerModelMock } = await vi.importActual('./mocks/XRControllerMock') +// return { XRControllerModel: XRControllerModelMock } +// }) + +vi.mock('./XRControllerModelFactory', async () => { + const { XRControllerModelFactoryMock } = await vi.importActual('./mocks/XRControllerModelFactoryMock') + return { XRControllerModelFactory: XRControllerModelFactoryMock } +}) + +describe('Controllers', () => { + it('should not render anything if controllers in state are empty', async () => { + const store = createStoreMock() + const xrControllerMock = new XRControllerMock(0) + store.setState({ controllers: [] }) + + const { renderer } = await render(, { wrapper: createStoreProvider(store) }) + + // We aren't rendering anything as a direct children, only in portals + const graph = renderer.toGraph() + expect(graph).toHaveLength(0) + // Checking portals + expect(xrControllerMock.grip.children).toHaveLength(0) + expect(xrControllerMock.controller.children).toHaveLength(0) + }) + + it('should render one xr controller model and one ray given one controller in state', async () => { + const store = createStoreMock() + const xrControllerMock = new XRControllerMock(0) + store.setState({ controllers: [xrControllerMock] }) + + const { renderer } = await render(, { wrapper: createStoreProvider(store) }) + + // We aren't rendering anything as a direct children, only in portals + const graph = renderer.toGraph() + expect(graph).toHaveLength(0) + // Checking portals + expect(xrControllerMock.grip.children).toHaveLength(1) + expect(xrControllerMock.grip.children[0]).toBeInstanceOf(XRControllerModel) + expect(xrControllerMock.controller.children).toHaveLength(1) + expect(xrControllerMock.controller.children[0].type).toBe('Line') + }) + + it('should render two xr controller models and two rays given one controller in state', async () => { + const store = createStoreMock() + const xrControllerMockLeft = new XRControllerMock(0) + const xrControllerMockRight = new XRControllerMock(1) + store.setState({ controllers: [xrControllerMockLeft, xrControllerMockRight] }) + + const { renderer } = await render(, { wrapper: createStoreProvider(store) }) + + // We aren't rendering anything as a direct children, only in portals + const graph = renderer.toGraph() + expect(graph).toHaveLength(0) + // Checking portals + // left + expect(xrControllerMockLeft.grip.children).toHaveLength(1) + expect(xrControllerMockLeft.grip.children[0]).toBeInstanceOf(XRControllerModel) + expect(xrControllerMockLeft.controller.children).toHaveLength(1) + expect(xrControllerMockLeft.controller.children[0].type).toBe('Line') + // right + expect(xrControllerMockRight.grip.children).toHaveLength(1) + expect(xrControllerMockRight.grip.children[0]).toBeInstanceOf(XRControllerModel) + expect(xrControllerMockRight.controller.children).toHaveLength(1) + expect(xrControllerMockRight.controller.children[0].type).toBe('Line') + }) + + it('should handle xr controller model given one controller in state', async () => { + const store = createStoreMock() + const xrControllerMock = new XRControllerMock(0) + xrControllerMock.inputSource = {} + store.setState({ controllers: [xrControllerMock] }) + // const initializeControllerModelSpy = vi.spyOn() + + const { renderer } = await render(, { wrapper: createStoreProvider(store) }) + + const xrControllerModelFactory = XRControllerModelFactoryMock.instance + expect(xrControllerModelFactory).toBeDefined() + expect(xrControllerMock.xrControllerModel).toBeInstanceOf(XRControllerModel) + expect(xrControllerModelFactory?.initializeControllerModel).toBeCalled() + }) +}) diff --git a/src/Controllers.tsx b/src/Controllers.tsx index be46ec7..ebdd05a 100644 --- a/src/Controllers.tsx +++ b/src/Controllers.tsx @@ -3,8 +3,9 @@ import * as THREE from 'three' import { useFrame, Object3DNode, extend, createPortal } from '@react-three/fiber' import { useXR } from './XR' import { XRController } from './XRController' -import { XRControllerModel, XRControllerModelFactory } from './XRControllerModelFactory' +import { XRControllerModelFactory } from './XRControllerModelFactory' import { useCallback } from 'react' +import { XRControllerModel } from './XRControllerModel' export interface RayProps extends Partial { /** The XRController to attach the ray to */ diff --git a/src/XRController.tsx b/src/XRController.tsx index c988b15..9332251 100644 --- a/src/XRController.tsx +++ b/src/XRController.tsx @@ -1,6 +1,6 @@ import * as THREE from 'three' import { XRControllerEvent } from './XREvents' -import { XRControllerModel } from './XRControllerModelFactory' +import { XRControllerModel } from './XRControllerModel' /** Counterpart of WebXRController from three ks * in a sense that it's long living */ diff --git a/src/XRControllerModel.ts b/src/XRControllerModel.ts new file mode 100644 index 0000000..0051499 --- /dev/null +++ b/src/XRControllerModel.ts @@ -0,0 +1,201 @@ +import { + Group, + Texture, + Object3D, + Mesh, + MeshBasicMaterial, + MeshLambertMaterial, + MeshPhongMaterial, + MeshStandardMaterial, + SphereGeometry +} from 'three' +import { MotionController, MotionControllerConstants } from 'three-stdlib' + +const isEnvMapApplicable = ( + material: any +): material is MeshBasicMaterial | MeshStandardMaterial | MeshPhongMaterial | MeshLambertMaterial => 'envMap' in material + +const applyEnvironmentMap = (envMap: Texture, envMapIntensity: number, obj: Object3D): void => { + obj.traverse((child) => { + if (child instanceof Mesh && isEnvMapApplicable(child.material)) { + child.material.envMap = envMap + if ('envMapIntensity' in child.material) child.material.envMapIntensity = envMapIntensity + child.material.needsUpdate = true + } + }) +} + +/** + * Walks the model's tree to find the nodes needed to animate the components and + * saves them to the motionContoller components for use in the frame loop. When + * touchpads are found, attaches a touch dot to them. + */ +function findNodes(motionController: MotionController, scene: Object3D): void { + // Loop through the components and find the nodes needed for each components' visual responses + Object.values(motionController.components).forEach((component) => { + const { type, touchPointNodeName, visualResponses } = component + + if (type === MotionControllerConstants.ComponentType.TOUCHPAD && touchPointNodeName) { + component.touchPointNode = scene.getObjectByName(touchPointNodeName) + if (component.touchPointNode) { + // Attach a touch dot to the touchpad. + const sphereGeometry = new SphereGeometry(0.001) + const material = new MeshBasicMaterial({ color: 0x0000ff }) + const sphere = new Mesh(sphereGeometry, material) + component.touchPointNode.add(sphere) + } else { + console.warn(`Could not find touch dot, ${component.touchPointNodeName}, in touchpad component ${component.id}`) + } + } + + // Loop through all the visual responses to be applied to this component + Object.values(visualResponses).forEach((visualResponse) => { + const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse + + // If animating a transform, find the two nodes to be interpolated between. + if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && minNodeName && maxNodeName) { + visualResponse.minNode = scene.getObjectByName(minNodeName) + visualResponse.maxNode = scene.getObjectByName(maxNodeName) + + // If the extents cannot be found, skip this animation + if (!visualResponse.minNode) { + console.warn(`Could not find ${minNodeName} in the model`) + return + } + + if (!visualResponse.maxNode) { + console.warn(`Could not find ${maxNodeName} in the model`) + return + } + } + + // If the target node cannot be found, skip this animation + visualResponse.valueNode = scene.getObjectByName(valueNodeName) + if (!visualResponse.valueNode) { + console.warn(`Could not find ${valueNodeName} in the model`) + } + }) + }) +} + +function addAssetSceneToControllerModel(controllerModel: XRControllerModel, scene: Object3D): void { + // Find the nodes needed for animation and cache them on the motionController. + findNodes(controllerModel.motionController!, scene) + + // Apply any environment map that the mesh already has set. + if (controllerModel.envMap) { + applyEnvironmentMap(controllerModel.envMap, controllerModel.envMapIntensity, scene) + } + + // Add the glTF scene to the controllerModel. + controllerModel.add(scene) +} + +export class XRControllerModel extends Group { + envMap: Texture | null + envMapIntensity: number + motionController: MotionController | null + scene: Object3D | null + + constructor() { + super() + + this.motionController = null + this.envMap = null + this.envMapIntensity = 1 + this.scene = null + } + + setEnvironmentMap(envMap: Texture, envMapIntensity = 1): XRControllerModel { + if (this.envMap === envMap && this.envMapIntensity === envMapIntensity) { + return this + } + + this.envMap = envMap + this.envMapIntensity = envMapIntensity + applyEnvironmentMap(envMap, envMapIntensity, this) + + return this + } + + connectModel(scene: Object3D): void { + if (!this.motionController) { + console.warn('scene tried to add, but no motion controller') + return + } + + this.scene = scene + addAssetSceneToControllerModel(this, scene) + this.dispatchEvent({ + type: 'modelconnected', + data: scene + }) + } + + connectMotionController(motionController: MotionController): void { + this.motionController = motionController + this.dispatchEvent({ + type: 'motionconnected', + data: motionController + }) + } + + /** + * Polls data from the XRInputSource and updates the model's components to match + * the real world data + */ + updateMatrixWorld(force: boolean): void { + super.updateMatrixWorld(force) + + if (!this.motionController) return + + // Cause the MotionController to poll the Gamepad for data + this.motionController.updateFromGamepad() + + // Update the 3D model to reflect the button, thumbstick, and touchpad state + Object.values(this.motionController.components).forEach((component) => { + // Update node data based on the visual responses' current states + Object.values(component.visualResponses).forEach((visualResponse) => { + const { valueNode, minNode, maxNode, value, valueNodeProperty } = visualResponse + + // Skip if the visual response node is not found. No error is needed, + // because it will have been reported at load time. + if (!valueNode) return + + // Calculate the new properties based on the weight supplied + if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY && typeof value === 'boolean') { + valueNode.visible = value + } else if ( + valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && + minNode && + maxNode && + typeof value === 'number' + ) { + valueNode.quaternion.slerpQuaternions(minNode.quaternion, maxNode.quaternion, value) + + valueNode.position.lerpVectors(minNode.position, maxNode.position, value) + } + }) + }) + } + + disconnect(): void { + this.dispatchEvent({ + type: 'motiondisconnected', + data: this.motionController + }) + this.dispatchEvent({ + type: 'modeldisconnected', + data: this.scene + }) + this.motionController = null + if (this.scene) { + this.remove(this.scene) + } + this.scene = null + } + + dispose(): void { + this.disconnect() + } +} diff --git a/src/XRControllerModelFactory.ts b/src/XRControllerModelFactory.ts index fe93c44..3ea0e1b 100644 --- a/src/XRControllerModelFactory.ts +++ b/src/XRControllerModelFactory.ts @@ -1,208 +1,10 @@ -import { - Mesh, - Object3D, - SphereGeometry, - MeshBasicMaterial, - MeshStandardMaterial, - MeshPhongMaterial, - MeshLambertMaterial, - Group -} from 'three' -import type { Texture } from 'three' -import { fetchProfile, GLTFLoader, MotionController, MotionControllerConstants } from 'three-stdlib' +import { Object3D } from 'three' +import { fetchProfile, GLTFLoader, MotionController } from 'three-stdlib' +import { XRControllerModel } from './XRControllerModel' const DEFAULT_PROFILES_PATH = 'https://cdn.jsdelivr.net/npm/@webxr-input-profiles/assets@1.0/dist/profiles' const DEFAULT_PROFILE = 'generic-trigger' -const isEnvMapApplicable = ( - material: any -): material is MeshBasicMaterial | MeshStandardMaterial | MeshPhongMaterial | MeshLambertMaterial => 'envMap' in material - -const applyEnvironmentMap = (envMap: Texture, envMapIntensity: number, obj: Object3D): void => { - obj.traverse((child) => { - if (child instanceof Mesh && isEnvMapApplicable(child.material)) { - child.material.envMap = envMap - if ('envMapIntensity' in child.material) child.material.envMapIntensity = envMapIntensity - child.material.needsUpdate = true - } - }) -} - -export class XRControllerModel extends Group { - envMap: Texture | null - envMapIntensity: number - motionController: MotionController | null - scene: Object3D | null - - constructor() { - super() - - this.motionController = null - this.envMap = null - this.envMapIntensity = 1 - this.scene = null - } - - setEnvironmentMap(envMap: Texture, envMapIntensity = 1): XRControllerModel { - if (this.envMap === envMap && this.envMapIntensity === envMapIntensity) { - return this - } - - this.envMap = envMap - this.envMapIntensity = envMapIntensity - applyEnvironmentMap(envMap, envMapIntensity, this) - - return this - } - - connectModel(scene: Object3D): void { - if (!this.motionController) { - console.warn('scene tried to add, but no motion controller') - return - } - - this.scene = scene - addAssetSceneToControllerModel(this, scene) - this.dispatchEvent({ - type: 'modelconnected', - data: scene - }) - } - - connectMotionController(motionController: MotionController): void { - this.motionController = motionController - this.dispatchEvent({ - type: 'motionconnected', - data: motionController - }) - } - - /** - * Polls data from the XRInputSource and updates the model's components to match - * the real world data - */ - updateMatrixWorld(force: boolean): void { - super.updateMatrixWorld(force) - - if (!this.motionController) return - - // Cause the MotionController to poll the Gamepad for data - this.motionController.updateFromGamepad() - - // Update the 3D model to reflect the button, thumbstick, and touchpad state - Object.values(this.motionController.components).forEach((component) => { - // Update node data based on the visual responses' current states - Object.values(component.visualResponses).forEach((visualResponse) => { - const { valueNode, minNode, maxNode, value, valueNodeProperty } = visualResponse - - // Skip if the visual response node is not found. No error is needed, - // because it will have been reported at load time. - if (!valueNode) return - - // Calculate the new properties based on the weight supplied - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.VISIBILITY && typeof value === 'boolean') { - valueNode.visible = value - } else if ( - valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && - minNode && - maxNode && - typeof value === 'number' - ) { - valueNode.quaternion.slerpQuaternions(minNode.quaternion, maxNode.quaternion, value) - - valueNode.position.lerpVectors(minNode.position, maxNode.position, value) - } - }) - }) - } - - disconnect(): void { - this.dispatchEvent({ - type: 'motiondisconnected', - data: this.motionController - }) - this.dispatchEvent({ - type: 'modeldisconnected', - data: this.scene - }) - this.motionController = null - if (this.scene) { - this.remove(this.scene) - } - this.scene = null - } - - dispose(): void { - this.disconnect() - } -} - -/** - * Walks the model's tree to find the nodes needed to animate the components and - * saves them to the motionContoller components for use in the frame loop. When - * touchpads are found, attaches a touch dot to them. - */ -function findNodes(motionController: MotionController, scene: Object3D): void { - // Loop through the components and find the nodes needed for each components' visual responses - Object.values(motionController.components).forEach((component) => { - const { type, touchPointNodeName, visualResponses } = component - - if (type === MotionControllerConstants.ComponentType.TOUCHPAD && touchPointNodeName) { - component.touchPointNode = scene.getObjectByName(touchPointNodeName) - if (component.touchPointNode) { - // Attach a touch dot to the touchpad. - const sphereGeometry = new SphereGeometry(0.001) - const material = new MeshBasicMaterial({ color: 0x0000ff }) - const sphere = new Mesh(sphereGeometry, material) - component.touchPointNode.add(sphere) - } else { - console.warn(`Could not find touch dot, ${component.touchPointNodeName}, in touchpad component ${component.id}`) - } - } - - // Loop through all the visual responses to be applied to this component - Object.values(visualResponses).forEach((visualResponse) => { - const { valueNodeName, minNodeName, maxNodeName, valueNodeProperty } = visualResponse - - // If animating a transform, find the two nodes to be interpolated between. - if (valueNodeProperty === MotionControllerConstants.VisualResponseProperty.TRANSFORM && minNodeName && maxNodeName) { - visualResponse.minNode = scene.getObjectByName(minNodeName) - visualResponse.maxNode = scene.getObjectByName(maxNodeName) - - // If the extents cannot be found, skip this animation - if (!visualResponse.minNode) { - console.warn(`Could not find ${minNodeName} in the model`) - return - } - - if (!visualResponse.maxNode) { - console.warn(`Could not find ${maxNodeName} in the model`) - return - } - } - - // If the target node cannot be found, skip this animation - visualResponse.valueNode = scene.getObjectByName(valueNodeName) - if (!visualResponse.valueNode) { - console.warn(`Could not find ${valueNodeName} in the model`) - } - }) - }) -} - -function addAssetSceneToControllerModel(controllerModel: XRControllerModel, scene: Object3D): void { - // Find the nodes needed for animation and cache them on the motionController. - findNodes(controllerModel.motionController!, scene) - - // Apply any environment map that the mesh already has set. - if (controllerModel.envMap) { - applyEnvironmentMap(controllerModel.envMap, controllerModel.envMapIntensity, scene) - } - - // Add the glTF scene to the controllerModel. - controllerModel.add(scene) -} - export class XRControllerModelFactory { gltfLoader: GLTFLoader path: string diff --git a/src/XREvents.test.tsx b/src/XREvents.test.tsx index e0a1a31..8563796 100644 --- a/src/XREvents.test.tsx +++ b/src/XREvents.test.tsx @@ -1,17 +1,8 @@ -import * as React from 'react' import { describe, expect, it, vi } from 'vitest' -import { XRContext, XRState } from './context' import { renderHook } from './testUtils' import { useXREvent } from './XREvents' -import { createStoreMock } from './mocks/storeMock' +import { createStoreMock, createStoreProvider } from './mocks/storeMock' import { XRControllerMock } from './mocks/XRControllerMock' -import { PropsWithChildren } from 'react' -import { StoreApi, UseBoundStore } from 'zustand' - -const createStoreProvider = - (store: UseBoundStore>) => - ({ children }: PropsWithChildren) => - describe('XREvents', () => { it('should not call callback if no events happened', async () => { diff --git a/src/mocks/XRControllerMock.ts b/src/mocks/XRControllerMock.ts index 3413765..6a567d8 100644 --- a/src/mocks/XRControllerMock.ts +++ b/src/mocks/XRControllerMock.ts @@ -1,7 +1,7 @@ import { Group, XRTargetRaySpace, XRGripSpace, XRHandSpace, Vector3, XRHandInputState, XRHandJoints } from 'three' import { XRController } from '../XRController' import { XRControllerEvent } from '../XREvents' -import { XRControllerModel } from '../XRControllerModelFactory' +import { XRControllerModel } from '../XRControllerModel' export class XRTargetRaySpaceMock extends Group implements XRTargetRaySpace { readonly angularVelocity: Vector3 = new Vector3() @@ -52,4 +52,4 @@ export class XRControllerMock extends Group implements XRController { } dispose(): void {} -} \ No newline at end of file +} diff --git a/src/mocks/XRControllerModelFactoryMock.ts b/src/mocks/XRControllerModelFactoryMock.ts new file mode 100644 index 0000000..5c07ae5 --- /dev/null +++ b/src/mocks/XRControllerModelFactoryMock.ts @@ -0,0 +1,25 @@ +import { GLTFLoader } from 'three-stdlib' +import { XRControllerModelFactory } from '../XRControllerModelFactory' +import { vi, afterEach } from 'vitest' +import { XRControllerModel } from '../XRControllerModel' + +afterEach(() => { + + console.log(3) + XRControllerModelFactoryMock.instance = undefined +}) + +// @ts-ignore +export class XRControllerModelFactoryMock implements XRControllerModelFactory { + static instance: XRControllerModelFactoryMock | undefined + constructor() { + console.log(1) + XRControllerModelFactoryMock.instance = this + console.log(1.5) + } + // @ts-ignore + gltfLoader: GLTFLoader + // @ts-ignore + path: string + initializeControllerModel = vi.fn<[controllerModel: XRControllerModel, xrInputSource: XRInputSource], void>() +} diff --git a/src/mocks/XRControllerModelMock.ts b/src/mocks/XRControllerModelMock.ts new file mode 100644 index 0000000..5c64cfd --- /dev/null +++ b/src/mocks/XRControllerModelMock.ts @@ -0,0 +1,25 @@ +import { Event, Group, Object3D, Texture } from 'three' +import { XRControllerModel } from '../XRControllerModel' +import { MotionController } from 'three-stdlib' + +export class XRControllerModelMock extends Group implements XRControllerModel { + envMap: Texture | null = null + envMapIntensity = 1 + motionController: MotionController | null = null + scene: Object3D | null = null + setEnvironmentMap(envMap: Texture, envMapIntensity?: number): XRControllerModel { + throw new Error('Method not implemented.') + } + connectModel(scene: Object3D): void { + throw new Error('Method not implemented.') + } + connectMotionController(motionController: MotionController): void { + throw new Error('Method not implemented.') + } + disconnect(): void { + throw new Error('Method not implemented.') + } + dispose(): void { + throw new Error('Method not implemented.') + } +} diff --git a/src/mocks/storeMock.ts b/src/mocks/storeMock.tsx similarity index 77% rename from src/mocks/storeMock.ts rename to src/mocks/storeMock.tsx index c22567f..fca664e 100644 --- a/src/mocks/storeMock.ts +++ b/src/mocks/storeMock.tsx @@ -1,7 +1,7 @@ -import create from 'zustand' +import create, { StoreApi, UseBoundStore } from 'zustand' import * as React from 'react' import * as THREE from 'three' -import { XRState } from '../context' +import { XRContext, XRState } from '../context' import { Group } from 'three' import { XRInteractionHandler, XRInteractionType } from '@react-three/xr' @@ -33,3 +33,8 @@ export const createStoreMock = () => addInteraction(_object: THREE.Object3D, _eventType: XRInteractionType, _handlerRef: React.RefObject) {}, removeInteraction(_object: THREE.Object3D, _eventType: XRInteractionType, _handlerRef: React.RefObject) {} })) + +export const createStoreProvider = + (store: UseBoundStore>) => + ({ children }: React.PropsWithChildren) => + \ No newline at end of file diff --git a/src/testUtilsThree.tsx b/src/testUtilsThree.tsx new file mode 100644 index 0000000..40292ca --- /dev/null +++ b/src/testUtilsThree.tsx @@ -0,0 +1,159 @@ +import { beforeEach, afterEach } from 'vitest' +import { create } from '@react-three/test-renderer' +// import { createRef, useEffect } from 'react' +import * as React from 'react' + +// https://github.com/testing-library/react-testing-library/blob/main/src/act-compat.js +function getGlobalThis() { + /* istanbul ignore else */ + if (typeof globalThis !== 'undefined') { + return globalThis + } + /* istanbul ignore next */ + if (typeof self !== 'undefined') { + return self + } + /* istanbul ignore next */ + if (typeof window !== 'undefined') { + return window + } + /* istanbul ignore next */ + if (typeof global !== 'undefined') { + return global + } + /* istanbul ignore next */ + throw new Error('unable to locate global object') +} + +function setIsReactActEnvironment(isReactActEnvironment: boolean | undefined) { + ;(getGlobalThis() as any).IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment +} + +function getIsReactActEnvironment() { + return (getGlobalThis() as any).IS_REACT_ACT_ENVIRONMENT +} + +let existingIsReactActEnvironment: boolean | undefined + +beforeEach(() => { + existingIsReactActEnvironment = getIsReactActEnvironment() + setIsReactActEnvironment(true) +}) + +afterEach(() => { + setIsReactActEnvironment(existingIsReactActEnvironment) + existingIsReactActEnvironment = undefined +}) + +// function withGlobalActEnvironment(actImplementation: typeof reactThreeTestRendererAct) { +// return (callback: () => Promise) => { +// const previousActEnvironment = getIsReactActEnvironment() +// setIsReactActEnvironment(true) +// try { +// // The return value of `act` is always a thenable. +// let callbackNeedsToBeAwaited = false +// const actResult = actImplementation(() => { +// const result = callback() +// if (result !== null && typeof result === 'object' && typeof result.then === 'function') { +// callbackNeedsToBeAwaited = true +// } +// return result +// }) +// if (callbackNeedsToBeAwaited) { +// const thenable = actResult +// return { +// then: (resolve: (v: any) => any, reject: (e: any) => any) => { +// thenable.then( +// (returnValue) => { +// setIsReactActEnvironment(previousActEnvironment) +// resolve(returnValue) +// }, +// (error) => { +// setIsReactActEnvironment(previousActEnvironment) +// reject(error) +// } +// ) +// } +// } +// } else { +// setIsReactActEnvironment(previousActEnvironment) +// return actResult +// } +// } catch (error) { +// // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT +// // or if we have to await the callback first. +// setIsReactActEnvironment(previousActEnvironment) +// throw error +// } +// } +// } + +// export const act = withGlobalActEnvironment(reactThreeTestRendererAct) + +// /** +// * Got this from vitest react example +// * @see https://vitest.dev/guide/#examples +// */ +// function toJson(component: ReactTestRenderer) { +// const result = component.toJSON() +// expect(result).toBeDefined() +// expect(result).not.toBeInstanceOf(Array) +// return result as ReactTestRendererJSON +// } + +/** + * Hack to make async effects affect render + * @see https://stackoverflow.com/a/70926194 + */ +export async function render( + element: React.ReactElement, + { + wrapper: WrapperComponent + }: { wrapper?: React.FunctionComponent | React.ComponentClass } = {} +) { + const wrapUiIfNeeded = (innerElement: React.ReactElement) => + WrapperComponent ? React.createElement(WrapperComponent, null, innerElement) : innerElement + + const renderer = await create(wrapUiIfNeeded(element)) + + async function rerender(newElement = element) { + await renderer.update(wrapUiIfNeeded(newElement)) + } + + async function unmount() { + await renderer.unmount() + } + + + return { + renderer, + // toJson: () => toJson(root), + rerender, + unmount + } +} + +// export async function renderHook( +// hook: () => T, +// { wrapper }: { wrapper?: React.FunctionComponent | React.ComponentClass } = {} +// ) { +// const result = createRef() as React.MutableRefObject + +// function TestComponent() { +// const pendingResult = hook() + +// useEffect(() => { +// result.current = pendingResult +// }) + +// return null +// } + +// const { rerender: baseRerender, unmount } = await render(, { wrapper }) + +// function rerender() { +// return baseRerender() +// } + +// return { result, rerender, unmount } +// } diff --git a/yarn.lock b/yarn.lock index a454c61..3236158 100644 --- a/yarn.lock +++ b/yarn.lock @@ -742,6 +742,11 @@ suspend-react "^0.0.8" zustand "^3.7.1" +"@react-three/test-renderer@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@react-three/test-renderer/-/test-renderer-8.2.0.tgz#ac69e4f9abc0f21f341378f3235bfeece4a7f782" + integrity sha512-sYTW/9AkU0f03M/rilYaCB9ORD3tS96bUhM+WRsx/QLtOKdUNCWrWwnxutm5M0orON0A0O84gbUosnqvCAKTsw== + "@types/chai-subset@^1.3.3": version "1.3.3" resolved "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz"