aether-shards/test/core/InputManager.test.js

208 lines
6.4 KiB
JavaScript

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);
});
});