import { expect } from "@esm-bundle/chai"; import sinon from "sinon"; import * as THREE from "three"; import { InputManager } from "../../src/core/InputManager.js"; describe("Core: InputManager", () => { let inputManager; let camera; let scene; let domElement; let originalGetGamepads; beforeEach(() => { camera = new THREE.PerspectiveCamera(); scene = new THREE.Scene(); // Mock DOM Element domElement = { getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100, }), addEventListener: sinon.spy(), removeEventListener: sinon.spy(), }; inputManager = new InputManager(camera, scene, domElement); // Save original navigator.getGamepads to restore later originalGetGamepads = navigator.getGamepads; }); afterEach(() => { inputManager.detach(); // Restore original navigator function navigator.getGamepads = originalGetGamepads; }); it("CoA 1: Should attach event listeners on creation", () => { expect(domElement.addEventListener.calledWith("mousemove")).to.be.true; expect(domElement.addEventListener.calledWith("click")).to.be.true; }); it("CoA 2: Should calculate normalized mouse coordinates", () => { // Simulate mouse event at center (50, 50) const event = { clientX: 50, clientY: 50 }; inputManager.updateMouseCoords(event); // Center should be (0, 0) in NDC expect(inputManager.mouse.x).to.equal(0); expect(inputManager.mouse.y).to.equal(0); }); it("CoA 3: Raycast should identify voxel coordinates based on intersection", () => { // Mock Raycaster intersection result // Use (0, 0.5, 0) which is the exact center of the top face of a block at (0,0,0) // 1x1x1 box at 0,0,0 extends from -0.5 to 0.5 const mockIntersection = { object: { isInstancedMesh: true }, point: new THREE.Vector3(0, 0.5, 0), face: { normal: new THREE.Vector3(0, 1, 0) }, // Up normal }; // Stub the internal raycaster call sinon .stub(inputManager.raycaster, "intersectObjects") .returns([mockIntersection]); const result = inputManager.raycast(); expect(result).to.exist; // Hit Logic: 0.5 + (-0.5 * 1) = 0. // Block hit is at 0,0,0 expect(result.hitPosition.x).to.equal(0); expect(result.hitPosition.y).to.equal(0); expect(result.hitPosition.z).to.equal(0); // Target Logic: 0 + 1 = 1 // Voxel Position (Target) should be 0,1,0 (Above the floor) expect(result.voxelPosition.x).to.equal(0); expect(result.voxelPosition.y).to.equal(1); expect(result.voxelPosition.z).to.equal(0); }); it("CoA 4: Mouse move should update cursor and emit hover", () => { const hoverSpy = sinon.spy(); inputManager.addEventListener("hover", hoverSpy); // Stub raycast to return a hit sinon.stub(inputManager, "raycast").returns({ voxelPosition: new THREE.Vector3(1, 1, 1), }); // Simulate Move inputManager.onMouseMove({ clientX: 0, clientY: 0 }); expect(inputManager.cursor.visible).to.be.true; expect(inputManager.cursor.position.x).to.equal(1); expect(hoverSpy.called).to.be.true; }); it("CoA 5: Mouse click should emit click event with coords", () => { const clickSpy = sinon.spy(); inputManager.addEventListener("click", clickSpy); // Use the API method to ensure internal state (cursorPos) is synced with Mesh // Directly setting mesh.position might desync the internal tracker used for the event payload inputManager.setCursor(2, 2, 2); // Simulate Click inputManager.onMouseClick({}); expect(clickSpy.calledOnce).to.be.true; const payload = clickSpy.firstCall.args[0].detail; // Access detail expect(payload.x).to.equal(2); expect(payload.y).to.equal(2); expect(payload.z).to.equal(2); }); it("CoA 6: Keyboard events should be tracked and emitted", () => { const keySpy = sinon.spy(); inputManager.addEventListener("keydown", keySpy); // Dispatch window event const event = new KeyboardEvent("keydown", { code: "KeyW" }); window.dispatchEvent(event); expect(keySpy.called).to.be.true; expect(keySpy.firstCall.args[0].detail).to.equal("KeyW"); expect(inputManager.isKeyPressed("KeyW")).to.be.true; // Key Up const upEvent = new KeyboardEvent("keyup", { code: "KeyW" }); window.dispatchEvent(upEvent); expect(inputManager.isKeyPressed("KeyW")).to.be.false; }); it("CoA 7: Gamepad button press should emit events", () => { const buttonSpy = sinon.spy(); inputManager.addEventListener("gamepadbuttondown", buttonSpy); // Mock Gamepad State const mockGamepad = { index: 0, buttons: [{ pressed: true }], // Button 0 pressed axes: [0, 0], }; // Mock Navigator directly on the window object (browser environment) // Note: Some browsers make navigator read-only, but usually this works in test runners. // If strict, we might need Object.defineProperty Object.defineProperty(navigator, "getGamepads", { value: () => [mockGamepad], writable: true, }); // Trigger Poll inputManager.update(); expect(buttonSpy.calledOnce).to.be.true; expect(buttonSpy.firstCall.args[0].detail).to.deep.equal({ gamepadIndex: 0, buttonIndex: 0, }); }); it("CoA 8: Gamepad axis movement should emit events with deadzone", () => { const axisSpy = sinon.spy(); inputManager.addEventListener("gamepadaxis", axisSpy); // Mock Gamepad State const mockGamepad = { index: 0, buttons: [], axes: [0.5, 0.05], // Axis 0 moved, Axis 1 inside deadzone (0.15) }; Object.defineProperty(navigator, "getGamepads", { value: () => [mockGamepad], writable: true, }); inputManager.update(); expect(axisSpy.calledOnce).to.be.true; // Only Axis 0 should trigger expect(axisSpy.firstCall.args[0].detail.value).to.equal(0.5); }); it("CoA 9: setCursor should prevent movement to invalid locations if validator provided", () => { // Setup a validator that only allows positive X values inputManager.setValidator((x, y, z) => { return x >= 0; }); // Attempt valid move inputManager.setCursor(5, 1, 5); expect(inputManager.cursor.position.x).to.equal(5); // Attempt invalid move (blocked by validator) inputManager.setCursor(-1, 1, 5); // Should stay at previous valid position expect(inputManager.cursor.position.x).to.equal(5); }); });