diff --git a/fission/src/systems/scene/CameraControls.ts b/fission/src/systems/scene/CameraControls.ts index b152794e69..e800655a68 100644 --- a/fission/src/systems/scene/CameraControls.ts +++ b/fission/src/systems/scene/CameraControls.ts @@ -203,12 +203,6 @@ export class CustomOrbitControls extends CameraControls { return { ...this._coords } } - public setTargetCoordinates(coords: Partial) { - if (coords.theta !== undefined) this._nextCoords.theta = coords.theta - if (coords.phi !== undefined) this._nextCoords.phi = coords.phi - if (coords.r !== undefined) this._nextCoords.r = coords.r - } - public setImmediateCoordinates(coords: Partial) { if (coords.theta !== undefined) { this._coords.theta = coords.theta diff --git a/fission/src/systems/scene/SceneRenderer.ts b/fission/src/systems/scene/SceneRenderer.ts index 504f623ae2..a942c08932 100644 --- a/fission/src/systems/scene/SceneRenderer.ts +++ b/fission/src/systems/scene/SceneRenderer.ts @@ -27,8 +27,8 @@ const CLEAR_COLOR = 0x121212 const GROUND_COLOR = 0xfffef0 const STANDARD_ASPECT = 16.0 / 9.0 -const STANDARD_CAMERA_FOV_X = 110.0 -const STANDARD_CAMERA_FOV_Y = STANDARD_CAMERA_FOV_X / STANDARD_ASPECT +export const STANDARD_CAMERA_FOV_X = 110.0 +export const STANDARD_CAMERA_FOV_Y = STANDARD_CAMERA_FOV_X / STANDARD_ASPECT const textureLoader = new THREE.TextureLoader() diff --git a/fission/src/test/scene/CameraControls.test.ts b/fission/src/test/scene/CameraControls.test.ts new file mode 100644 index 0000000000..9adb6cf086 --- /dev/null +++ b/fission/src/test/scene/CameraControls.test.ts @@ -0,0 +1,170 @@ +import { expect, test, beforeEach, describe } from "vitest" +import { CustomOrbitControls } from "@/systems/scene/CameraControls" +import * as THREE from "three" +import ScreenInteractionHandler, { InteractionType } from "@/systems/scene/ScreenInteractionHandler" + +describe("CustomOrbitControls", () => { + let camera: THREE.PerspectiveCamera + let interactionHandler: ScreenInteractionHandler + let controls: CustomOrbitControls + + beforeEach(() => { + camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) + camera.position.set(0, 0, 5) + + const mockElement = document.createElement("div") + interactionHandler = new ScreenInteractionHandler(mockElement) + + controls = new CustomOrbitControls(camera, interactionHandler) + }) + + describe("Camera Position and Update", () => { + test("sets simple coordinates correctly", () => { + controls.setImmediateCoordinates({ theta: Math.PI / 2, phi: 0, r: 2.0 }) + controls.update(1 / 60) + + expect(camera.position.x).toBeCloseTo(2) + expect(camera.position.y).toBeCloseTo(0) + expect(camera.position.z).toBeCloseTo(0) + }) + + test("sets complex coordinates correctly", () => { + controls.setImmediateCoordinates({ theta: Math.PI / 3, phi: Math.PI / 6, r: 4.0 }) + controls.update(1 / 60) + + expect(camera.position.distanceTo(new THREE.Vector3(0, 0, 0))).toBeCloseTo(4) + + expect(camera.position.x).toBeCloseTo(3) + expect(camera.position.y).toBeCloseTo(-2) + expect(camera.position.z).toBeCloseTo(1.732) + }) + + test("clamps extreme values", () => { + // Test r (zoom) bounds - values should be clamped + controls.setImmediateCoordinates({ r: 1000 }) + controls.update(1 / 60) + const maxR = controls.getCurrentCoordinates().r + + controls.setImmediateCoordinates({ r: 0.001 }) + controls.update(1 / 60) + const minR = controls.getCurrentCoordinates().r + + expect(maxR).toBeLessThan(1000) + expect(minR).toBeGreaterThan(0.001) + expect(minR).toBeLessThan(maxR) + + // Test phi (vertical) bounds + controls.setImmediateCoordinates({ phi: Math.PI }) + controls.update(1 / 60) + const maxPhi = controls.getCurrentCoordinates().phi + + controls.setImmediateCoordinates({ phi: -Math.PI }) + controls.update(1 / 60) + const minPhi = controls.getCurrentCoordinates().phi + + expect(maxPhi).toBeLessThan(Math.PI) + expect(minPhi).toBeGreaterThan(-Math.PI) + expect(minPhi).toBeLessThan(maxPhi) + }) + }) + + describe("Mouse Interaction", () => { + const simulateMouseInteraction = (options: { + startPosition: [number, number] + movement?: [number, number] + scale?: number + updateFrames: number + endPosition: [number, number] + interactionType?: InteractionType + }) => { + const { startPosition, movement, scale, updateFrames, endPosition, interactionType = 0 } = options + + controls.interactionStart({ + interactionType, + position: startPosition, + }) + + if (movement || scale !== undefined) { + controls.interactionMove({ + interactionType, + movement, + scale, + }) + } + + for (let i = 0; i < updateFrames; i++) { + controls.update(1 / 60) + } + + controls.interactionEnd({ + interactionType, + position: endPosition, + }) + + for (let i = 0; i < 10; i++) { + controls.update(1 / 60) + } + } + + beforeEach(() => { + controls.setImmediateCoordinates({ theta: 0, phi: 0, r: 5 }) + controls.update(1 / 60) + }) + + test("simulate mouse drag", () => { + const initialCoords = controls.getCurrentCoordinates() + + simulateMouseInteraction({ + startPosition: [100, 100], + movement: [0.28, -0.105], + updateFrames: 60, + endPosition: [180, 70], + }) + + expect(controls.getCurrentCoordinates()).not.toEqual(initialCoords) + + expect(camera.position.distanceTo(new THREE.Vector3(0, 0, 0))).toBeCloseTo(5) + }) + + test("should zoom in and out correctly", () => { + const initialDistance = camera.position.distanceTo(new THREE.Vector3(0, 0, 0)) + expect(initialDistance).toBeCloseTo(5, 0) + + simulateMouseInteraction({ + scale: -1.0, + updateFrames: 1, + startPosition: [100, 100], + endPosition: [100, 100], + }) + + const zoomedInDistance = camera.position.distanceTo(new THREE.Vector3(0, 0, 0)) + + simulateMouseInteraction({ + scale: 2.0, + updateFrames: 1, + startPosition: [100, 100], + endPosition: [100, 100], + }) + + const finalDistance = camera.position.distanceTo(new THREE.Vector3(0, 0, 0)) + + expect(zoomedInDistance).toBeCloseTo(4, 0) + expect(finalDistance).toBeCloseTo(5.4, 0) + }) + + test("should not update when disabled", () => { + const initialPosition = camera.position.clone() + + controls.enabled = false + + simulateMouseInteraction({ + startPosition: [100, 100], + movement: [0.175, 0.35], + updateFrames: 30, + endPosition: [150, 200], + }) + + expect(camera.position.distanceTo(initialPosition)).toBeCloseTo(0) + }) + }) +}) diff --git a/fission/src/test/scene/DragModeSystem.test.ts b/fission/src/test/scene/DragModeSystem.test.ts new file mode 100644 index 0000000000..74c8548d90 --- /dev/null +++ b/fission/src/test/scene/DragModeSystem.test.ts @@ -0,0 +1,306 @@ +import { expect, test, vi, beforeEach, describe, afterEach } from "vitest" +import DragModeSystem from "@/systems/scene/DragModeSystem" +import PhysicsSystem from "@/systems/physics/PhysicsSystem" +import * as THREE from "three" +import { MiraType } from "@/mirabuf/MirabufLoader" +import { PRIMARY_MOUSE_INTERACTION, InteractionType } from "@/systems/scene/ScreenInteractionHandler" +import World from "@/systems/World" +import MirabufSceneObject from "@/mirabuf/MirabufSceneObject" + +vi.mock("@/systems/World", () => ({ + default: { + get physicsSystem() { + return this._physicsSystem + }, + set physicsSystem(value) { + this._physicsSystem = value + }, + _physicsSystem: null, + sceneRenderer: { + mainCamera: { + position: { x: 0, y: 0, z: 5 }, + quaternion: { x: 0, y: 0, z: 0, w: 1 }, + fov: 75, + aspect: 1, + near: 0.1, + far: 1000, + }, + scene: { + add: vi.fn(), + remove: vi.fn(), + }, + currentCameraControls: { + enabled: true, + coords: { theta: 0, phi: 0, r: 5 }, + focus: { + elements: new Array(16).fill(0), + makeTranslation: vi.fn(), + copy: vi.fn(), + }, + focusProvider: undefined, + }, + pixelToWorldSpace: (x: number, y: number) => ({ + x: x / 100, + y: y / 100, + z: 0, + sub: vi.fn().mockReturnThis(), + normalize: vi.fn().mockReturnThis(), + multiplyScalar: vi.fn().mockReturnThis(), + }), + screenInteractionHandler: { + interactionStart: vi.fn(), + interactionMove: vi.fn(), + interactionEnd: vi.fn(), + }, + renderer: { + domElement: { + parentElement: { + querySelector: vi.fn().mockReturnValue({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }, + }, + }, + }, + }, +})) + +type WorldWithPhysicsSystem = typeof World & { + physicsSystem: PhysicsSystem | null | undefined +} + +describe("DragModeSystem Integration Tests", () => { + let dragModeSystem: DragModeSystem + let physicsSystem: PhysicsSystem + + beforeEach(() => { + physicsSystem = new PhysicsSystem() + const mockWorld = World as unknown as WorldWithPhysicsSystem + mockWorld.physicsSystem = physicsSystem + + const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) + camera.position.set(0, 0, 5) + const sceneRenderer = mockWorld.sceneRenderer as { mainCamera: THREE.PerspectiveCamera } + sceneRenderer.mainCamera = camera + + dragModeSystem = new DragModeSystem() + }) + + afterEach(() => { + dragModeSystem.destroy() + physicsSystem.destroy() + }) + + describe("Event Handling", () => { + test("calls dispatchEvent when toggling drag mode", () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent") + + dragModeSystem.enabled = true + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "dragModeToggled", + detail: { enabled: true }, + }) + ) + + dragModeSystem.enabled = false + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "dragModeToggled", + detail: { enabled: false }, + }) + ) + + dispatchEventSpy.mockRestore() + }) + + test("should handle disable drag mode event", () => { + dragModeSystem.enabled = true + + window.dispatchEvent(new CustomEvent("disableDragMode")) + + expect(dragModeSystem.enabled).toBe(false) + }) + }) + + describe("Cleanup", () => { + test("should cleanup properly on destroy", () => { + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener") + + dragModeSystem.enabled = true + dragModeSystem.destroy() + + expect(dragModeSystem.enabled).toBe(false) + expect(removeEventListenerSpy).toHaveBeenCalledWith("disableDragMode", expect.any(Function)) + + removeEventListenerSpy.mockRestore() + }) + }) + + describe("Physics Integration", () => { + function setupDraggableCube() { + // Create a physics cube which will then be a draggable game piece + const vertices = new Float32Array([ + -0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, -0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, + 0.5, 0.5, 0.5, -0.5, 0.5, 0.5, + ]) + + const shapeResult = physicsSystem.createConvexHull(vertices) + expect(shapeResult.HasError()).toBe(false) + + const shape = shapeResult.Get() + const body = physicsSystem.createBody(shape, 1.0, new THREE.Vector3(0, 0, 0), new THREE.Quaternion()) + const bodyId = body.GetID() + physicsSystem.addBodyToSystem(bodyId, true) + + // Create a mock MirabufSceneObject that properly passes instanceof checks + const mockSceneObject = Object.create(MirabufSceneObject.prototype) + mockSceneObject.loadFocusTransform = vi.fn() + vi.spyOn(mockSceneObject, "miraType", "get").mockReturnValue(MiraType.FIELD) + + const mockAssociation = { + sceneObject: mockSceneObject, + isGamePiece: true, + } + + const originalGetBodyAssociation = physicsSystem.getBodyAssociation + physicsSystem.getBodyAssociation = vi.fn().mockReturnValue(mockAssociation) + + const originalRayCast = physicsSystem.rayCast + const mockRaycastResult = { + data: { mBodyID: bodyId }, + // eslint-disable-next-line + point: { GetX: () => 0, GetY: () => 0, GetZ: () => 0 }, + } + physicsSystem.rayCast = vi.fn().mockReturnValue(mockRaycastResult) + + const physicsBody = physicsSystem.getBody(bodyId) + dragModeSystem.enabled = true + + return { + physicsBody, + cleanup: () => { + physicsSystem.getBodyAssociation = originalGetBodyAssociation + physicsSystem.rayCast = originalRayCast + physicsSystem.destroyBodyIds(bodyId) + shape.Release() + }, + } + } + + test("should drag and move a cube when mouse is moved", () => { + const { physicsBody, cleanup } = setupDraggableCube() + + const initialPos = physicsBody.GetPosition() + const initialPosition = { x: initialPos.GetX(), y: initialPos.GetY(), z: initialPos.GetZ() } + + // Simulate mouse click to start dragging + const startInteraction = { + interactionType: PRIMARY_MOUSE_INTERACTION as InteractionType, + position: [400, 300] as [number, number], + } + + const screenHandler = World.sceneRenderer.screenInteractionHandler + screenHandler?.interactionStart?.(startInteraction) + + // Simulate mouse movement to drag the cube + const moveInteraction = { + interactionType: PRIMARY_MOUSE_INTERACTION as InteractionType, + movement: [100, 0] as [number, number], + } + + if (screenHandler && screenHandler.interactionMove) { + screenHandler.interactionMove(moveInteraction) + } + + // Update the drag system and physics system to apply forces + for (let i = 0; i < 10; i++) { + dragModeSystem.update(0.016) + physicsSystem.update(0.016) + } + + // Check that the cube has moved from its initial position + const afterDragPos = physicsBody.GetPosition() + const moved = + Math.abs(afterDragPos.GetX() - initialPosition.x) > 0.2 || + Math.abs(afterDragPos.GetY() - initialPosition.y) > 0.2 || + Math.abs(afterDragPos.GetZ() - initialPosition.z) > 0.2 + expect(moved).toBe(true) + + // Simulate mouse release to stop dragging + const endInteraction = { + interactionType: PRIMARY_MOUSE_INTERACTION as InteractionType, + position: [400, 300] as [number, number], + } + + screenHandler.interactionEnd?.(endInteraction) + + cleanup() + }) + + test("should move cube away from camera when wheel scrolled during drag", () => { + const { physicsBody, cleanup } = setupDraggableCube() + + const camera = World.sceneRenderer.mainCamera + const cameraPosition = camera.position.clone() + + // Start dragging first + const startInteraction = { + interactionType: PRIMARY_MOUSE_INTERACTION as InteractionType, + position: [400, 300] as [number, number], + } + + const screenHandler = World.sceneRenderer.screenInteractionHandler + screenHandler?.interactionStart?.(startInteraction) + + // Update to establish drag state + for (let i = 0; i < 5; i++) { + dragModeSystem.update(0.016) + physicsSystem.update(0.016) + } + + const beforeScrollPos = physicsBody.GetPosition() + const beforeScrollPosition = new THREE.Vector3( + beforeScrollPos.GetX(), + beforeScrollPos.GetY(), + beforeScrollPos.GetZ() + ) + const distanceBeforeScroll = cameraPosition.distanceTo(beforeScrollPosition) + + // Simulate mouse wheel scroll down + const wheelEvent = new WheelEvent("wheel", { + deltaY: 10000, + bubbles: true, + cancelable: true, + }) + const wheelEventHandler = dragModeSystem["_wheelEventHandler"] + wheelEventHandler?.(wheelEvent) + + for (let i = 0; i < 10; i++) { + dragModeSystem.update(0.016) + physicsSystem.update(0.016) + } + + const afterWheelPos = physicsBody.GetPosition() + const afterScrollPosition = new THREE.Vector3( + afterWheelPos.GetX(), + afterWheelPos.GetY(), + afterWheelPos.GetZ() + ) + const distanceAfterScroll = cameraPosition.distanceTo(afterScrollPosition) + + expect(Math.abs(distanceAfterScroll - distanceBeforeScroll)).toBeGreaterThan(0.5) + + // End dragging + const endInteraction = { + interactionType: PRIMARY_MOUSE_INTERACTION as InteractionType, + position: [400, 300] as [number, number], + } + + screenHandler.interactionEnd?.(endInteraction) + + cleanup() + }) + }) +}) diff --git a/fission/src/test/scene/GizmoSceneObject.test.ts b/fission/src/test/scene/GizmoSceneObject.test.ts new file mode 100644 index 0000000000..d383ea207f --- /dev/null +++ b/fission/src/test/scene/GizmoSceneObject.test.ts @@ -0,0 +1,208 @@ +import { expect, test, vi, beforeEach, describe, afterEach } from "vitest" +import GizmoSceneObject from "@/systems/scene/GizmoSceneObject" +import * as THREE from "three" +import MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import { RigidNodeId } from "@/mirabuf/MirabufParser" + +vi.mock("@/systems/World", () => ({ + default: { + sceneRenderer: { + mainCamera: { + fov: 75, + aspect: 1, + near: 0.1, + far: 1000, + position: { x: 0, y: 0, z: 5, distanceTo: vi.fn(() => 5) }, + }, + renderer: { domElement: {} }, + registerGizmoSceneObject: vi.fn(), + addObject: vi.fn(), + removeObject: vi.fn(), + isAnyGizmoDragging: vi.fn(() => false), + currentCameraControls: { enabled: true }, + gizmosOnMirabuf: new Map(), + }, + physicsSystem: { + getBody: vi.fn(() => ({ + /* eslint-disable @typescript-eslint/naming-convention */ + GetWorldTransform: vi.fn(() => ({ + GetTranslation: vi.fn(() => ({ + GetX: () => 0, + GetY: () => 0, + GetZ: () => 0, + })), + GetRotation: vi.fn(() => ({ + GetX: () => 0, + GetY: () => 0, + GetZ: () => 0, + GetW: () => 1, + })), + GetQuaternion: vi.fn(() => ({ + GetX: () => 0, + GetY: () => 0, + GetZ: () => 0, + GetW: () => 1, + })), + })), + /* eslint-enable @typescript-eslint/naming-convention */ + })), + setBodyPositionAndRotation: vi.fn(), + }, + }, +})) + +vi.mock("three/examples/jsm/controls/TransformControls.js", () => ({ + TransformControls: vi.fn().mockImplementation(() => ({ + setMode: vi.fn(), + getHelper: vi.fn(() => ({ + updateMatrixWorld: vi.fn(), + })), + setSpace: vi.fn(), + attach: vi.fn(), + detach: vi.fn(), + setSize: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dragging: false, + enabled: true, + translationSnap: null, + rotationSnap: null, + setScaleSnap: vi.fn(), + axis: "XYZ", + mode: "translate", + object: null, + })), +})) + +describe("GizmoSceneObject", () => { + let gizmoSceneObject: GizmoSceneObject + let mockMesh: THREE.Mesh + let mockParentObject: MirabufSceneObject + + beforeEach(() => { + vi.clearAllMocks() + + mockMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 0x00ff00 })) + + mockParentObject = { + id: "test-parent-id", + mirabufInstance: { + parser: { + rigidNodes: [{ id: "node1" as RigidNodeId }, { id: "node2" as RigidNodeId }], + }, + }, + mechanism: { + getBodyByNodeId: vi.fn(() => "mock-body-id"), + }, + disablePhysics: vi.fn(), + enablePhysics: vi.fn(), + updateMeshTransforms: vi.fn(), + } as unknown as MirabufSceneObject + + gizmoSceneObject = new GizmoSceneObject("translate", 1.0, mockMesh, mockParentObject) + gizmoSceneObject.setup() + }) + + afterEach(() => { + gizmoSceneObject?.dispose() + }) + + describe("setTransform()", () => { + test("should apply transformation with position, rotation, and scale correctly", () => { + const position = new THREE.Vector3(1, 2, 3) + const rotation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2) + const scale = new THREE.Vector3(1.5, 1.5, 1.5) + + const transform = new THREE.Matrix4().compose(position, rotation, scale) + + gizmoSceneObject.setTransform(transform) + + const extractedPos = new THREE.Vector3() + const extractedRot = new THREE.Quaternion() + const extractedScale = new THREE.Vector3() + + gizmoSceneObject.obj.matrix.decompose(extractedPos, extractedRot, extractedScale) + + expect(extractedPos.x).toBeCloseTo(position.x) + expect(extractedPos.y).toBeCloseTo(position.y) + expect(extractedPos.z).toBeCloseTo(position.z) + + expect(extractedScale.x).toBeCloseTo(scale.x) + expect(extractedScale.y).toBeCloseTo(scale.y) + expect(extractedScale.z).toBeCloseTo(scale.z) + + expect(extractedRot.x).toBeCloseTo(rotation.x) + expect(extractedRot.y).toBeCloseTo(rotation.y) + expect(extractedRot.z).toBeCloseTo(rotation.z) + expect(extractedRot.w).toBeCloseTo(rotation.w) + }) + }) + + describe("updateNodeTransform()", () => { + test("should handle missing parent gracefully", () => { + const noParentGizmo = new GizmoSceneObject("translate", 1.0, mockMesh) + const nodeId = "node1" as RigidNodeId + + expect(() => noParentGizmo.updateNodeTransform(nodeId)).not.toThrow() + + noParentGizmo.dispose() + }) + }) + + describe("update()", () => { + test("should update gizmo size and scale with camera distance", () => { + gizmoSceneObject.gizmo.object = mockMesh + + gizmoSceneObject.update() + const setSize = gizmoSceneObject.gizmo.setSize as ReturnType + const initialSize = setSize.mock.calls[0][0] + + const gizmoMainCamera = gizmoSceneObject["_mainCamera"] as unknown as { + position: { distanceTo: ReturnType } + } + gizmoMainCamera.position.distanceTo.mockReturnValue(10) + + setSize.mockClear() + gizmoSceneObject.update() + + const newSize = setSize.mock.calls[0][0] + + expect(newSize).toBeLessThan(initialSize) + }) + + test("should update node transforms for all rigid nodes when dragging", () => { + gizmoSceneObject.gizmo.dragging = true + gizmoSceneObject.gizmo.object = mockMesh + + const updateNodeTransformSpy = vi.spyOn(gizmoSceneObject, "updateNodeTransform") + + gizmoSceneObject.update() + + expect(updateNodeTransformSpy).toHaveBeenCalledWith("node1") + expect(updateNodeTransformSpy).toHaveBeenCalledWith("node2") + expect(updateNodeTransformSpy).toHaveBeenCalledTimes(2) + + updateNodeTransformSpy.mockRestore() + }) + + test("should disable physics if dragging", () => { + gizmoSceneObject.gizmo.dragging = true + gizmoSceneObject.gizmo.object = mockMesh + + gizmoSceneObject.update() + + expect(mockParentObject.disablePhysics).toHaveBeenCalled() + expect(mockParentObject.updateMeshTransforms).toHaveBeenCalled() + }) + + test("should handle gizmo without parent object gracefully", () => { + const noParentGizmo = new GizmoSceneObject("translate", 1.0, mockMesh) + noParentGizmo.setup() + noParentGizmo.gizmo.object = mockMesh + + expect(() => noParentGizmo.update()).not.toThrow() + + noParentGizmo.dispose() + }) + }) +}) diff --git a/fission/src/test/scene/Joystick.test.ts b/fission/src/test/scene/Joystick.test.ts new file mode 100644 index 0000000000..ec1c213ccc --- /dev/null +++ b/fission/src/test/scene/Joystick.test.ts @@ -0,0 +1,127 @@ +import { test, expect, describe, beforeEach, afterEach, vi } from "vitest" +import Joystick from "@/systems/scene/Joystick" +import { MAX_JOYSTICK_RADIUS } from "@/ui/components/TouchControls" + +describe("Joystick Tests", () => { + let joystick: Joystick + let mockBaseElement: HTMLElement + let mockStickElement: HTMLElement + let mockBoundingRect: DOMRect + + beforeEach(() => { + mockBaseElement = document.createElement("div") + mockStickElement = document.createElement("div") + + mockBoundingRect = { + left: 100, + top: 100, + right: 200, + bottom: 200, + width: 100, + height: 100, + x: 100, + y: 100, + toJSON: () => ({}), + } + + vi.spyOn(mockBaseElement, "getBoundingClientRect").mockReturnValue(mockBoundingRect) + + vi.spyOn(mockBaseElement, "addEventListener") + vi.spyOn(document, "addEventListener") + + joystick = new Joystick(mockBaseElement, mockStickElement) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + describe("Pointer Events", () => { + test("Should handle pointerdown event", () => { + const pointerDownEvent = new PointerEvent("pointerdown", { + clientX: 150 + MAX_JOYSTICK_RADIUS / 2, + clientY: 150 - MAX_JOYSTICK_RADIUS / 2, + pointerId: 1, + }) + + mockBaseElement.dispatchEvent(pointerDownEvent) + + expect(joystick.x).toBe(0.5) + expect(joystick.y).toBe(-0.5) + }) + + test("Should handle pointermove event", () => { + const pointerDownEvent = new PointerEvent("pointerdown", { + clientX: 150, + clientY: 150, + pointerId: 1, + }) + mockBaseElement.dispatchEvent(pointerDownEvent) + + const pointerMoveEvent = new PointerEvent("pointermove", { + clientX: 150 + MAX_JOYSTICK_RADIUS / 2, + clientY: 150 + MAX_JOYSTICK_RADIUS / 2, + pointerId: 1, + }) + document.dispatchEvent(pointerMoveEvent) + + expect(joystick.x).toBe(0.5) + expect(joystick.y).toBe(0.5) + }) + + test("Should ignore pointermove event from different pointer", () => { + const pointerDownEvent = new PointerEvent("pointerdown", { + clientX: 150, + clientY: 150, + pointerId: 1, + }) + mockBaseElement.dispatchEvent(pointerDownEvent) + + const pointerMoveEvent = new PointerEvent("pointermove", { + clientX: 150 + MAX_JOYSTICK_RADIUS / 2, + clientY: 150 + MAX_JOYSTICK_RADIUS / 2, + pointerId: 2, + }) + document.dispatchEvent(pointerMoveEvent) + + expect(joystick.x).toBe(0) + expect(joystick.y).toBe(0) + }) + + test("Should handle pointerup event and reset position", () => { + const pointerDownEvent = new PointerEvent("pointerdown", { + clientX: 150 + MAX_JOYSTICK_RADIUS / 2, + clientY: 150 + MAX_JOYSTICK_RADIUS / 2, + pointerId: 1, + }) + mockBaseElement.dispatchEvent(pointerDownEvent) + + expect(joystick.x).toBe(0.5) + expect(joystick.y).toBe(0.5) + + const pointerUpEvent = new PointerEvent("pointerup", { + pointerId: 1, + }) + document.dispatchEvent(pointerUpEvent) + + expect(joystick.x).toBe(0) + expect(joystick.y).toBe(0) + }) + }) + + describe("Edge Cases", () => { + test("Should constrain position within max radius", () => { + const mockPointerEvent = new PointerEvent("pointerdown", { + clientX: 150 + MAX_JOYSTICK_RADIUS * 2, + clientY: 150 - MAX_JOYSTICK_RADIUS * 2, + pointerId: 1, + }) + + mockBaseElement.dispatchEvent(mockPointerEvent) + + expect(joystick.x).toBeCloseTo(0.7, 1) + expect(joystick.y).toBeCloseTo(-0.7, 1) + }) + }) +}) diff --git a/fission/src/test/scene/SceneInteractionHandler.test.ts b/fission/src/test/scene/SceneInteractionHandler.test.ts new file mode 100644 index 0000000000..42f1e730da --- /dev/null +++ b/fission/src/test/scene/SceneInteractionHandler.test.ts @@ -0,0 +1,340 @@ +import { expect, test, vi, beforeEach, describe, afterEach } from "vitest" +import ScreenInteractionHandler, { + PRIMARY_MOUSE_INTERACTION, + SECONDARY_MOUSE_INTERACTION, +} from "../../systems/scene/ScreenInteractionHandler" + +describe("ScreenInteractionHandler", () => { + let handler: ScreenInteractionHandler + let mockElement: HTMLElement + let mockCallbacks: { + interactionStart: ReturnType + interactionMove: ReturnType + interactionEnd: ReturnType + contextMenu: ReturnType + } + + const getEventHandler = (eventType: string): ((event: unknown) => void) => { + const mockCalls = (mockElement.addEventListener as unknown as { mock: { calls: unknown[][] } }).mock.calls + const call = mockCalls.find((call: unknown[]) => call[0] === eventType) + return call?.[1] as (event: unknown) => void + } + + beforeEach(() => { + mockElement = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as HTMLElement + + Object.defineProperty(window, "innerWidth", { value: 1000, writable: true }) + Object.defineProperty(window, "innerHeight", { value: 600, writable: true }) + + handler = new ScreenInteractionHandler(mockElement) + + mockCallbacks = { + interactionStart: vi.fn(), + interactionMove: vi.fn(), + interactionEnd: vi.fn(), + contextMenu: vi.fn(), + } + + handler.interactionStart = mockCallbacks.interactionStart + handler.interactionMove = mockCallbacks.interactionMove + handler.interactionEnd = mockCallbacks.interactionEnd + handler.contextMenu = mockCallbacks.contextMenu + }) + + afterEach(() => { + handler.dispose() + vi.clearAllMocks() + }) + + test("constructor attaches event listeners", () => { + expect(mockElement.addEventListener).toHaveBeenCalledWith("pointermove", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("wheel", expect.any(Function), { passive: false }) + expect(mockElement.addEventListener).toHaveBeenCalledWith("contextmenu", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("pointerdown", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("pointerup", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("pointercancel", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("pointerleave", expect.any(Function)) + expect(mockElement.addEventListener).toHaveBeenCalledWith("touchmove", expect.any(Function)) + }) + + test("dispose removes event listeners", () => { + handler.dispose() + + expect(mockElement.removeEventListener).toHaveBeenCalledWith("pointermove", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("wheel", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("contextmenu", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("pointerdown", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("pointerup", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("pointercancel", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("pointerleave", expect.any(Function)) + expect(mockElement.removeEventListener).toHaveBeenCalledWith("touchmove", expect.any(Function)) + }) + + describe("Mouse interactions", () => { + test("handles left click", () => { + const mockPointerDown = { + pointerType: "mouse", + button: PRIMARY_MOUSE_INTERACTION, + clientX: 100, + clientY: 200, + pointerId: 1, + } as PointerEvent + + const mockPointerMove = { + pointerType: "mouse", + button: PRIMARY_MOUSE_INTERACTION, + clientX: 120, + clientY: 220, + movementX: 20, + movementY: 20, + pointerId: 1, + } as PointerEvent + + const mockPointerUp = { + pointerType: "mouse", + button: PRIMARY_MOUSE_INTERACTION, + clientX: 120, + clientY: 220, + pointerId: 1, + } as PointerEvent + + const pointerDownHandler = getEventHandler("pointerdown") + pointerDownHandler(mockPointerDown) + + const pointerMoveHandler = getEventHandler("pointermove") + pointerMoveHandler(mockPointerMove) + + const pointerUpHandler = getEventHandler("pointerup") + pointerUpHandler(mockPointerUp) + + expect(mockCallbacks.interactionStart).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + position: [100, 200], + }) + + expect(mockCallbacks.interactionMove).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + movement: [20, 20], + }) + + expect(mockCallbacks.interactionEnd).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + position: [120, 220], + }) + }) + + test("handles right-click context menu", () => { + const mockPointerDown = { + pointerType: "mouse", + button: SECONDARY_MOUSE_INTERACTION, + clientX: 100, + clientY: 200, + pointerId: 1, + } as PointerEvent + + const mockPointerUp = { + pointerType: "mouse", + button: SECONDARY_MOUSE_INTERACTION, + clientX: 100, + clientY: 200, + pointerId: 1, + } as PointerEvent + + const pointerDownHandler = getEventHandler("pointerdown") + pointerDownHandler(mockPointerDown) + + const pointerUpHandler = getEventHandler("pointerup") + pointerUpHandler(mockPointerUp) + + expect(mockCallbacks.interactionEnd).toHaveBeenCalledWith({ + interactionType: SECONDARY_MOUSE_INTERACTION, + position: [100, 200], + }) + }) + }) + + describe("Touch interactions", () => { + test("handles single touch interaction", () => { + const mockTouchDown = { + pointerType: "touch", + pointerId: 1, + clientX: 100, + clientY: 200, + width: 20, + height: 20, + } as PointerEvent + + const mockTouchUp = { + pointerType: "touch", + pointerId: 1, + clientX: 100, + clientY: 200, + width: 20, + height: 20, + } as PointerEvent + + const pointerDownHandler = getEventHandler("pointerdown") + const pointerUpHandler = getEventHandler("pointerup") + + pointerDownHandler(mockTouchDown) + expect(mockCallbacks.interactionStart).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + position: [100, 200], + }) + + pointerUpHandler(mockTouchUp) + expect(mockCallbacks.interactionEnd).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + position: [100, 200], + }) + }) + + test("handles double touch for pinch gestures", () => { + const mockFirstTouch = { + pointerType: "touch", + pointerId: 1, + clientX: 0, + clientY: 0, + width: 20, + height: 20, + } as PointerEvent + + const mockSecondTouch = { + pointerType: "touch", + pointerId: 2, + clientX: 300, + clientY: 400, + width: 20, + height: 20, + } as PointerEvent + + const pointerDownHandler = getEventHandler("pointerdown") + + pointerDownHandler(mockFirstTouch) + expect(mockCallbacks.interactionStart).toHaveBeenCalledWith({ + interactionType: PRIMARY_MOUSE_INTERACTION, + position: [0, 0], + }) + + pointerDownHandler(mockSecondTouch) + expect(mockCallbacks.interactionStart).toHaveBeenCalledWith({ + interactionType: SECONDARY_MOUSE_INTERACTION, + position: [300, 400], + }) + + expect(handler.pinchSeparation).toBe(500) + expect(handler.pinchPosition).toEqual([150, 200]) + }) + }) + + describe("Wheel interactions", () => { + test("handles wheel events", () => { + const mockWheelEvent = { + deltaY: 100, + ctrlKey: false, + } as WheelEvent + + const wheelHandler = getEventHandler("wheel") + wheelHandler(mockWheelEvent) + + expect(mockCallbacks.interactionMove).toHaveBeenCalledWith({ + interactionType: -1, + scale: 1.0, + }) + }) + + test("prevents default on ctrl+wheel", () => { + const mockWheelEvent = { + deltaY: 100, + ctrlKey: true, + preventDefault: vi.fn(), + } as unknown as WheelEvent + + const wheelHandler = getEventHandler("wheel") + wheelHandler(mockWheelEvent) + + expect(mockWheelEvent.preventDefault).toHaveBeenCalled() + expect(mockCallbacks.interactionMove).not.toHaveBeenCalled() + }) + }) + + describe("Update method for pinch gestures", () => { + test("dispatches pinch events during update", () => { + const mockFirstTouch = { + pointerType: "touch", + pointerId: 1, + clientX: 100, + clientY: 100, + width: 20, + height: 20, + } as PointerEvent + + const mockSecondTouch = { + pointerType: "touch", + pointerId: 2, + clientX: 200, + clientY: 200, + width: 20, + height: 20, + } as PointerEvent + + const pointerDownHandler = getEventHandler("pointerdown") + const pointerMoveHandler = getEventHandler("pointermove") + + pointerDownHandler(mockFirstTouch) + pointerDownHandler(mockSecondTouch) + + const mockFirstTouchMoved = { + ...mockFirstTouch, + clientX: 150, + clientY: 150, + movementX: 50, + movementY: 50, + } as PointerEvent + + const mockSecondTouchMoved = { + ...mockSecondTouch, + clientX: 250, + clientY: 250, + movementX: 50, + movementY: 50, + } as PointerEvent + + pointerMoveHandler(mockFirstTouchMoved) + pointerMoveHandler(mockSecondTouchMoved) + + handler.update(0.016) + + const mockFirstTouchPinched = { + ...mockFirstTouchMoved, + clientX: 160, + clientY: 160, + movementX: 10, + movementY: 10, + } as PointerEvent + + const mockSecondTouchPinched = { + ...mockSecondTouchMoved, + clientX: 240, + clientY: 240, + movementX: -10, + movementY: -10, + } as PointerEvent + + pointerMoveHandler(mockFirstTouchPinched) + pointerMoveHandler(mockSecondTouchPinched) + + handler.update(0.016) + + expect(mockCallbacks.interactionMove).toHaveBeenCalledWith( + expect.objectContaining({ + interactionType: SECONDARY_MOUSE_INTERACTION, + scale: expect.any(Number), + }) + ) + }) + }) +}) diff --git a/fission/src/test/scene/SceneRenderer.test.ts b/fission/src/test/scene/SceneRenderer.test.ts new file mode 100644 index 0000000000..16cd8e633b --- /dev/null +++ b/fission/src/test/scene/SceneRenderer.test.ts @@ -0,0 +1,434 @@ +import { expect, test, vi, beforeEach, describe, afterEach } from "vitest" +import SceneRenderer, { STANDARD_CAMERA_FOV_X, STANDARD_CAMERA_FOV_Y } from "@/systems/scene/SceneRenderer" +import * as THREE from "three" +import { Theme } from "@/ui/helpers/UseThemeHelpers" +import SceneObject from "@/systems/scene/SceneObject" +import GizmoSceneObject from "@/systems/scene/GizmoSceneObject" +import MirabufSceneObject from "@/mirabuf/MirabufSceneObject" +import { MiraType } from "@/mirabuf/MirabufLoader" +import JOLT from "@/util/loading/JoltSyncLoader" + +interface MockSceneObject { + dispose: ReturnType + update: ReturnType + setup: ReturnType + id: number +} + +vi.mock("three", async () => { + const actual = await vi.importActual("three") + return { + ...actual, + WebGLRenderer: vi.fn().mockImplementation(() => ({ + domElement: document.createElement("canvas"), + setSize: vi.fn(), + setClearColor: vi.fn(), + setPixelRatio: vi.fn(), + render: vi.fn(), + dispose: vi.fn(), + shadowMap: { + enabled: true, + type: actual.PCFSoftShadowMap, + }, + capabilities: { + maxTextureSize: 4096, + }, + getSize: vi.fn().mockReturnValue(new actual.Vector2(1920, 1080)), + })), + } +}) + +vi.mock("@/systems/World", () => ({ + default: { + sceneRenderer: { + mainCamera: { + position: { x: 0, y: 0, z: 5 }, + updateMatrixWorld: vi.fn(), + }, + pixelToWorldSpace: vi.fn(() => new THREE.Vector3(0, 0, 0)), + }, + physicsSystem: { + rayCast: vi.fn(() => null), + getBodyAssociation: vi.fn(() => null), + }, + }, +})) + +vi.mock("@/systems/scene/CameraControls", () => ({ + CustomOrbitControls: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + update: vi.fn(), + })), +})) + +vi.mock("@/systems/scene/ScreenInteractionHandler", () => ({ + default: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + update: vi.fn(), + contextMenu: null, + })), +})) + +vi.mock("postprocessing", () => ({ + EffectComposer: vi.fn().mockImplementation(() => ({ + addPass: vi.fn(), + render: vi.fn(), + dispose: vi.fn(), + })), + EffectPass: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + RenderPass: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + SMAAEffect: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + EdgeDetectionMode: { + COLOR: "COLOR", + }, +})) + +vi.mock("three/examples/jsm/csm/CSM.js", () => ({ + CSM: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + remove: vi.fn(), + update: vi.fn(), + setupMaterial: vi.fn(), + fade: true, + })), +})) + +Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1920, +}) + +Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 1080, +}) + +Object.defineProperty(window, "devicePixelRatio", { + writable: true, + configurable: true, + value: 1, +}) + +describe("SceneRenderer", () => { + let sceneRenderer: SceneRenderer + + beforeEach(() => { + vi.clearAllMocks() + sceneRenderer = new SceneRenderer() + }) + + afterEach(() => { + if (sceneRenderer) { + sceneRenderer.destroy() + } + }) + + describe("Scene Object Management", () => { + test("should setup and remove scene objects", () => { + const mockSceneObject: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + + const id = sceneRenderer.registerSceneObject(mockSceneObject as unknown as SceneObject) + + sceneRenderer.removeSceneObject(id) + expect(sceneRenderer.sceneObjects.has(id)).toBe(false) + expect(mockSceneObject.setup).toHaveBeenCalled() + expect(mockSceneObject.dispose).toHaveBeenCalled() + }) + + test("should remove all scene objects", () => { + const mockSceneObject1: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + const mockSceneObject2: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + + sceneRenderer.registerSceneObject(mockSceneObject1 as unknown as SceneObject) + sceneRenderer.registerSceneObject(mockSceneObject2 as unknown as SceneObject) + expect(sceneRenderer.sceneObjects.size).toBe(2) + + sceneRenderer.removeAllSceneObjects() + expect(sceneRenderer.sceneObjects.size).toBe(0) + expect(mockSceneObject1.dispose).toHaveBeenCalled() + expect(mockSceneObject2.dispose).toHaveBeenCalled() + }) + }) + + describe("Geometry Creation", () => { + test("should create sphere with custom material", () => { + const customMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) + const sphere = sceneRenderer.createSphere(1.0, customMaterial) + expect(sphere.material).toBe(customMaterial) + }) + + test("should create sphere with default material", () => { + const sphere = sceneRenderer.createSphere(1.0) + expect(sphere.material).toBeInstanceOf(THREE.MeshToonMaterial) + }) + + test("should create box with default material and correct position", () => { + const vec3 = new JOLT.Vec3(2, 3, 4) + + const box = sceneRenderer.createBox(vec3) + expect(box.material).toBeInstanceOf(THREE.MeshToonMaterial) + expect(box.geometry.attributes.position.array[0]).toBe(1) + expect(box.geometry.attributes.position.array[1]).toBe(1.5) + expect(box.geometry.attributes.position.array[2]).toBe(2) + }) + + test("should create toon material", () => { + const material = sceneRenderer.createToonMaterial(0xff0000, 3) + expect(material).toBeInstanceOf(THREE.MeshToonMaterial) + expect(material.color.getHex()).toBe(0xff0000) + }) + }) + + describe("Coordinate Conversion", () => { + test("should convert center screen to world space", () => { + const centerX = window.innerWidth / 2 + const centerY = window.innerHeight / 2 + const worldPos = sceneRenderer.pixelToWorldSpace(centerX, centerY) + + expect(worldPos.x).toBe(0) + expect(worldPos.y).toBe(0) + }) + + test("should convert world to pixel space", () => { + const worldPos = new THREE.Vector3(0, 0, 0) + + sceneRenderer.updateCanvasSize() + const pixelPos1920 = sceneRenderer.worldToPixelSpace(worldPos) + + Object.defineProperty(window, "innerWidth", { value: 800 }) + Object.defineProperty(window, "innerHeight", { value: 600 }) + sceneRenderer.updateCanvasSize() + + const pixelPos800 = sceneRenderer.worldToPixelSpace(worldPos) + expect(pixelPos800[0]).not.toBe(pixelPos1920[0]) + expect(pixelPos800[1]).not.toBe(pixelPos1920[1]) + + Object.defineProperty(window, "innerWidth", { value: 1920 }) + Object.defineProperty(window, "innerHeight", { value: 1080 }) + sceneRenderer.updateCanvasSize() + }) + }) + + describe("Canvas Management", () => { + test("should update camera aspect ratio based on window size", () => { + // Windows size is already set to 1920x1080 + sceneRenderer.updateCanvasSize() + + const aspectRatio = 1920 / 1080 + expect(sceneRenderer.mainCamera.aspect).toBeCloseTo(aspectRatio) + expect(sceneRenderer.mainCamera.fov).toBeCloseTo(STANDARD_CAMERA_FOV_X / aspectRatio) + }) + + test("should handle wide aspect ratios correctly", () => { + Object.defineProperty(window, "innerWidth", { value: 3840 }) + Object.defineProperty(window, "innerHeight", { value: 1080 }) + + sceneRenderer.updateCanvasSize() + + const aspectRatio = 3840 / 1080 + expect(sceneRenderer.mainCamera.aspect).toBeCloseTo(aspectRatio) + + expect(sceneRenderer.mainCamera.fov).toBeCloseTo(STANDARD_CAMERA_FOV_X / aspectRatio) + }) + + test("should handle tall aspect ratios correctly", () => { + Object.defineProperty(window, "innerWidth", { value: 800 }) + Object.defineProperty(window, "innerHeight", { value: 1200 }) + + sceneRenderer.updateCanvasSize() + + expect(sceneRenderer.mainCamera.aspect).toBeCloseTo(800 / 1200) + expect(sceneRenderer.mainCamera.fov).toBeCloseTo(STANDARD_CAMERA_FOV_Y) + }) + }) + + describe("Lighting", () => { + test("should switch between directional and CSM lighting modes", () => { + sceneRenderer.changeLighting(false) + const directionalLight = sceneRenderer.scene.children.find(child => child instanceof THREE.DirectionalLight) + expect(directionalLight).toBeInstanceOf(THREE.DirectionalLight) + + sceneRenderer.changeLighting(true) + const noDirectionalLight = sceneRenderer.scene.children.find( + child => child instanceof THREE.DirectionalLight + ) + expect(noDirectionalLight).toBeUndefined() + + sceneRenderer.changeLighting(false) + const newDirectionalLight = sceneRenderer.scene.children.find( + child => child instanceof THREE.DirectionalLight + ) + expect(newDirectionalLight).toBeInstanceOf(THREE.DirectionalLight) + }) + + test("should handle null material gracefully", () => { + sceneRenderer.changeLighting(true) + + expect(() => sceneRenderer.setupMaterial(null as unknown as THREE.Material)).not.toThrow() + }) + }) + + describe("Skybox", () => { + test("should update skybox colors", () => { + const mockTheme: Partial = { + Background: { + color: { + r: 0.5, + g: 0.7, + b: 0.9, + a: 1.0, + }, + above: [], + }, + } + + sceneRenderer.updateSkyboxColors(mockTheme as unknown as Theme) + + // Find the skybox in the scene + const skybox = sceneRenderer.scene.children.find( + child => + child instanceof THREE.Mesh && + child.material instanceof THREE.ShaderMaterial && + child.geometry instanceof THREE.SphereGeometry + ) as THREE.Mesh + + expect(skybox.material.uniforms.rColor.value).toBe(0.5) + expect(skybox.material.uniforms.gColor.value).toBe(0.7) + expect(skybox.material.uniforms.bColor.value).toBe(0.9) + }) + }) + + describe("Gizmo Management", () => { + test("should register gizmos with parents", () => { + const mockGizmo = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + hasParent: vi.fn().mockReturnValue(true), + parentObjectId: 123, + gizmo: { dragging: false }, + } + + sceneRenderer.registerGizmoSceneObject(mockGizmo as unknown as GizmoSceneObject) + + expect(sceneRenderer.gizmosOnMirabuf.has(123)).toBe(true) + expect(sceneRenderer.gizmosOnMirabuf.get(123)).toBe(mockGizmo) + }) + + test("should not register gizmos without parents", () => { + const mockGizmo = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + hasParent: vi.fn().mockReturnValue(false), + parentObjectId: undefined, + gizmo: { dragging: false }, + } + + const initialMapSize = sceneRenderer.gizmosOnMirabuf.size + + sceneRenderer.registerGizmoSceneObject(mockGizmo as unknown as GizmoSceneObject) + + expect(sceneRenderer.gizmosOnMirabuf.size).toBe(initialMapSize) + }) + }) + + describe("Update Loop", () => { + test("should update all scene objects", () => { + const mockSceneObject1: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + const mockSceneObject2: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + + sceneRenderer.registerSceneObject(mockSceneObject1 as unknown as SceneObject) + sceneRenderer.registerSceneObject(mockSceneObject2 as unknown as SceneObject) + expect(sceneRenderer.sceneObjects.size).toBe(2) + + sceneRenderer.update(0.016) + + expect(mockSceneObject1.update).toHaveBeenCalledTimes(1) + expect(mockSceneObject2.update).toHaveBeenCalledTimes(1) + expect(sceneRenderer.currentCameraControls.update).toHaveBeenCalledWith(0.016) + expect(sceneRenderer.screenInteractionHandler.update).toHaveBeenCalledWith(0.016) + }) + }) + + describe("Camera Controls", () => { + test("should set camera controls", () => { + const initialControls = sceneRenderer.currentCameraControls + + sceneRenderer.setCameraControls("Orbit") + + expect(initialControls.dispose).toHaveBeenCalled() + expect(sceneRenderer.currentCameraControls).toBeDefined() + expect(sceneRenderer.currentCameraControls).not.toBe(initialControls) + }) + }) + + describe("Field Management", () => { + test("should not remove non-field objects when calling removeAllFields", () => { + // Mock regular scene object - should not be removed + const mockSceneObject: MockSceneObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + } + + // Mock robot object - should not be removed + const mockRobotObject = { + dispose: vi.fn(), + update: vi.fn(), + setup: vi.fn(), + id: 0, + miraType: MiraType.ROBOT, + } + + const sceneObjectId = sceneRenderer.registerSceneObject(mockSceneObject as unknown as SceneObject) + const robotObjectId = sceneRenderer.registerSceneObject(mockRobotObject as unknown as MirabufSceneObject) + + expect(sceneRenderer.sceneObjects.size).toBe(2) + + sceneRenderer.removeAllFields() + + // Both objects should still be there - neither should be disposed + expect(sceneRenderer.sceneObjects.size).toBe(2) + expect(sceneRenderer.sceneObjects.has(sceneObjectId)).toBe(true) + expect(sceneRenderer.sceneObjects.has(robotObjectId)).toBe(true) + + expect(mockSceneObject.dispose).not.toHaveBeenCalled() + expect(mockRobotObject.dispose).not.toHaveBeenCalled() + }) + }) +})