diff --git a/api/animate.js b/api/animate.js index 32bb8d20f..6a4b74f9d 100644 --- a/api/animate.js +++ b/api/animate.js @@ -136,10 +136,10 @@ export const flockAnimate = { easing = "Linear", } = {}, ) { + const rawDuration = Number(duration); duration = - Number.isFinite(Number(duration)) && Number(duration) > 0 - ? Number(duration) - : 1; + Number.isFinite(rawDuration) && rawDuration > 0 ? rawDuration : 1; + const instant = Number.isFinite(rawDuration) && rawDuration === 0; x = Number.isFinite(Number(x)) ? Number(x) : 0; y = Number.isFinite(Number(y)) ? Number(y) : 0; z = Number.isFinite(Number(z)) ? Number(z) : 0; @@ -151,6 +151,27 @@ export const flockAnimate = { return; } + if (instant) { + const targetRotation = new flock.BABYLON.Vector3( + x * (Math.PI / 180), + y * (Math.PI / 180), + z * (Math.PI / 180), + ); + mesh.rotation = targetRotation; + mesh.computeWorldMatrix(true); + + if (mesh.physics && mesh.physics._pluginData?.hpBodyId) { + mesh.physics.setTargetTransform( + mesh.absolutePosition, + mesh.absoluteRotationQuaternion || + flock.BABYLON.Quaternion.FromEulerVector(mesh.rotation), + ); + } + + resolve(); + return; + } + const children = mesh.getChildMeshes(); const childData = children.map((c) => ({ diff --git a/api/csg.js b/api/csg.js index cb6c064e0..69b6ab74c 100644 --- a/api/csg.js +++ b/api/csg.js @@ -312,7 +312,7 @@ export const flockCSG = { mergedMesh.metadata.blockKey = blockId; mergedMesh.metadata.sharedMaterial = false; - return mergedMesh; + return modelId; } const originalMaterial = referenceMesh.material; diff --git a/api/ui.js b/api/ui.js index 7b4eead7f..0b4139540 100644 --- a/api/ui.js +++ b/api/ui.js @@ -342,7 +342,6 @@ export const flockUI = { button.height = `${size}px`; button.color = color; button.background = "transparent"; - button.thickness = 0; button.fontSize = `${40 * flock.displayScale}px`; button.fontFamily = fontFamily; @@ -374,11 +373,6 @@ export const flockUI = { grid.height = `${160 * flock.displayScale}px`; grid.horizontalAlignment = flock.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT; grid.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; - - // Position it slightly away from the screen edge - grid.left = "20px"; - grid.top = "-20px"; - grid.addRowDefinition(1); grid.addRowDefinition(1); grid.addColumnDefinition(1); @@ -396,14 +390,6 @@ export const flockUI = { color, ); - // Add padding so buttons don't overlap touch areas - [upButton, downButton, leftButton, rightButton].forEach((b) => { - b.paddingTop = "4px"; - b.paddingBottom = "4px"; - b.paddingLeft = "4px"; - b.paddingRight = "4px"; - }); - grid.addControl(upButton, 0, 1); grid.addControl(leftButton, 1, 0); grid.addControl(downButton, 1, 1); @@ -418,11 +404,6 @@ export const flockUI = { rightGrid.horizontalAlignment = flock.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; rightGrid.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; - - // Position it away from the edge - rightGrid.left = "-20px"; - rightGrid.top = "-20px"; - rightGrid.addRowDefinition(1); rightGrid.addRowDefinition(1); rightGrid.addColumnDefinition(1); @@ -435,103 +416,6 @@ export const flockUI = { const button3 = flock.createSmallButton("③", "f", color); const button4 = flock.createSmallButton("④", " ", color); - [button1, button2, button3, button4].forEach((b) => { - b.paddingTop = "4px"; - b.paddingBottom = "4px"; - b.paddingLeft = "4px"; - b.paddingRight = "4px"; - }); - - rightGrid.addControl(button1, 0, 0); - rightGrid.addControl(button2, 0, 1); - rightGrid.addControl(button3, 1, 0); - rightGrid.addControl(button4, 1, 1); - }, - createSmallButton(text, keys, color) { - if (!flock.controlsTexture) return; - - const keyList = Array.isArray(keys) ? keys : [keys]; - - const buttonId = `small-${text}-${Math.random().toString(36).slice(2)}`; - const button = flock.GUI.Button.CreateSimpleButton(buttonId, text); - button.width = `${70 * flock.displayScale}px`; - button.height = `${70 * flock.displayScale}px`; - button.color = color; - button.background = "transparent"; - button.fontSize = `${40 * flock.displayScale}px`; - - button.fontFamily = fontFamily; - - button.onPointerDownObservable.add(() => { - keyList.forEach((key) => { - if (key == null) return; - flock.canvas.pressedButtons.add(key); - flock.gridKeyPressObservable.notifyObservers(key); - }); - }); - - const releaseAction = () => { - keyList.forEach((key) => { - if (key == null) return; - flock.canvas.pressedButtons.delete(key); - flock.gridKeyReleaseObservable.notifyObservers(key); - }); - }; - - button.onPointerUpObservable.add(releaseAction); - button.onPointerOutObservable.add(releaseAction); - - return button; - }, - createArrowControls(color) { - if (!flock.controlsTexture) return; - - const grid = new flock.GUI.Grid(); - grid.width = `${240 * flock.displayScale}px`; - grid.height = `${160 * flock.displayScale}px`; - grid.horizontalAlignment = flock.GUI.Control.HORIZONTAL_ALIGNMENT_LEFT; - grid.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; - grid.addRowDefinition(1); - grid.addRowDefinition(1); - grid.addColumnDefinition(1); - grid.addColumnDefinition(1); - grid.addColumnDefinition(1); - flock.controlsTexture.addControl(grid); - - const upButton = flock.createSmallButton("△", ["w", "ArrowUp"], color); - const downButton = flock.createSmallButton("▽", ["s", "ArrowDown"], color); - const leftButton = flock.createSmallButton("◁", ["a", "ArrowLeft"], color); - const rightButton = flock.createSmallButton( - "▷", - ["d", "ArrowRight"], - color, - ); - - grid.addControl(upButton, 0, 1); - grid.addControl(leftButton, 1, 0); - grid.addControl(downButton, 1, 1); - grid.addControl(rightButton, 1, 2); - }, - createButtonControls(color) { - if (!flock.controlsTexture) return; - - const rightGrid = new flock.GUI.Grid(); - rightGrid.width = `${160 * flock.displayScale}px`; - rightGrid.height = `${160 * flock.displayScale}px`; - rightGrid.horizontalAlignment = - flock.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT; - rightGrid.verticalAlignment = flock.GUI.Control.VERTICAL_ALIGNMENT_BOTTOM; - rightGrid.addRowDefinition(1); - rightGrid.addRowDefinition(1); - rightGrid.addColumnDefinition(1); - rightGrid.addColumnDefinition(1); - flock.controlsTexture.addControl(rightGrid); - - const button1 = flock.createSmallButton("①", "e", color); - const button2 = flock.createSmallButton("②", "r", color); - const button3 = flock.createSmallButton("③", "f", color); - const button4 = flock.createSmallButton("④", " ", color); - rightGrid.addControl(button1, 0, 0); rightGrid.addControl(button2, 0, 1); rightGrid.addControl(button3, 1, 0); diff --git a/api/xr.js b/api/xr.js index 315e25f0c..b10fda7dc 100644 --- a/api/xr.js +++ b/api/xr.js @@ -4,71 +4,6 @@ export function setFlockReference(ref) { flock = ref; } -const rumblePatterns = { - objectGrab: [ - { duration: 40, weakMagnitude: 0.1, strongMagnitude: 0.8, pauseAfter: 30 }, - { duration: 25, weakMagnitude: 0.1, strongMagnitude: 0.4, pauseAfter: 0 }, - ], - objectDrop: [ - { duration: 60, weakMagnitude: 0.3, strongMagnitude: 1.0, pauseAfter: 20 }, - { duration: 30, weakMagnitude: 0.1, strongMagnitude: 0.4, pauseAfter: 0 }, - ], - smallCollision: [ - { duration: 30, weakMagnitude: 0.5, strongMagnitude: 0.2, pauseAfter: 40 }, - { duration: 20, weakMagnitude: 0.2, strongMagnitude: 0.1, pauseAfter: 0 }, - ], - heavyCollision: [ - { duration: 100, weakMagnitude: 0.5, strongMagnitude: 1.0, pauseAfter: 30 }, - { duration: 60, weakMagnitude: 0.3, strongMagnitude: 0.7, pauseAfter: 40 }, - { duration: 30, weakMagnitude: 0.1, strongMagnitude: 0.3, pauseAfter: 0 }, - ], - snapToGrid: [ - { duration: 20, weakMagnitude: 0.0, strongMagnitude: 0.9, pauseAfter: 25 }, - { duration: 20, weakMagnitude: 0.0, strongMagnitude: 0.9, pauseAfter: 0 }, - ], - errorInvalid: [ - { duration: 50, weakMagnitude: 0.9, strongMagnitude: 0.1, pauseAfter: 25 }, - { duration: 40, weakMagnitude: 0.7, strongMagnitude: 0.1, pauseAfter: 20 }, - { duration: 50, weakMagnitude: 0.9, strongMagnitude: 0.1, pauseAfter: 30 }, - { duration: 30, weakMagnitude: 0.5, strongMagnitude: 0.0, pauseAfter: 0 }, - ], - successConfirmation: [ - { duration: 30, weakMagnitude: 0.1, strongMagnitude: 0.3, pauseAfter: 40 }, - { duration: 40, weakMagnitude: 0.2, strongMagnitude: 0.6, pauseAfter: 40 }, - { duration: 50, weakMagnitude: 0.3, strongMagnitude: 0.9, pauseAfter: 0 }, - ], - slidingGravel: [ - { duration: 40, weakMagnitude: 0.6, strongMagnitude: 0.2, pauseAfter: 25 }, - { duration: 25, weakMagnitude: 0.4, strongMagnitude: 0.1, pauseAfter: 20 }, - { duration: 50, weakMagnitude: 0.7, strongMagnitude: 0.3, pauseAfter: 30 }, - { duration: 30, weakMagnitude: 0.4, strongMagnitude: 0.1, pauseAfter: 20 }, - { duration: 45, weakMagnitude: 0.6, strongMagnitude: 0.2, pauseAfter: 0 }, - ], - slidingMetal: [ - { duration: 90, weakMagnitude: 0.05, strongMagnitude: 0.4, pauseAfter: 25 }, - { duration: 70, weakMagnitude: 0.05, strongMagnitude: 0.25, pauseAfter: 0 }, - ], - machineRunning: [ - { duration: 60, weakMagnitude: 0.2, strongMagnitude: 0.5, pauseAfter: 30 }, - { duration: 60, weakMagnitude: 0.2, strongMagnitude: 0.5, pauseAfter: 30 }, - { duration: 60, weakMagnitude: 0.2, strongMagnitude: 0.5, pauseAfter: 30 }, - { duration: 60, weakMagnitude: 0.2, strongMagnitude: 0.5, pauseAfter: 0 }, - ], - explosion: [ - { duration: 120, weakMagnitude: 0.8, strongMagnitude: 1.0, pauseAfter: 20 }, - { duration: 80, weakMagnitude: 0.6, strongMagnitude: 0.8, pauseAfter: 30 }, - { duration: 60, weakMagnitude: 0.3, strongMagnitude: 0.5, pauseAfter: 40 }, - { duration: 40, weakMagnitude: 0.1, strongMagnitude: 0.2, pauseAfter: 0 }, - ], - teleport: [ - { duration: 30, weakMagnitude: 0.1, strongMagnitude: 0.2, pauseAfter: 20 }, - { duration: 50, weakMagnitude: 0.3, strongMagnitude: 0.5, pauseAfter: 20 }, - { duration: 70, weakMagnitude: 0.5, strongMagnitude: 0.9, pauseAfter: 20 }, - { duration: 50, weakMagnitude: 0.3, strongMagnitude: 0.5, pauseAfter: 20 }, - { duration: 30, weakMagnitude: 0.1, strongMagnitude: 0.2, pauseAfter: 0 }, - ], -}; - export const flockXR = { /* Category: Scene>XR @@ -106,54 +41,6 @@ export const flockXR = { }, ); }, - controllerRumble(motor, strength, duration) { - const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; - for (const gamepad of gamepads) { - if (!gamepad || !gamepad.vibrationActuator) continue; - const weakMagnitude = motor === "left" ? 0 : strength; - const strongMagnitude = motor === "right" ? 0 : strength; - gamepad.vibrationActuator.playEffect("dual-rumble", { - startDelay: 0, - duration: duration, - weakMagnitude: weakMagnitude, - strongMagnitude: strongMagnitude, - }); - } - }, - controllerRumblePattern(motor, strength, onDuration, offDuration, repeats) { - const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; - for (const gamepad of gamepads) { - if (!gamepad || !gamepad.vibrationActuator) continue; - const weakMagnitude = motor === "left" ? 0 : strength; - const strongMagnitude = motor === "right" ? 0 : strength; - for (let i = 0; i < repeats; i++) { - gamepad.vibrationActuator.playEffect("dual-rumble", { - startDelay: i * (onDuration + offDuration), - duration: onDuration, - weakMagnitude: weakMagnitude, - strongMagnitude: strongMagnitude, - }); - } - } - }, - playRumblePattern(patternName) { - const pattern = rumblePatterns[patternName]; - if (!pattern) return; - const gamepads = navigator.getGamepads ? navigator.getGamepads() : []; - for (const gamepad of gamepads) { - if (!gamepad || !gamepad.vibrationActuator) continue; - let startDelay = 0; - for (const pulse of pattern) { - gamepad.vibrationActuator.playEffect("dual-rumble", { - startDelay, - duration: pulse.duration, - weakMagnitude: pulse.weakMagnitude, - strongMagnitude: pulse.strongMagnitude, - }); - startDelay += pulse.duration + pulse.pauseAfter; - } - } - }, async setXRMode(mode) { await flock.initializeXR(mode); flock.printText({ diff --git a/flock.js b/flock.js index 791444872..fe68b74d1 100644 --- a/flock.js +++ b/flock.js @@ -977,7 +977,6 @@ export const flock = { makeFollow: this.makeFollow?.bind(this), stopFollow: this.stopFollow?.bind(this), removeParent: this.removeParent?.bind(this), - createGround: this.createGround?.bind(this), createMap: this.createMap?.bind(this), setSky: this.setSky?.bind(this), lightIntensity: this.lightIntensity?.bind(this), @@ -988,9 +987,6 @@ export const flock = { cameraControl: this.cameraControl?.bind(this), setCameraBackground: this.setCameraBackground?.bind(this), setXRMode: this.setXRMode?.bind(this), - controllerRumble: this.controllerRumble?.bind(this), - controllerRumblePattern: this.controllerRumblePattern?.bind(this), - playRumblePattern: this.playRumblePattern?.bind(this), applyForce: this.applyForce?.bind(this), moveByVector: this.moveByVector?.bind(this), glideTo: this.glideTo?.bind(this), diff --git a/playwright.config.js b/playwright.config.js index 7616a973a..c1bd37e14 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -8,7 +8,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: "html", use: { - baseURL: "http://localhost:5173", + baseURL: "http://127.0.0.1:5173", trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", @@ -22,7 +22,7 @@ export default defineConfig({ ], webServer: { command: "npm run dev", - port: 5173, + url: "http://127.0.0.1:5173", reuseExistingServer: !process.env.CI, }, }); diff --git a/scripts/run-api-tests.mjs b/scripts/run-api-tests.mjs index 6f63429b2..fa25f65d0 100644 --- a/scripts/run-api-tests.mjs +++ b/scripts/run-api-tests.mjs @@ -51,6 +51,10 @@ const AVAILABLE_SUITES = [ pattern: "@diagnostic", }, { id: "physics", name: "Physics Tests (6 tests)", pattern: "@physics" }, + { id: "sound2", name: "Sound2 Tests (BPM and speech)", pattern: "@sound2" }, + { id: "camera", name: "Camera API Tests", pattern: "@camera" }, + { id: "control", name: "Control API Tests", pattern: "@control" }, + { id: "xr", name: "XR API Tests", pattern: "@xr" }, { id: "materials", name: "Materials Tests (22 tests)", @@ -110,11 +114,31 @@ const AVAILABLE_SUITES = [ name: "XR Export Tests", pattern: "XR exportMesh GLB tests", }, + { + id: "meshhierarchy", + name: "Mesh Hierarchy Tests", + pattern: "@meshhierarchy", + }, { id: "characterAnimations", name: "Character Animation API", pattern: "Character Animation API", }, + { + id: "math", + name: "Math API Tests", + pattern: "@math", + }, + { + id: "shapes", + name: "Shapes API Tests", + pattern: "@shapes", + }, + { + id: "sensing", + name: "Sensing API Tests", + pattern: "@sensing", + }, ]; const args = process.argv.slice(2); @@ -250,7 +274,14 @@ async function checkServerHealth(url, maxAttempts = 30) { /** * Start the Vite development server */ -function startServer() { +async function startServer() { + const alreadyRunning = await checkServerHealth("http://127.0.0.1:5173", 3); + if (alreadyRunning) { + console.log("✅ Reusing existing development server\n"); + serverReady = true; + return; + } + return new Promise((resolve, reject) => { console.log("🚀 Starting development server..."); @@ -327,7 +358,7 @@ function startServer() { async function verifyServerWithHealthCheck() { console.log(" 🔍 Verifying server is responsive..."); - const isHealthy = await checkServerHealth("http://localhost:5173", 10); + const isHealthy = await checkServerHealth("http://127.0.0.1:5173", 10); if (isHealthy) { console.log("✅ Development server started and responding\n"); @@ -367,7 +398,7 @@ function startServer() { ); } - const isHealthy = await checkServerHealth("http://localhost:5173", 20); + const isHealthy = await checkServerHealth("http://127.0.0.1:5173", 20); if (isHealthy) { serverReady = true; @@ -477,7 +508,7 @@ async function runTests(suiteId = "all") { // Navigate to test page console.log("📄 Loading test page..."); - await page.goto("http://localhost:5173/tests/tests.html", { + await page.goto("http://127.0.0.1:5173/tests/tests.html", { waitUntil: "networkidle", timeout: 60000, }); diff --git a/tests/animate.test.js b/tests/animate.test.js index e24becc08..d8c4cfb84 100644 --- a/tests/animate.test.js +++ b/tests/animate.test.js @@ -959,5 +959,32 @@ export function runAnimateTests(flock) { expect(endTime - startTime).to.be.lessThan(100); }); }); + + describe("rotateToObject", function () { + it("should rotate mesh1 to face mesh2", async function () { + const id1 = "rotateToObjA"; + const id2 = "rotateToObjB"; + await flock.createBox(id1, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(id2, { + width: 1, + height: 1, + depth: 1, + position: [5, 0, 0], + }); + boxIds.push(id1, id2); + + const mesh1 = flock.scene.getMeshByName(id1); + const initialRotationY = mesh1.rotation.y; + + await flock.rotateToObject(id1, id2, { duration: 0 }); + + expect(mesh1.rotation.y).to.not.be.closeTo(initialRotationY, 0.001); + }); + }); }); } diff --git a/tests/buttoncontrols.test.js b/tests/buttoncontrols.test.js new file mode 100644 index 000000000..7b2acf01a --- /dev/null +++ b/tests/buttoncontrols.test.js @@ -0,0 +1,214 @@ +import { expect } from "chai"; + +export function runButtonControlsTests(flock) { + describe("buttonControls function tests", function () { + beforeEach(function () { + flock.canvas ??= {}; + flock.canvas.pressedButtons ??= new Set(); + flock.displayScale ??= 1; + flock.gridKeyPressObservable ??= new flock.BABYLON.Observable(); + flock.gridKeyReleaseObservable ??= new flock.BABYLON.Observable(); + }); + + afterEach(function () { + if (flock.controlsTexture) { + flock.controlsTexture.dispose(); + flock.controlsTexture = null; + } + flock.canvas.pressedButtons.clear(); + }); + + // buttonControls tests + describe("buttonControls function tests", function () { + it('should create controlsTexture when mode is "ENABLED"', function () { + flock.buttonControls("BOTH", "ENABLED"); + expect(flock.controlsTexture).to.exist; + }); + + it('should not create controlsTexture when mode is "DISABLED"', function () { + flock.buttonControls("BOTH", "DISABLED"); + expect(flock.controlsTexture).to.be.null; + }); + + it('should dispose an existing controlsTexture when mode is "DISABLED"', function () { + flock.buttonControls("BOTH", "ENABLED"); + const first = flock.controlsTexture; + expect(first).to.exist; + flock.buttonControls("BOTH", "DISABLED"); + expect(flock.controlsTexture).to.be.null; + }); + + it('should add arrow controls when control is "ARROWS"', function () { + flock.buttonControls("ARROWS", "ENABLED"); + expect(flock.controlsTexture).to.exist; + expect(flock.controlsTexture.getDescendants().length).to.be.greaterThan( + 0, + ); + }); + + it('should add action controls when control is "ACTIONS"', function () { + flock.buttonControls("ACTIONS", "ENABLED"); + expect(flock.controlsTexture).to.exist; + expect(flock.controlsTexture.getDescendants().length).to.be.greaterThan( + 0, + ); + }); + + it("should replace the previous texture when called twice", function () { + flock.buttonControls("BOTH", "ENABLED"); + const first = flock.controlsTexture; + flock.buttonControls("BOTH", "ENABLED"); + expect(flock.controlsTexture).to.exist; + expect(flock.controlsTexture).to.not.equal(first); + }); + }); + + // createSmallButton tests + describe("createSmallButton function tests", function () { + beforeEach(function () { + flock.controlsTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI( + "TestControls", + true, + flock.scene, + ); + }); + + it("should return undefined when controlsTexture is missing", function () { + flock.controlsTexture.dispose(); + flock.controlsTexture = null; + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + expect(button).to.be.undefined; + }); + + it("should return a button with the correct text", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + expect(button).to.exist; + expect(button.textBlock.text).to.equal("△"); + }); + + it("should set width and height to 70px at default displayScale", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + expect(button.width).to.equal("70px"); + expect(button.height).to.equal("70px"); + }); + + it("should set font size to 40px at default displayScale", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + expect(button.fontSize).to.equal("40px"); + }); + + it("should apply the specified color", function () { + const button = flock.createSmallButton("△", "ArrowUp", "red"); + expect(button.color).to.equal("red"); + }); + + it("should add key to pressedButtons on pointer down", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + button.onPointerDownObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.true; + }); + + it("should remove key from pressedButtons on pointer up", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + button.onPointerDownObservable.notifyObservers({}); + button.onPointerUpObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.false; + }); + + it("should remove key from pressedButtons on pointer out", function () { + const button = flock.createSmallButton("△", "ArrowUp", "#ffffff"); + button.onPointerDownObservable.notifyObservers({}); + button.onPointerOutObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.false; + }); + + it("should add all keys when an array is provided", function () { + const button = flock.createSmallButton("△", ["w", "ArrowUp"], "#ffffff"); + button.onPointerDownObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("w")).to.be.true; + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.true; + }); + + it("should remove all keys on release when an array is provided", function () { + const button = flock.createSmallButton("△", ["w", "ArrowUp"], "#ffffff"); + button.onPointerDownObservable.notifyObservers({}); + button.onPointerUpObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("w")).to.be.false; + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.false; + }); + }); + + // createArrowControls tests + describe("createArrowControls function tests", function () { + it("should return early without error when controlsTexture is missing", function () { + expect(() => flock.createArrowControls("#ffffff")).to.not.throw(); + }); + + it("should add controls to controlsTexture", function () { + flock.controlsTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI( + "TestControls", + true, + flock.scene, + ); + flock.createArrowControls("#ffffff"); + expect(flock.controlsTexture.getDescendants().length).to.be.greaterThan( + 0, + ); + }); + + it("should create an up arrow button that maps ArrowUp", function () { + flock.controlsTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI( + "TestControls", + true, + flock.scene, + ); + flock.createArrowControls("#ffffff"); + const upBtn = flock.controlsTexture + .getDescendants() + .find((c) => c.textBlock?.text === "△"); + expect(upBtn).to.exist; + upBtn.onPointerDownObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("ArrowUp")).to.be.true; + }); + }); + + // createButtonControls tests + describe("createButtonControls function tests", function () { + it("should return early without error when controlsTexture is missing", function () { + expect(() => flock.createButtonControls("#ffffff")).to.not.throw(); + }); + + it("should add controls to controlsTexture", function () { + flock.controlsTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI( + "TestControls", + true, + flock.scene, + ); + flock.createButtonControls("#ffffff"); + expect(flock.controlsTexture.getDescendants().length).to.be.greaterThan( + 0, + ); + }); + + it("should create a ① button that maps the e key", function () { + flock.controlsTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI( + "TestControls", + true, + flock.scene, + ); + flock.createButtonControls("#ffffff"); + const btn1 = flock.controlsTexture + .getDescendants() + .find((c) => c.textBlock?.text === "①"); + expect(btn1).to.exist; + btn1.onPointerDownObservable.notifyObservers({}); + expect(flock.canvas.pressedButtons.has("e")).to.be.true; + }); + }); + }); +} diff --git a/tests/camera.test.js b/tests/camera.test.js new file mode 100644 index 000000000..02d2b8db8 --- /dev/null +++ b/tests/camera.test.js @@ -0,0 +1,94 @@ +import { expect } from "chai"; + +export function runCameraTests(flock) { + describe("Camera API @camera", function () { + describe("cameraControl", function () { + afterEach(function () { + delete flock._cameraControlBindings; + }); + + it("should store the binding in _cameraControlBindings", function () { + flock.cameraControl("W", "moveUp"); + expect(flock._cameraControlBindings).to.be.an("array"); + const binding = flock._cameraControlBindings.find( + (b) => b.action === "moveUp", + ); + expect(binding).to.exist; + expect(binding.normalizedKey).to.equal("W".toUpperCase().charCodeAt(0)); + }); + + it("should replace an existing binding for the same action", function () { + flock.cameraControl("W", "moveUp"); + flock.cameraControl("I", "moveUp"); + const bindings = flock._cameraControlBindings.filter( + (b) => b.action === "moveUp", + ); + expect(bindings).to.have.length(1); + expect(bindings[0].normalizedKey).to.equal( + "I".toUpperCase().charCodeAt(0), + ); + }); + + it("should warn and not store a binding for an unsupported key", function () { + const warnings = []; + const original = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + flock.cameraControl("@@@", "moveUp"); + console.warn = original; + expect(warnings.length).to.be.greaterThan(0); + const binding = (flock._cameraControlBindings || []).find( + (b) => b.action === "moveUp", + ); + expect(binding).to.not.exist; + }); + }); + + describe("attachCamera", function () { + const boxIds = []; + let savedCamera; + + beforeEach(function () { + savedCamera = flock.scene.activeCamera; + }); + + afterEach(function () { + boxIds.forEach((id) => { + try { + flock.dispose(id); + } catch (e) { + console.warn(`Failed to dispose ${id}:`, e); + } + }); + boxIds.length = 0; + flock.scene.activeCamera = savedCamera; + }); + + it("should set scene.activeCamera to an ArcRotateCamera following the mesh", async function () { + const id = "cameraAttachBox"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.attachCamera(id); + + expect(flock.scene.activeCamera).to.exist; + expect(flock.scene.activeCamera.metadata.following).to.exist; + expect(flock.scene.activeCamera.metadata.following.name).to.equal(id); + }); + }); + + describe("canvasControls", function () { + it("should not throw when called with false", function () { + expect(() => flock.canvasControls(false)).to.not.throw(); + }); + + it("should not throw when called with true", function () { + expect(() => flock.canvasControls(true)).to.not.throw(); + }); + }); + }); +} diff --git a/tests/control.test.js b/tests/control.test.js new file mode 100644 index 000000000..75c43eceb --- /dev/null +++ b/tests/control.test.js @@ -0,0 +1,55 @@ +import { expect } from "chai"; + +export function runControlTests(flock) { + describe("Control API @control", function () { + describe("waitUntil", function () { + it("should resolve once the condition becomes true", async function () { + let flag = false; + + // Flip the flag after a short delay, pumping the scene so the + // onBeforeRenderObservable fires and waitUntil can check the condition. + const interval = setInterval(() => flock.scene.render(), 0); + setTimeout(() => { + flag = true; + }, 50); + + await flock.waitUntil(() => flag); + clearInterval(interval); + + expect(flag).to.be.true; + }); + + it("should resolve immediately when condition is already true", async function () { + const interval = setInterval(() => flock.scene.render(), 0); + await flock.waitUntil(() => true); + clearInterval(interval); + }); + + it("should warn and resolve when called without a function", async function () { + const warnings = []; + const original = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + await flock.waitUntil("not a function"); + console.warn = original; + expect(warnings.length).to.be.greaterThan(0); + }); + }); + + describe("safeLoop", function () { + it("should call the loop body for each iteration", async function () { + const calls = []; + await flock.safeLoop(0, (i) => calls.push(i)); + await flock.safeLoop(1, (i) => calls.push(i)); + await flock.safeLoop(2, (i) => calls.push(i)); + expect(calls).to.deep.equal([0, 1, 2]); + }); + + it("should stop when state.stopExecution is set", async function () { + const calls = []; + const state = { stopExecution: true }; + await flock.safeLoop(0, (i) => calls.push(i), 100, undefined, state); + expect(calls).to.be.empty; + }); + }); + }); +} diff --git a/tests/effects.test.js b/tests/effects.test.js index 88f9c763e..3de9dec63 100644 --- a/tests/effects.test.js +++ b/tests/effects.test.js @@ -188,5 +188,69 @@ export function runEffectsTests(flock) { identicalPairMaterial.dispose(); singleColorMaterial.dispose(); }); + + it("getMainLight should return '__main_light__'", function () { + expect(flock.getMainLight()).to.equal("__main_light__"); + }); + + it("lightColor should set diffuse and ground color on the main light", function () { + const originalMainLight = flock.mainLight; + const light = { intensity: 1, diffuse: null, groundColor: null }; + flock.mainLight = light; + flock.lightColor("#ff0000", "#0000ff"); + expect(light.diffuse.r).to.be.closeTo(1, 0.01); + expect(light.diffuse.g).to.be.closeTo(0, 0.01); + expect(light.diffuse.b).to.be.closeTo(0, 0.01); + expect(light.groundColor.r).to.be.closeTo(0, 0.01); + expect(light.groundColor.g).to.be.closeTo(0, 0.01); + expect(light.groundColor.b).to.be.closeTo(1, 0.01); + flock.mainLight = originalMainLight; + }); + + it("startParticleSystem should start a stopped particle system", function () { + const ps = new flock.BABYLON.ParticleSystem( + "startTestPS", + 100, + flock.scene, + ); + ps.emitter = flock.BABYLON.Vector3.Zero(); + createdEffects.push("startTestPS"); + + ps.stop(); + expect(ps.isStarted()).to.be.false; + + flock.startParticleSystem("startTestPS"); + expect(ps.isStarted()).to.be.true; + }); + + it("stopParticleSystem should mark the particle system as stopped", function () { + const ps = new flock.BABYLON.ParticleSystem( + "stopTestPS", + 100, + flock.scene, + ); + ps.emitter = flock.BABYLON.Vector3.Zero(); + createdEffects.push("stopTestPS"); + + ps.start(); + expect(ps._stopped).to.be.false; + + flock.stopParticleSystem("stopTestPS"); + expect(ps._stopped).to.be.true; + }); + + it("resetParticleSystem should clear all particles", function () { + const ps = new flock.BABYLON.ParticleSystem( + "resetTestPS", + 100, + flock.scene, + ); + ps.emitter = flock.BABYLON.Vector3.Zero(); + createdEffects.push("resetTestPS"); + + flock.resetParticleSystem("resetTestPS"); + expect(ps._particles).to.have.lengthOf(0); + expect(ps._stockParticles).to.have.lengthOf(0); + }); }); } diff --git a/tests/events.test.js b/tests/events.test.js new file mode 100644 index 000000000..ae3b57037 --- /dev/null +++ b/tests/events.test.js @@ -0,0 +1,375 @@ +import { expect } from "chai"; + +export function runEventsTests(flock) { + describe("Events API @events", function () { + const meshIds = []; + + afterEach(function () { + // Clear any custom events registered during tests + if (flock.events) { + Object.keys(flock.events).forEach((key) => delete flock.events[key]); + } + // Dispose any meshes created during tests + meshIds.forEach((id) => { + try { + flock.dispose(id); + } catch (e) { + // ignore + } + }); + meshIds.length = 0; + }); + + // ------------------------------------------------------------------------- + describe("isAllowedEventName", function () { + it("returns false for empty string", function () { + expect(flock.isAllowedEventName("")).to.be.false; + }); + + it("returns false for non-string input", function () { + expect(flock.isAllowedEventName(null)).to.be.false; + expect(flock.isAllowedEventName(42)).to.be.false; + }); + + it("returns false for name longer than 30 characters", function () { + expect(flock.isAllowedEventName("a".repeat(31))).to.be.false; + }); + + it("returns false for reserved prefixes", function () { + expect(flock.isAllowedEventName("onSomething")).to.be.false; + expect(flock.isAllowedEventName("systemEvent")).to.be.false; + expect(flock.isAllowedEventName("internalMsg")).to.be.false; + expect(flock.isAllowedEventName("babylonTick")).to.be.false; + expect(flock.isAllowedEventName("flockReady")).to.be.false; + expect(flock.isAllowedEventName("_hidden")).to.be.false; + }); + + it("returns false for disallowed characters", function () { + expect(flock.isAllowedEventName("hello!")).to.be.false; + expect(flock.isAllowedEventName("say@world")).to.be.false; + }); + + it("returns true for a valid plain name", function () { + expect(flock.isAllowedEventName("jump")).to.be.true; + expect(flock.isAllowedEventName("collect coin")).to.be.true; + }); + + it("returns true for a name with emoji", function () { + expect(flock.isAllowedEventName("🎉party")).to.be.true; + }); + }); + + // ------------------------------------------------------------------------- + describe("sanitizeEventName", function () { + it("removes disallowed characters", function () { + expect(flock.sanitizeEventName("hello!")).to.equal("hello"); + expect(flock.sanitizeEventName("say@world")).to.equal("sayworld"); + }); + + it("truncates to 50 characters", function () { + const long = "a".repeat(60); + expect(flock.sanitizeEventName(long)).to.have.lengthOf(50); + }); + + it("returns empty string for non-string input", function () { + expect(flock.sanitizeEventName(null)).to.equal(""); + expect(flock.sanitizeEventName(123)).to.equal(""); + }); + + it("preserves emoji and spaces", function () { + expect(flock.sanitizeEventName("🎉 party time")).to.equal( + "🎉 party time", + ); + }); + }); + + // ------------------------------------------------------------------------- + describe("onEvent and broadcastEvent", function () { + it("calls handler when matching event is broadcast", function () { + let called = false; + flock.onEvent("pickup", () => { + called = true; + }); + flock.broadcastEvent("pickup"); + expect(called).to.be.true; + }); + + it("does not call handler when a different event is broadcast", function () { + let called = false; + flock.onEvent("pickup", () => { + called = true; + }); + flock.broadcastEvent("drop"); + expect(called).to.be.false; + }); + + it("calls all handlers when multiple are registered for the same event", function () { + // Simulates several meshes each independently listening to the same event + let countA = 0; + let countB = 0; + let countC = 0; + flock.onEvent("collect", () => countA++); + flock.onEvent("collect", () => countB++); + flock.onEvent("collect", () => countC++); + flock.broadcastEvent("collect"); + expect(countA).to.equal(1); + expect(countB).to.equal(1); + expect(countC).to.equal(1); + }); + + it("calls handler with data passed to broadcastEvent", function () { + let received = null; + flock.onEvent("score", (data) => { + received = data; + }); + flock.broadcastEvent("score", 42); + expect(received).to.equal(42); + }); + + it("fires handler exactly once when once=true", function () { + let count = 0; + flock.onEvent( + "ping", + () => { + count++; + }, + true, + ); + flock.broadcastEvent("ping"); + flock.broadcastEvent("ping"); + flock.broadcastEvent("ping"); + expect(count).to.equal(1); + }); + + it("silently rejects broadcastEvent with reserved event name", function () { + let called = false; + // Cannot register on reserved name, so just verify broadcast doesn't throw + expect(() => flock.broadcastEvent("onSomething")).to.not.throw(); + expect(called).to.be.false; + }); + + it("warns and does not throw when handler is not a function", function () { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + expect(() => flock.onEvent("jump", "notAFunction")).to.not.throw(); + } finally { + console.warn = originalWarn; + } + expect(warnings.some((w) => w.includes("handler must be a function"))).to + .be.true; + }); + }); + + // ------------------------------------------------------------------------- + describe("start", function () { + it("calls action on the next render frame", async function () { + let called = false; + flock.start(() => { + called = true; + }); + await flock.wait(0.1); + expect(called).to.be.true; + }); + }); + + // ------------------------------------------------------------------------- + describe("forever @slow", function () { + this.timeout(10000); + + it("calls action at least 3 times across render frames", async function () { + let count = 0; + flock.forever(async () => { + count++; + }); + await flock.wait(0.5); + expect(count).to.be.at.least(3); + }); + + it("does not run action concurrently when action takes time", async function () { + let concurrent = false; + let running = false; + let count = 0; + flock.forever(async () => { + if (running) { + concurrent = true; + } + running = true; + count++; + await flock.wait(0.05); + running = false; + }); + await flock.wait(0.5); + expect(concurrent).to.be.false; + expect(count).to.be.at.least(1); + }); + }); + + // ------------------------------------------------------------------------- + describe("whenKeyEvent", function () { + it("calls callback when matching KEYDOWN key fires", function () { + let called = false; + flock.whenKeyEvent("x", () => { + called = true; + }); + flock.scene.onKeyboardObservable.notifyObservers({ + type: flock.BABYLON.KeyboardEventTypes.KEYDOWN, + event: { key: "x" }, + }); + expect(called).to.be.true; + }); + + it("does not call callback for a different key", function () { + let called = false; + flock.whenKeyEvent("x", () => { + called = true; + }); + flock.scene.onKeyboardObservable.notifyObservers({ + type: flock.BABYLON.KeyboardEventTypes.KEYDOWN, + event: { key: "z" }, + }); + expect(called).to.be.false; + }); + + it("fires on KEYUP when isReleased=true, not on KEYDOWN", function () { + let downCalled = false; + let upCalled = false; + flock.whenKeyEvent( + "m", + () => { + downCalled = true; + }, + false, + ); + flock.whenKeyEvent( + "m", + () => { + upCalled = true; + }, + true, + ); + + flock.scene.onKeyboardObservable.notifyObservers({ + type: flock.BABYLON.KeyboardEventTypes.KEYDOWN, + event: { key: "m" }, + }); + expect(downCalled).to.be.true; + expect(upCalled).to.be.false; + + flock.scene.onKeyboardObservable.notifyObservers({ + type: flock.BABYLON.KeyboardEventTypes.KEYUP, + event: { key: "m" }, + }); + expect(upCalled).to.be.true; + }); + + it("warns and does not throw when callback is not a function", function () { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + expect(() => flock.whenKeyEvent("k", "notAFunction")).to.not.throw(); + } finally { + console.warn = originalWarn; + } + expect(warnings.some((w) => w.includes("callback must be a function"))) + .to.be.true; + }); + }); + + // ------------------------------------------------------------------------- + describe("whenActionEvent", function () { + it("triggers callback when FORWARD action key 'w' is pressed", function () { + let called = false; + flock.whenActionEvent("FORWARD", () => { + called = true; + }); + flock.scene.onKeyboardObservable.notifyObservers({ + type: flock.BABYLON.KeyboardEventTypes.KEYDOWN, + event: { key: "w" }, + }); + expect(called).to.be.true; + }); + + it("warns and does not throw when callback is not a function", function () { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + expect(() => + flock.whenActionEvent("FORWARD", "notAFunction"), + ).to.not.throw(); + } finally { + console.warn = originalWarn; + } + expect(warnings.some((w) => w.includes("callback must be a function"))) + .to.be.true; + }); + }); + + // ------------------------------------------------------------------------- + describe("onTrigger with applyToGroup @physics", function () { + // Note: createBox("name__N") strips the __N suffix, creating a mesh + // named "name". For separate meshes that share a group root, use single + // underscore: "evtbox_1" and "evtbox_2" both have group root "evtbox" + // (via getGroupRoot which splits on "_" when "__" is absent). + + it("registers trigger on all meshes sharing the same name prefix", async function () { + const id1 = "evtbox_1"; + const id2 = "evtbox_2"; + await flock.createBox(id1, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.createBox(id2, { width: 1, height: 1, depth: 1, position: [2, 0, 0] }); + meshIds.push(id1, id2); + + const mesh1 = flock.scene.getMeshByName(id1); + const mesh2 = flock.scene.getMeshByName(id2); + expect(mesh1).to.exist; + expect(mesh2).to.exist; + + let count = 0; + flock.onTrigger(id1, { + trigger: "OnPickTrigger", + callback: () => count++, + applyToGroup: true, + }); + + mesh1.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + mesh2.actionManager.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + + expect(count).to.equal(2); + }); + + it("registers trigger only on named mesh when applyToGroup is false", async function () { + const id1 = "solobox_1"; + const id2 = "solobox_2"; + await flock.createBox(id1, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.createBox(id2, { width: 1, height: 1, depth: 1, position: [2, 0, 0] }); + meshIds.push(id1, id2); + + const mesh1 = flock.scene.getMeshByName(id1); + const mesh2 = flock.scene.getMeshByName(id2); + expect(mesh1).to.exist; + expect(mesh2).to.exist; + + let count = 0; + flock.onTrigger(id1, { + trigger: "OnPickTrigger", + callback: () => count++, + applyToGroup: false, + }); + + // Trigger the second mesh — should not fire since only id1 was registered + mesh2.actionManager?.processTrigger( + flock.BABYLON.ActionManager.OnPickTrigger, + ); + + expect(count).to.equal(0); + }); + }); + }); +} diff --git a/tests/materials.test.js b/tests/materials.test.js index 9e874f715..3fe43b935 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -566,7 +566,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { const materialsBefore = flock.scene.materials.length; - flock.subtractMeshes("subtracted", box1, [box2]); + flock.subtractMeshes("subtracted", "box1", ["box2"]); boxIds.push("subtracted"); flock.whenModelReady("subtracted", (mesh) => { expect(flock.scene.materials.length).to.equal(materialsBefore - 1); @@ -595,7 +595,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { const materialsBefore = flock.scene.materials.length; - flock.intersectMeshes("intersected", [box1, box2]); + flock.intersectMeshes("intersected", ["box1", "box2"]); boxIds.push("intersected"); flock.whenModelReady("intersected", (mesh) => { expect(flock.scene.materials.length).to.equal(materialsBefore - 1); @@ -624,7 +624,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { const materialsBefore = flock.scene.materials.length; - flock.createHull("hull", [box1, box2]); + flock.createHull("hull", ["box1", "box2"]); boxIds.push("hull"); flock.whenModelReady("hull", (mesh) => { expect(flock.scene.materials.length).to.equal(materialsBefore - 1); @@ -652,7 +652,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { - flock.mergeMeshes("merged", [box1, box2]); + flock.mergeMeshes("merged", ["box1", "box2"]); boxIds.push("merged"); flock.whenModelReady("merged", (mesh) => { expect(mesh.material.metadata.internal).to.equal(true); @@ -680,7 +680,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { - flock.subtractMeshes("subtracted", box1, [box2]); + flock.subtractMeshes("subtracted", "box1", ["box2"]); boxIds.push("subtracted"); flock.whenModelReady("subtracted", (mesh) => { expect(mesh.material.metadata.internal).to.equal(true); @@ -708,7 +708,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { - flock.intersectMeshes("intersected", [box1, box2]); + flock.intersectMeshes("intersected", ["box1", "box2"]); boxIds.push("intersected"); flock.whenModelReady("intersected", (mesh) => { expect(mesh.material.metadata.internal).to.equal(true); @@ -736,7 +736,7 @@ export function runMaterialsTests(flock) { flock.whenModelReady("box1", (box1) => { flock.whenModelReady("box2", (box2) => { - flock.createHull("hull", [box1, box2]); + flock.createHull("hull", ["box1", "box2"]); boxIds.push("hull"); flock.whenModelReady("hull", (mesh) => { expect(mesh.material.metadata.internal).to.equal(true); @@ -764,12 +764,13 @@ export function runMaterialsTests(flock) { }); meshIds.push("singleMergeBox"); - const singleMesh = await flock.whenModelReady("singleMergeBox"); - const merged = await flock.mergeMeshes("singleSimpleMerge", [ - singleMesh, + const id = await flock.mergeMeshes("singleSimpleMerge", [ + "singleMergeBox", ]); meshIds.push("singleSimpleMerge"); + expect(id).to.be.a('string'); + const merged = flock.scene.getMeshByName(id); expect(merged).to.exist; expect(merged.name).to.include("singleSimpleMerge"); expect(merged.getTotalVertices()).to.be.greaterThan(0); @@ -792,11 +793,11 @@ export function runMaterialsTests(flock) { }); meshIds.push("mergeBoxA", "mergeBoxB"); - const boxA = await flock.whenModelReady("mergeBoxA"); - const boxB = await flock.whenModelReady("mergeBoxB"); - const merged = await flock.mergeMeshes("multiValidMerge", [boxA, boxB]); + const id = await flock.mergeMeshes("multiValidMerge", ["mergeBoxA", "mergeBoxB"]); meshIds.push("multiValidMerge"); + expect(id).to.be.a('string'); + const merged = flock.scene.getMeshByName(id); expect(merged).to.exist; expect(merged.getTotalVertices()).to.be.greaterThan(0); }); @@ -818,14 +819,14 @@ export function runMaterialsTests(flock) { }); meshIds.push("mergeBaseBox", treeId); - const base = await flock.whenModelReady("mergeBaseBox"); - const tree = await flock.whenModelReady(treeId); - const merged = await flock.mergeMeshes("validPlusCompositeMerge", [ - base, - tree, + const id = await flock.mergeMeshes("validPlusCompositeMerge", [ + "mergeBaseBox", + treeId, ]); meshIds.push("validPlusCompositeMerge"); + expect(id).to.be.a('string'); + const merged = flock.scene.getMeshByName(id); expect(merged).to.exist; expect(merged.getTotalVertices()).to.be.greaterThan(0); }); @@ -840,11 +841,18 @@ export function runMaterialsTests(flock) { }); meshIds.push("mergeGoodBox"); - const goodMesh = await flock.whenModelReady("mergeGoodBox"); - const invalidMesh = new flock.BABYLON.Mesh( - "mergeInvalidMesh", - flock.scene, - ); + await flock.createBox("mergeInvalidMesh", { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + meshIds.push("mergeInvalidMesh"); + + // Make the mesh invalid by clearing its geometry + const invalidMesh = flock.scene.getMeshByName("mergeInvalidMesh"); + invalidMesh.setVerticesData(flock.BABYLON.VertexBuffer.PositionKind, new Float32Array(0)); + invalidMesh.setIndices([]); const warningMessages = []; const originalWarn = console.warn; @@ -853,25 +861,75 @@ export function runMaterialsTests(flock) { return originalWarn(...args); }; - let merged; - try { - merged = await flock.mergeMeshes("mergeWithInvalid", [ - goodMesh, - invalidMesh, - ]); - } finally { - console.warn = originalWarn; - invalidMesh.dispose(); - } + const id = await flock.mergeMeshes("mergeWithInvalid", [ + "mergeGoodBox", + "mergeInvalidMesh", + ]); + console.warn = originalWarn; meshIds.push("mergeWithInvalid"); - expect(merged).to.exist; - expect(merged.getTotalVertices()).to.be.greaterThan(0); + expect(id).to.be.a('string'); expect( warningMessages.some((message) => - message.includes("Skipping mesh after preparation failure"), + message.includes("No valid geometry found"), ), ).to.equal(true); + const mergedMesh = flock.scene.getMeshByName(id); + expect(mergedMesh.getTotalVertices()).to.be.greaterThan(0); + }); + }); + describe("randomColour", function () { + it("should return a lowercase hex colour string", function () { + const colour = flock.randomColour(); + expect(colour).to.match(/^#[0-9a-f]{6}$/); + }); + + it("should return different values on successive calls", function () { + const results = new Set( + Array.from({ length: 20 }, () => flock.randomColour()), + ); + expect(results.size).to.be.greaterThan(1); + }); + }); + + describe("changeColorMesh", function () { + it("should update the mesh material diffuse color", async function () { + const id = "changeColorMeshBox"; + flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + color: "#0000ff", + position: [0, 0, 0], + }); + boxIds.push(id); + + const mesh = flock.scene.getMeshByName(id); + flock.changeColorMesh(mesh, "#ff0000"); + + expect(mesh.material.diffuseColor.r).to.be.closeTo(1, 0.01); + expect(mesh.material.diffuseColor.g).to.be.closeTo(0, 0.01); + expect(mesh.material.diffuseColor.b).to.be.closeTo(0, 0.01); + }); + }); + + describe("changeMaterial", function () { + it("should resolve and update the mesh material", async function () { + const id = "changeMaterialBox"; + flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + color: "#ffffff", + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.changeMaterial(id, "brick.png", "#ffffff"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.material).to.exist; + expect(mesh.material.getClassName()).to.equal("StandardMaterial"); }); }); }); diff --git a/tests/math.test.js b/tests/math.test.js new file mode 100644 index 000000000..c0415c542 --- /dev/null +++ b/tests/math.test.js @@ -0,0 +1,68 @@ +import { expect } from "chai"; + +export function runMathTests(flock) { + describe("Math API @math", function () { + describe("createVector3", function () { + it("should return a Vector3 with the given x, y, z values", function () { + const v = flock.createVector3(1, 2, 3); + expect(v.x).to.equal(1); + expect(v.y).to.equal(2); + expect(v.z).to.equal(3); + }); + + it("should return a Vector3 with negative values", function () { + const v = flock.createVector3(-5, 0, 10.5); + expect(v.x).to.equal(-5); + expect(v.y).to.equal(0); + expect(v.z).to.equal(10.5); + }); + }); + + describe("randomInteger", function () { + it("should return an integer within the given range [a, b]", function () { + for (let i = 0; i < 20; i++) { + const result = flock.randomInteger(3, 7); + expect(result).to.be.at.least(3); + expect(result).to.be.at.most(7); + expect(Number.isInteger(result)).to.be.true; + } + }); + + it("should auto-swap when a > b and still return a value in the correct range", function () { + for (let i = 0; i < 20; i++) { + const result = flock.randomInteger(10, 5); + expect(result).to.be.at.least(5); + expect(result).to.be.at.most(10); + } + }); + + it("should return the only possible value when a === b", function () { + expect(flock.randomInteger(4, 4)).to.equal(4); + }); + }); + + describe("seededRandom", function () { + it("should return the same value for the same seed", function () { + const a = flock.seededRandom(1, 10, 42); + const b = flock.seededRandom(1, 10, 42); + expect(a).to.equal(b); + }); + + it("should return different values for different seeds", function () { + const results = new Set(); + for (let seed = 1; seed <= 20; seed++) { + results.add(flock.seededRandom(1, 100, seed)); + } + expect(results.size).to.be.greaterThan(1); + }); + + it("should return a value within the given range [from, to]", function () { + for (let seed = 1; seed <= 50; seed++) { + const result = flock.seededRandom(5, 15, seed); + expect(result).to.be.at.least(5); + expect(result).to.be.at.most(15); + } + }); + }); + }); +} diff --git a/tests/mesh-hierarchy.test.js b/tests/mesh-hierarchy.test.js new file mode 100644 index 000000000..456fb2155 --- /dev/null +++ b/tests/mesh-hierarchy.test.js @@ -0,0 +1,351 @@ +import { expect } from "chai"; + +function configureDraco(BABYLON) { + const base = import.meta?.env?.BASE_URL ?? "/"; + const root = base.endsWith("/") ? base : `${base}/`; + + BABYLON.DracoCompression.DefaultNumWorkers = 0; + BABYLON.DracoCompression.Configuration = { + decoder: { + wasmUrl: `${root}draco/draco_wasm_wrapper_gltf.js`, + wasmBinaryUrl: `${root}draco/draco_decoder_gltf.wasm`, + fallbackUrl: `${root}draco/draco_decoder_gltf.js`, + }, + }; +} + +async function pumpAnimation(flock, promise) { + const interval = setInterval(() => { + flock.scene.render(); + }, 0); + try { + await promise; + flock.scene.render(); + flock.scene.render(); + } finally { + clearInterval(interval); + } +} + +function waitForModel(flock, meshId) { + return new Promise((resolve) => flock.whenModelReady(meshId, resolve)); +} + +export function runMeshHierarchyTests(flock) { + describe("Mesh Hierarchy API @meshhierarchy", function () { + const meshIds = []; + + afterEach(function () { + meshIds.forEach((id) => { + try { + flock.dispose(id); + } catch (e) { + console.warn(`Failed to dispose ${id}:`, e); + } + }); + meshIds.length = 0; + }); + + describe("parentChild", function () { + it("should set the child mesh parent to the parent mesh", async function () { + const parentId = "hierarchyParent1"; + const childId = "hierarchyChild1"; + + await flock.createBox(parentId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(childId, { + width: 0.5, + height: 0.5, + depth: 0.5, + position: [2, 0, 0], + }); + meshIds.push(parentId, childId); + + await flock.parentChild(parentId, childId); + + const parentMesh = flock.scene.getMeshByName(parentId); + const childMesh = flock.scene.getMeshByName(childId); + expect(childMesh.parent).to.equal(parentMesh); + }); + + it("should apply position offsets to the child", async function () { + const parentId = "hierarchyParent2"; + const childId = "hierarchyChild2"; + + await flock.createBox(parentId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(childId, { + width: 0.5, + height: 0.5, + depth: 0.5, + position: [0, 0, 0], + }); + meshIds.push(parentId, childId); + + await flock.parentChild(parentId, childId, 1, 0, 0); + + const childMesh = flock.scene.getMeshByName(childId); + expect(childMesh.parent).to.exist; + // The x offset of 1 is applied directly in local space. + // y/z are also adjusted by parent and child pivot alignments. + expect(childMesh.position.x).to.be.closeTo(1, 0.01); + expect(childMesh.position.z).to.be.closeTo(0, 0.01); + }); + }); + + describe("removeParent", function () { + it("should remove the parent from the child mesh", async function () { + const parentId = "hierarchyParent3"; + const childId = "hierarchyChild3"; + + await flock.createBox(parentId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(childId, { + width: 0.5, + height: 0.5, + depth: 0.5, + position: [0, 0, 0], + }); + meshIds.push(parentId, childId); + + await flock.parentChild(parentId, childId); + + const childMesh = flock.scene.getMeshByName(childId); + expect(childMesh.parent).to.exist; + + await flock.removeParent(childId); + + expect(childMesh.parent).to.be.null; + }); + + it("should preserve world position after removing parent", async function () { + const parentId = "hierarchyParent4"; + const childId = "hierarchyChild4"; + + await flock.createBox(parentId, { + width: 1, + height: 1, + depth: 1, + position: [5, 0, 0], + }); + await flock.createBox(childId, { + width: 0.5, + height: 0.5, + depth: 0.5, + position: [5, 0, 0], + }); + meshIds.push(parentId, childId); + + await flock.parentChild(parentId, childId); + const childMesh = flock.scene.getMeshByName(childId); + const worldPosBefore = childMesh.getAbsolutePosition().clone(); + + await flock.removeParent(childId); + + const worldPosAfter = childMesh.getAbsolutePosition(); + expect(worldPosAfter.x).to.be.closeTo(worldPosBefore.x, 0.1); + expect(worldPosAfter.y).to.be.closeTo(worldPosBefore.y, 0.1); + expect(worldPosAfter.z).to.be.closeTo(worldPosBefore.z, 0.1); + }); + }); + + describe("makeFollow and stopFollow", function () { + it("should set a _followObserver on the follower mesh", async function () { + const followerId = "follower1"; + const targetId = "followTarget1"; + + await flock.createBox(followerId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(targetId, { + width: 1, + height: 1, + depth: 1, + position: [3, 0, 0], + }); + meshIds.push(followerId, targetId); + + await flock.makeFollow(followerId, targetId, "CENTER"); + + const followerMesh = flock.scene.getMeshByName(followerId); + expect(followerMesh._followObserver).to.exist; + }); + + it("should clear the _followObserver when stopFollow is called", async function () { + const followerId = "follower2"; + const targetId = "followTarget2"; + + await flock.createBox(followerId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(targetId, { + width: 1, + height: 1, + depth: 1, + position: [3, 0, 0], + }); + meshIds.push(followerId, targetId); + + await flock.makeFollow(followerId, targetId, "CENTER"); + await flock.stopFollow(followerId); + + const followerMesh = flock.scene.getMeshByName(followerId); + expect(followerMesh._followObserver).to.not.exist; + }); + }); + + describe("hold, attach, and drop @slow", function () { + this.timeout(30000); + + let lizId, treeId; + + before(async function () { + if (flock.engine) flock.engine.dispose(); + + flock.engine = new flock.BABYLON.NullEngine(); + flock.scene = new flock.BABYLON.Scene(flock.engine); + flock.BABYLON.SceneLoader.ShowLoadingScreen = false; + + new flock.BABYLON.FreeCamera( + "testCamera", + flock.BABYLON.Vector3.Zero(), + flock.scene, + ); + + const baseMock = { + name: "MockPhysics", + getPluginVersion: () => 2, + isInitialized: () => true, + _checkIsReady: () => true, + onMeshRemovedObservable: new flock.BABYLON.Observable(), + onBeforePhysicsObservable: new flock.BABYLON.Observable(), + onAfterPhysicsObservable: new flock.BABYLON.Observable(), + getTimeStep: () => 1 / 60, + getMotionType: () => 1, + dispose: () => {}, + }; + + const physicsMock = new Proxy(baseMock, { + get: (target, prop) => (prop in target ? target[prop] : () => {}), + }); + + flock.scene.enablePhysics( + new flock.BABYLON.Vector3(0, -9.81, 0), + physicsMock, + ); + configureDraco(flock.BABYLON); + + lizId = flock.createCharacter({ + modelName: "Liz3.glb", + modelId: "holdTestLiz", + position: { x: 0, y: 0, z: 0 }, + }); + + treeId = flock.createObject({ + modelName: "tree.glb", + modelId: "holdTestTree", + position: { x: 0, y: 0, z: 0 }, + }); + + await pumpAnimation( + flock, + Promise.all([ + waitForModel(flock, lizId), + waitForModel(flock, treeId), + ]), + ); + }); + + beforeEach(async function () { + // Reset tree attachment state before each test + const treeMesh = flock.scene.getMeshByName(treeId); + if (treeMesh) { + treeMesh.detachFromBone?.(); + treeMesh.parent = null; + } + }); + + after(function () { + flock.dispose(lizId); + flock.dispose(treeId); + }); + + // hold uses the bone name "Hold" directly (no mapping). Liz3.glb is a + // mixamo model — its left-hand bone is "mixamorig:LeftHand", not "Hold". + // hold therefore resolves without attaching; use attach for mixamo models. + it("hold should resolve without error even when character has no matching Hold bone", async function () { + await pumpAnimation(flock, flock.hold(treeId, lizId)); + + const treeMesh = flock.scene.getMeshByName(treeId); + // Liz3 (mixamo) has no "Hold" bone — tree stays detached + expect(treeMesh.parent).to.be.null; + }); + + it("attach should parent the tree to the skeleton mesh on Liz", async function () { + await pumpAnimation( + flock, + flock.attach(treeId, lizId, { boneName: "Hold" }), + ); + + const treeMesh = flock.scene.getMeshByName(treeId); + // attachToBone sets parent to the skeleton mesh and _transformToBoneReferal + expect(treeMesh.parent, "tree should be parented to Liz's skeleton mesh").to.exist; + expect(treeMesh._transformToBoneReferal, "tree should record bone attachment").to.exist; + }); + + it("attached mesh should follow Liz when she moves", async function () { + await pumpAnimation( + flock, + flock.attach(treeId, lizId, { boneName: "Hold" }), + ); + + const lizMesh = flock.scene.getMeshByName(lizId); + lizMesh.position.x = 10; + flock.scene.render(); + + const treeWorldPos = flock.scene + .getMeshByName(treeId) + .getAbsolutePosition(); + expect(treeWorldPos.x).to.be.closeTo(10, 2); + }); + + it("drop should detach the tree so it no longer follows Liz", async function () { + await pumpAnimation( + flock, + flock.attach(treeId, lizId, { boneName: "Hold" }), + ); + await pumpAnimation(flock, flock.drop(treeId)); + + const treeMesh = flock.scene.getMeshByName(treeId); + expect(treeMesh.parent).to.be.null; + + const lizMesh = flock.scene.getMeshByName(lizId); + const posBeforeMove = treeMesh.getAbsolutePosition().clone(); + + lizMesh.position.x = 30; + flock.scene.render(); + + const posAfterMove = treeMesh.getAbsolutePosition(); + expect(posAfterMove.x).to.be.closeTo(posBeforeMove.x, 0.5); + }); + }); + }); +} diff --git a/tests/models/Flock.glb b/tests/models/Flock.glb new file mode 100644 index 000000000..1423875a7 Binary files /dev/null and b/tests/models/Flock.glb differ diff --git a/tests/movement.test.js b/tests/movement.test.js new file mode 100644 index 000000000..7d778364f --- /dev/null +++ b/tests/movement.test.js @@ -0,0 +1,146 @@ +import { expect } from "chai"; + +export function runMovementTests(flock) { + describe("moveForward @movement", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((id) => flock.dispose(id)); + boxIds.length = 0; + }); + + it("should do nothing when mesh does not exist", function () { + expect(() => flock.moveForward("nonExistentMesh", 5)).to.not.throw(); + }); + + it("should do nothing when speed is 0", async function () { + const id = "boxMoveForwardZeroSpeed"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + expect(() => flock.moveForward(id, 0)).to.not.throw(); + }); + + it("should do nothing when physicsCapsule metadata is missing", async function () { + const id = "boxMoveForwardNoCapsule"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh).to.exist; + // Box has no physicsCapsule metadata — function returns early after that check + const velBefore = mesh.physics.getLinearVelocity().clone(); + + flock.moveForward(id, 5); + + const velAfter = mesh.physics.getLinearVelocity(); + expect(velAfter.x).to.be.closeTo(velBefore.x, 0.001); + expect(velAfter.z).to.be.closeTo(velBefore.z, 0.001); + }); + }); + + describe("moveSideways @movement", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((id) => flock.dispose(id)); + boxIds.length = 0; + }); + + it("should do nothing when mesh does not exist", function () { + expect(() => flock.moveSideways("nonExistentMesh", 5)).to.not.throw(); + }); + + it("should do nothing when speed is 0", async function () { + const id = "boxMoveSidewaysZeroSpeed"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + expect(() => flock.moveSideways(id, 0)).to.not.throw(); + }); + + it("should set non-zero horizontal linear velocity when called with positive speed @slow", async function () { + this.timeout(3000); + const id = "boxMoveSidewaysPositive"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + flock.moveSideways(id, 5); + + const mesh = flock.scene.getMeshByName(id); + const vel = mesh.physics.getLinearVelocity(); + const horizontalSpeed = Math.sqrt(vel.x * vel.x + vel.z * vel.z); + expect(horizontalSpeed).to.be.greaterThan(0); + }); + + it("should preserve existing Y velocity when called @slow", async function () { + this.timeout(3000); + const id = "boxMoveSidewaysPreserveY"; + // Place on the ground so Y velocity is 0 and stable — avoids flakiness from gravity between measurements + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + const mesh = flock.scene.getMeshByName(id); + flock.moveSideways(id, 5); + + const vel = mesh.physics.getLinearVelocity(); + expect(vel.y).to.be.closeTo(0, 0.1); + }); + }); + + describe("strafe @movement", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((id) => flock.dispose(id)); + boxIds.length = 0; + }); + + it("should do nothing when mesh does not exist", function () { + expect(() => flock.strafe("nonExistentMesh", 5)).to.not.throw(); + }); + + it("should do nothing when speed is 0", async function () { + const id = "boxStrafeZeroSpeed"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + expect(() => flock.strafe(id, 0)).to.not.throw(); + }); + + it("should set non-zero horizontal linear velocity when called with positive speed @slow", async function () { + this.timeout(3000); + const id = "boxStrafePositive"; + await flock.createBox(id, { width: 1, height: 1, depth: 1, position: [0, 0, 0] }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + await new Promise((r) => setTimeout(r, 200)); + + flock.strafe(id, 5); + + const mesh = flock.scene.getMeshByName(id); + const vel = mesh.physics.getLinearVelocity(); + const horizontalSpeed = Math.sqrt(vel.x * vel.x + vel.z * vel.z); + expect(horizontalSpeed).to.be.greaterThan(0); + }); + }); +} diff --git a/tests/objects.test.js b/tests/objects.test.js index b880418c1..43a2090ef 100644 --- a/tests/objects.test.js +++ b/tests/objects.test.js @@ -1,5 +1,31 @@ import { expect } from "chai"; +function configureDraco(BABYLON) { + const base = import.meta?.env?.BASE_URL ?? "/"; + const root = base.endsWith("/") ? base : `${base}/`; + BABYLON.DracoCompression.DefaultNumWorkers = 0; + BABYLON.DracoCompression.Configuration = { + decoder: { + wasmUrl: `${root}draco/draco_wasm_wrapper_gltf.js`, + wasmBinaryUrl: `${root}draco/draco_decoder_gltf.wasm`, + fallbackUrl: `${root}draco/draco_decoder_gltf.js`, + }, + }; +} + +async function pumpAnimation(flock, promise) { + const interval = setInterval(() => { + flock.scene.render(); + }, 0); + try { + await promise; + flock.scene.render(); + flock.scene.render(); + } finally { + clearInterval(interval); + } +} + export function runCreateObjectTests(flock) { describe("createObject tests @slow", function () { this.timeout(5000); @@ -259,3 +285,95 @@ export function runCreateObjectTests(flock) { }); }); } + +export function runCreateModelTests(flock) { + describe("createModel tests @slow", function () { + this.timeout(10000); + + const modelName = "Flock.glb"; + const modelId = "flock-model-test"; + let meshId; + + before(async function () { + if (flock.engine) flock.engine.dispose(); + + flock.modelCache = {}; + flock.modelsBeingLoaded = {}; + + flock.engine = new flock.BABYLON.NullEngine(); + flock.scene = new flock.BABYLON.Scene(flock.engine); + flock.BABYLON.SceneLoader.ShowLoadingScreen = false; + + new flock.BABYLON.FreeCamera( + "testCamera", + flock.BABYLON.Vector3.Zero(), + flock.scene, + ); + + const baseMock = { + name: "MockPhysics", + getPluginVersion: () => 2, + isInitialized: () => true, + _checkIsReady: () => true, + onMeshRemovedObservable: new flock.BABYLON.Observable(), + onBeforePhysicsObservable: new flock.BABYLON.Observable(), + onAfterPhysicsObservable: new flock.BABYLON.Observable(), + getTimeStep: () => 1 / 60, + getMotionType: () => 1, + dispose: () => {}, + }; + + const physicsMock = new Proxy(baseMock, { + get: (target, prop) => (prop in target ? target[prop] : () => {}), + }); + + flock.scene.enablePhysics( + new flock.BABYLON.Vector3(0, -9.81, 0), + physicsMock, + ); + + configureDraco(flock.BABYLON); + + meshId = flock.createModel({ + modelName, + modelId, + position: { x: 1, y: 0, z: 2 }, + }); + + await pumpAnimation(flock, flock.show(meshId)); + }); + + after(function () { + flock.dispose(meshId); + }); + + it("createModel returns a string ID", function () { + expect(meshId).to.be.a("string"); + }); + + it("mesh exists in the scene after loading", function () { + const mesh = flock.scene.getMeshByName(meshId); + expect(mesh).to.exist; + }); + + it("mesh metadata has the correct modelName", function () { + const mesh = flock.scene.getMeshByName(meshId); + expect(mesh.metadata?.modelName).to.equal(modelName); + }); + + it("mesh is placed at the given position", function () { + const mesh = flock.scene.getMeshByName(meshId); + expect(mesh.position.x).to.be.closeTo(1, 0.01); + expect(mesh.position.z).to.be.closeTo(2, 0.01); + }); + + it("show and hide work on the loaded model", async function () { + await pumpAnimation(flock, flock.hide(meshId)); + const mesh = flock.scene.getMeshByName(meshId); + expect(mesh.isEnabled()).to.be.false; + + await pumpAnimation(flock, flock.show(meshId)); + expect(mesh.isEnabled()).to.be.true; + }); + }); +} diff --git a/tests/physics.test.js b/tests/physics.test.js index 9c99a0aef..747bce543 100644 --- a/tests/physics.test.js +++ b/tests/physics.test.js @@ -230,4 +230,291 @@ export function runPhysicsTests(flock) { expect(errorLogged).to.be.true; }); }); + + describe("meshExists @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((boxId) => { + flock.dispose(boxId); + }); + boxIds.length = 0; + }); + + it("should return true for an existing mesh", async function () { + const id = "boxMeshExists"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + expect(flock.meshExists(id)).to.be.true; + }); + + it("should return false for a non-existent mesh", function () { + expect(flock.meshExists("doesNotExist")).to.be.false; + }); + }); + + describe("checkMeshesTouching @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((boxId) => { + flock.dispose(boxId); + }); + boxIds.length = 0; + }); + + it("should return true when two meshes overlap", async function () { + const id1 = "boxTouching1"; + const id2 = "boxTouching2"; + await flock.createBox(id1, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(id2, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id1, id2); + + expect(flock.checkMeshesTouching(id1, id2)).to.be.true; + }); + + it("should return false when meshes do not overlap", async function () { + const id1 = "boxNotTouching1"; + const id2 = "boxNotTouching2"; + await flock.createBox(id1, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.createBox(id2, { + width: 1, + height: 1, + depth: 1, + position: [100, 0, 0], + }); + boxIds.push(id1, id2); + + expect(flock.checkMeshesTouching(id1, id2)).to.be.false; + }); + }); + + describe("up method @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((boxId) => { + flock.dispose(boxId); + }); + boxIds.length = 0; + }); + + it("should apply upward impulse to a dynamic mesh @slow", async function () { + const id = "boxUp"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + await flock.setPhysics(id, "DYNAMIC"); + boxIds.push(id); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh).to.exist; + + flock.up(id, 10); + + await new Promise((r) => setTimeout(r, 200)); + + const velocity = mesh.physics.getLinearVelocity(); + expect(velocity.y).to.be.greaterThan(0); + }); + + it("should log when mesh not found", function () { + let logged = false; + const originalConsoleLog = console.log; + console.log = (...args) => { + logged = true; + originalConsoleLog(...args); + }; + + flock.up("nonExistentMesh", 5); + + console.log = originalConsoleLog; + + expect(logged).to.be.true; + }); + }); + + describe("setPhysics @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((boxId) => { + flock.dispose(boxId); + }); + boxIds.length = 0; + }); + + it("should set motion type to STATIC", async function () { + const id = "boxSetPhysicsStatic"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.setPhysics(id, "STATIC"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.physics.getMotionType()).to.equal( + flock.BABYLON.PhysicsMotionType.STATIC, + ); + }); + + it("should set motion type to DYNAMIC", async function () { + const id = "boxSetPhysicsDynamic"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.setPhysics(id, "DYNAMIC"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.physics.getMotionType()).to.equal( + flock.BABYLON.PhysicsMotionType.DYNAMIC, + ); + }); + + it("should dispose physics body when set to NONE", async function () { + const id = "boxSetPhysicsNone"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + boxIds.push(id); + + await flock.setPhysics(id, "NONE"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.physics).to.be.null; + }); + }); + + describe("isTouchingSurface @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((boxId) => { + flock.dispose(boxId); + }); + boxIds.length = 0; + }); + + it("should return false when mesh is in the air", async function () { + const id = "boxInAir"; + await flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 10, 0], + }); + boxIds.push(id); + + expect(flock.isTouchingSurface(id)).to.be.false; + }); + + it("should return false and log when mesh does not exist", function () { + let logged = false; + const originalConsoleLog = console.log; + console.log = (...args) => { + logged = true; + originalConsoleLog(...args); + }; + + const result = flock.isTouchingSurface("nonExistentMesh"); + + console.log = originalConsoleLog; + + expect(result).to.be.false; + expect(logged).to.be.true; + }); + }); + + describe("setPhysicsShape @physics", function () { + const boxIds = []; + + afterEach(function () { + boxIds.forEach((id) => { + try { + flock.dispose(id); + } catch (e) { + console.warn(`Failed to dispose ${id}:`, e); + } + }); + boxIds.length = 0; + }); + + it("should set physicsShapeType to CAPSULE on the mesh metadata", async function () { + const id = "physicsShapeBox1"; + flock.createBox(id, { + width: 1, + height: 2, + depth: 1, + position: [0, 1, 0], + }); + boxIds.push(id); + + await flock.setPhysicsShape(id, "CAPSULE"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.metadata.physicsShapeType).to.equal("CAPSULE"); + }); + + it("should set physicsShapeType to MESH on the mesh metadata", async function () { + const id = "physicsShapeBox2"; + flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [3, 0, 0], + }); + boxIds.push(id); + + await flock.setPhysicsShape(id, "MESH"); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.metadata.physicsShapeType).to.equal("MESH"); + }); + }); + + describe("showPhysics @physics", function () { + it("should not throw when called", function () { + expect(() => flock.showPhysics()).to.not.throw(); + }); + + it("should not throw when called with false", function () { + expect(() => flock.showPhysics(false)).to.not.throw(); + }); + }); } diff --git a/tests/printtext.test.js b/tests/printtext.test.js new file mode 100644 index 000000000..29c758d2b --- /dev/null +++ b/tests/printtext.test.js @@ -0,0 +1,68 @@ +import { expect } from "chai"; + +export function runPrintTextTests(flock) { + describe("printText function tests", function () { + let advancedTexture; + + beforeEach(function () { + advancedTexture = + flock.GUI.AdvancedDynamicTexture.CreateFullscreenUI("PrintTextUI"); + flock.stackPanel = new flock.GUI.StackPanel(); + flock.stackPanel.isVertical = true; + advancedTexture.addControl(flock.stackPanel); + flock.abortController = new AbortController(); + }); + + afterEach(function () { + // Null the panel first so the abort handler's guard skips removeControl + flock.stackPanel = null; + flock.abortController.abort(); + if (advancedTexture) { + advancedTexture.dispose(); + advancedTexture = null; + } + }); + + it("should return early without error when stackPanel is missing", function () { + const savedPanel = flock.stackPanel; + flock.stackPanel = null; + expect(() => flock.printText({ text: "test" })).to.not.throw(); + flock.stackPanel = savedPanel; + }); + + it("should add a background control to the stackPanel", function () { + flock.printText({ text: "Hello", duration: 9999 }); + const bg = advancedTexture.getControlByName("textBackground"); + expect(bg).to.exist; + }); + + it("should display the correct text", function () { + flock.printText({ text: "Hello World", duration: 9999 }); + const textBlock = advancedTexture.getControlByName("textBlock"); + expect(textBlock.text).to.equal("Hello World"); + }); + + it("should use white as the default text color", function () { + flock.printText({ text: "Default color", duration: 9999 }); + const textBlock = advancedTexture.getControlByName("textBlock"); + expect(textBlock.color).to.equal("white"); + }); + + it("should use the specified text color", function () { + flock.printText({ text: "Colored text", color: "red", duration: 9999 }); + const textBlock = advancedTexture.getControlByName("textBlock"); + expect(textBlock.color).to.equal("red"); + }); + + it("should remove the control after the duration expires @slow", function (done) { + this.timeout(5000); + flock.printText({ text: "Fade out", duration: 1 }); + // Duration (1s) + fade animation (~1s for 30 frames at 30fps) + buffer + setTimeout(() => { + const bg = advancedTexture.getControlByName("textBackground"); + expect(bg).to.be.null; + done(); + }, 3000); + }); + }); +} diff --git a/tests/scene.test.js b/tests/scene.test.js new file mode 100644 index 000000000..80dacf63b --- /dev/null +++ b/tests/scene.test.js @@ -0,0 +1,418 @@ +import { expect } from "chai"; + +export function runSceneTests(flock) { + describe("Scene API Tests", function () { + // ─── setSky ──────────────────────────────────────────────────────────────── + + describe("setSky", function () { + afterEach(function () { + if (flock.sky) { + flock.disposeMesh(flock.sky); + flock.sky = null; + } + }); + + it("should create a sky sphere for a single color string", function () { + flock.setSky("#6495ed"); + expect(flock.sky).to.exist; + expect(flock.sky.name).to.equal("sky"); + }); + + it("should create a sky sphere for a 2-color gradient array", function () { + flock.setSky(["#6495ed", "#ffffff"]); + expect(flock.sky).to.exist; + expect(flock.sky.material).to.exist; + }); + + it("should create a sky sphere for a 3-color gradient array", function () { + flock.setSky(["#000033", "#6495ed", "#ffffff"]); + expect(flock.sky).to.exist; + expect(flock.sky.material).to.exist; + }); + + it("should create a sky sphere when passed a Material instance", function () { + const mat = new flock.BABYLON.StandardMaterial( + "testSkyMat", + flock.scene, + ); + mat.backFaceCulling = false; + flock.setSky(mat); + expect(flock.sky).to.exist; + expect(flock.sky.material).to.equal(mat); + mat.dispose(); + }); + + it("should set clearColor and not create a sky sphere with clear:true", function () { + flock.setSky("#ff0000", { clear: true }); + expect(flock.sky).to.not.exist; + }); + + it("should only have one sky mesh after calling setSky twice", function () { + flock.setSky("#6495ed"); + const first = flock.sky; + flock.setSky("#ff6347"); + const second = flock.sky; + + expect(second).to.exist; + expect(second).to.not.equal(first); + + const skyMeshes = flock.scene.meshes.filter((m) => m.name === "sky"); + expect(skyMeshes.length).to.equal(1); + }); + }); + + // ─── createLinearGradientTexture ─────────────────────────────────────────── + + describe("createLinearGradientTexture", function () { + const createdTextures = []; + + afterEach(function () { + createdTextures.forEach((t) => t.dispose()); + createdTextures.length = 0; + }); + + it("should return a DynamicTexture for a two-color array", function () { + const tex = flock.createLinearGradientTexture(["#336633", "#88cc88"]); + createdTextures.push(tex); + expect(tex).to.exist; + expect(tex.getClassName()).to.equal("DynamicTexture"); + }); + + it("should return a DynamicTexture for a single-color array", function () { + const tex = flock.createLinearGradientTexture(["#336633"]); + createdTextures.push(tex); + expect(tex).to.exist; + expect(tex.getClassName()).to.equal("DynamicTexture"); + }); + + it("should produce a taller-than-wide texture by default (vertical)", function () { + const tex = flock.createLinearGradientTexture(["#336633", "#88cc88"], { + size: 256, + }); + createdTextures.push(tex); + const { width, height } = tex.getSize(); + expect(height).to.be.greaterThan(width); + }); + + it("should produce a wider-than-tall texture with horizontal:true", function () { + const tex = flock.createLinearGradientTexture(["#336633", "#88cc88"], { + size: 256, + horizontal: true, + }); + createdTextures.push(tex); + const { width, height } = tex.getSize(); + expect(width).to.be.greaterThan(height); + }); + + it("should respect the size option", function () { + const tex = flock.createLinearGradientTexture(["#336633", "#88cc88"], { + size: 128, + }); + createdTextures.push(tex); + const { height } = tex.getSize(); + expect(height).to.equal(128); + }); + }); + + // ─── createMap ───────────────────────────────────────────────────────────── + + describe("createMap", function () { + // flock.ground is assigned immediately for heightmaps, but metadata is + // only populated inside the async onReady callback. Poll until it lands. + async function waitForGroundMetadata(timeout = 8000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (flock.ground?.metadata) return flock.ground; + await new Promise((r) => setTimeout(r, 50)); + } + return flock.ground; + } + + afterEach(function () { + if (flock.ground) { + flock.disposeMesh(flock.ground); + flock.ground = null; + } + }); + + it("should create a ground mesh for a plain color list", function () { + const ground = flock.createMap("NONE", ["#336633", "#88cc88"]); + expect(ground).to.exist; + expect(ground.name).to.equal("ground"); + }); + + it("should create a ground mesh for a material object with color list", function () { + const ground = flock.createMap("NONE", { + color: ["#336633", "#88cc88"], + materialName: "none.png", + }); + expect(ground).to.exist; + expect(ground.name).to.equal("ground"); + }); + + it("should set flock.ground to the returned mesh", function () { + const ground = flock.createMap("NONE", ["#336633", "#88cc88"]); + expect(flock.ground).to.equal(ground); + }); + + it("should set correct metadata on the ground mesh", function () { + const ground = flock.createMap("NONE", ["#336633", "#88cc88"]); + expect(ground.metadata).to.exist; + expect(ground.metadata.blockKey).to.equal("ground"); + expect(ground.metadata.heightMapImage).to.equal("NONE"); + }); + + it("should attach a physics body to the ground mesh", function () { + const ground = flock.createMap("NONE", ["#336633", "#88cc88"]); + expect(ground.physics).to.exist; + }); + + it("should reuse the same mesh when called again with the same image", function () { + const first = flock.createMap("NONE", ["#336633", "#88cc88"]); + const second = flock.createMap("NONE", ["#cc8833", "#88cc88"]); + expect(second).to.equal(first); + const groundMeshes = flock.scene.meshes.filter( + (m) => m.name === "ground", + ); + expect(groundMeshes.length).to.equal(1); + }); + + it("should not accumulate ground meshes across repeated calls", function () { + flock.createMap("NONE", ["#336633", "#88cc88"]); + flock.createMap("NONE", ["#cc8833", "#336633"]); + flock.createMap("NONE", ["#ffffff", "#000000"]); + const groundMeshes = flock.scene.meshes.filter( + (m) => m.name === "ground", + ); + expect(groundMeshes.length).to.equal(1); + }); + + it("should apply a texture material to a flat ground", function () { + const mat = flock.createMaterial({ materialName: "test.png" }); + const ground = flock.createMap("NONE", mat); + expect(ground).to.exist; + expect(ground.name).to.equal("ground"); + expect(ground.material).to.exist; + }); + + it("should create a ground mesh from a heightmap image @slow", async function () { + this.timeout(10000); + flock.createMap("Islands.png", ["#336633", "#88cc88"]); + const ground = await waitForGroundMetadata(); + expect(ground).to.exist; + expect(ground.name).to.equal("ground"); + expect(ground.metadata.heightMapImage).to.equal("Islands.png"); + }); + + it("should replace a flat ground with a heightmap ground @slow", async function () { + this.timeout(10000); + flock.createMap("NONE", ["#336633", "#88cc88"]); + flock.createMap("Islands.png", ["#336633", "#88cc88"]); + const ground = await waitForGroundMetadata(); + expect(ground.metadata.heightMapImage).to.equal("Islands.png"); + const groundMeshes = flock.scene.meshes.filter( + (m) => m.name === "ground", + ); + expect(groundMeshes.length).to.equal(1); + }); + }); + + // ─── getGroundLevelAt ────────────────────────────────────────────────────── + + describe("getGroundLevelAt", function () { + afterEach(function () { + if (flock.ground) { + flock.disposeMesh(flock.ground); + flock.ground = null; + } + }); + + it("should return 0 when no ground exists", function () { + const level = flock.getGroundLevelAt(0, 0); + expect(level).to.equal(0); + }); + + it("should return a number after a flat ground is created", function () { + flock.createMap("NONE", ["#336633", "#88cc88"]); + const level = flock.getGroundLevelAt(0, 0); + expect(level).to.be.a("number"); + }); + + it("should return 0 at the centre of a flat ground", function () { + flock.createMap("NONE", ["#336633", "#88cc88"]); + const level = flock.getGroundLevelAt(0, 0); + expect(level).to.equal(0); + }); + + it("should accept custom rayStartY and rayLength options without error", function () { + flock.createMap("NONE", ["#336633", "#88cc88"]); + const level = flock.getGroundLevelAt(0, 0, { + rayStartY: 500, + rayLength: 1000, + }); + expect(level).to.be.a("number"); + }); + }); + + // ─── waitForGroundReady ──────────────────────────────────────────────────── + + describe("waitForGroundReady", function () { + afterEach(function () { + if (flock.ground) { + flock.disposeMesh(flock.ground); + flock.ground = null; + } + }); + + it("should resolve immediately with the ground when flock.ground already exists", async function () { + flock.createMap("NONE", ["#336633", "#88cc88"]); + const ground = await flock.waitForGroundReady(); + expect(ground).to.equal(flock.ground); + }); + + }); + + // ─── initialize ─────────────────────────────────────────────────────────── + + describe("initialize", function () { + this.timeout(10000); + + let savedScene; + + before(function () { + savedScene = flock.scene; + flock.engine?.stopRenderLoop(); + }); + + after(function () { + flock.scene = savedScene; + flock.engine?.runRenderLoop(flock._renderLoop); + }); + + it("sets up BABYLON, canvas, observables, and abortController", async function () { + await flock.initialize(); + expect(flock.BABYLON).to.exist; + expect(flock.canvas.id).to.equal("renderCanvas"); + expect(flock.abortController).to.be.instanceOf(AbortController); + expect(flock.gridKeyPressObservable).to.exist; + expect(flock.gridKeyReleaseObservable).to.exist; + }); + }); + + // ─── createEngine ───────────────────────────────────────────────────────── + + describe("createEngine", function () { + let savedEngine; + let savedScene; + + before(function () { + savedEngine = flock.engine; + savedScene = flock.scene; + flock.engine = new flock.BABYLON.NullEngine(); + flock.createEngine(); + }); + + after(function () { + flock.engine?.dispose(); + flock.engine = savedEngine; + flock.scene = savedScene; + }); + + it("creates a Babylon Engine (not NullEngine)", function () { + expect(flock.engine).to.be.instanceOf(flock.BABYLON.Engine); + expect(flock.engine).to.not.be.instanceOf(flock.BABYLON.NullEngine); + }); + + it("sets enableOfflineSupport to false", function () { + expect(flock.engine.enableOfflineSupport).to.be.false; + }); + }); + + // ─── cloneMesh ───────────────────────────────────────────────────────────── + + describe("cloneMesh", function () { + const createdIds = []; + + afterEach(function () { + createdIds.forEach((id) => flock.dispose(id)); + createdIds.length = 0; + }); + + it("should return null for a missing sourceMeshName", function () { + const result = flock.cloneMesh({ cloneId: "clone1" }); + expect(result).to.be.null; + }); + + it("should return null for a missing cloneId", function () { + const boxId = flock.createBox("cloneSrc__1", { + color: "#996633", + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + createdIds.push(boxId); + const result = flock.cloneMesh({ sourceMeshName: boxId }); + expect(result).to.be.null; + }); + + it("should return a string ID for valid inputs", function () { + const boxId = flock.createBox("cloneSrc__2", { + color: "#996633", + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + createdIds.push(boxId); + const cloneId = flock.cloneMesh({ + sourceMeshName: boxId, + cloneId: "myClone", + }); + createdIds.push(cloneId); + expect(cloneId).to.be.a("string"); + expect(cloneId).to.include("myClone"); + }); + + it("should return an ID starting with cloneId + '_'", function () { + const boxId = flock.createBox("cloneSrc__3", { + color: "#996633", + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + createdIds.push(boxId); + const cloneId = flock.cloneMesh({ + sourceMeshName: boxId, + cloneId: "myClone", + }); + createdIds.push(cloneId); + expect(cloneId).to.match(/^myClone_/); + }); + + it("should invoke the callback after cloning", async function () { + this.timeout(3000); + const boxId = flock.createBox("cloneSrc__4", { + color: "#996633", + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + createdIds.push(boxId); + let called = false; + const cloneId = flock.cloneMesh({ + sourceMeshName: boxId, + cloneId: "myClone", + callback: () => { + called = true; + }, + }); + createdIds.push(cloneId); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(called).to.be.true; + }); + }); + }); +} diff --git a/tests/sensing.test.js b/tests/sensing.test.js new file mode 100644 index 000000000..a746701b4 --- /dev/null +++ b/tests/sensing.test.js @@ -0,0 +1,83 @@ +import { expect } from "chai"; + +export function runSensingTests(flock) { + describe("Sensing API @sensing", function () { + describe("keyPressed", function () { + afterEach(function () { + flock.canvas.pressedKeys.clear(); + }); + + it("should return false for a specific key when nothing is pressed", function () { + flock.canvas.pressedKeys.clear(); + expect(flock.keyPressed("W")).to.be.false; + }); + + it("should return true for NONE when no keys are pressed", function () { + flock.canvas.pressedKeys.clear(); + expect(flock.keyPressed("NONE")).to.be.true; + }); + + it("should return false for NONE when a key is pressed", function () { + flock.canvas.pressedKeys.add("w"); + expect(flock.keyPressed("NONE")).to.be.false; + }); + + it("should return true for a key that is in pressedKeys", function () { + flock.canvas.pressedKeys.add("w"); + expect(flock.keyPressed("W")).to.be.true; + }); + }); + + describe("setActionKey and actionPressed", function () { + afterEach(function () { + flock.canvas.pressedKeys.clear(); + if (flock._actionMapOverrides) { + delete flock._actionMapOverrides["FORWARD"]; + } + }); + + it("actionPressed should return false for an action when no key is pressed", function () { + flock.canvas.pressedKeys.clear(); + expect(flock.actionPressed("FORWARD")).to.be.false; + }); + + it("setActionKey should remap an action to a new key", function () { + flock.setActionKey("FORWARD", "X"); + flock.canvas.pressedKeys.add("x"); + expect(flock.actionPressed("FORWARD")).to.be.true; + }); + + it("after remapping, the old default key should no longer trigger the action", function () { + flock.setActionKey("FORWARD", "X"); + flock.canvas.pressedKeys.add("w"); + expect(flock.actionPressed("FORWARD")).to.be.false; + }); + }); + + describe("getTime", function () { + it("should return a positive number of milliseconds", function () { + const t = flock.getTime("milliseconds"); + expect(t).to.be.a("number"); + expect(t).to.be.greaterThan(0); + }); + + it("should return a positive number of seconds", function () { + const t = flock.getTime("seconds"); + expect(t).to.be.a("number"); + expect(t).to.be.greaterThan(0); + }); + + it("should return a positive number of minutes", function () { + const t = flock.getTime("minutes"); + expect(t).to.be.a("number"); + expect(t).to.be.greaterThan(0); + }); + + it("milliseconds should be larger than seconds", function () { + const ms = flock.getTime("milliseconds"); + const s = flock.getTime("seconds"); + expect(ms).to.be.greaterThan(s); + }); + }); + }); +} diff --git a/tests/shapes.test.js b/tests/shapes.test.js new file mode 100644 index 000000000..a2a6c98b3 --- /dev/null +++ b/tests/shapes.test.js @@ -0,0 +1,108 @@ +import { expect } from "chai"; + +export function runShapesTests(flock) { + describe("Shapes API @shapes", function () { + const createdIds = []; + + afterEach(function () { + createdIds.forEach((id) => { + try { + flock.dispose(id); + } catch (e) { + console.warn(`Failed to dispose ${id}:`, e); + } + }); + createdIds.length = 0; + }); + + describe("createCapsule", function () { + it("should return a string id and create the mesh in the scene", function () { + const id = flock.createCapsule("testCapsule1", { + color: "#ff6600", + diameter: 1, + height: 2, + position: [0, 1, 0], + }); + createdIds.push(id); + + expect(id).to.be.a("string"); + const mesh = flock.scene.getMeshByName(id); + expect(mesh).to.exist; + }); + + it("should set blockKey metadata on the created capsule", function () { + const id = flock.createCapsule("testCapsule2", { + color: "#0066ff", + diameter: 0.5, + height: 1.5, + position: [2, 1, 0], + }); + createdIds.push(id); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.metadata).to.exist; + expect(mesh.metadata.blockKey).to.be.a("string"); + }); + }); + + describe("createPlane", function () { + it("should return a string id and create the mesh in the scene", function () { + const id = flock.createPlane("testPlane1", { + color: "#00cc44", + width: 3, + height: 2, + position: [0, 0, 0], + }); + createdIds.push(id); + + expect(id).to.be.a("string"); + const mesh = flock.scene.getMeshByName(id); + expect(mesh).to.exist; + }); + + it("should set metadata.shape to 'plane'", function () { + const id = flock.createPlane("testPlane2", { + color: "#cc0044", + width: 1, + height: 1, + position: [3, 0, 0], + }); + createdIds.push(id); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.metadata).to.exist; + expect(mesh.metadata.shape).to.equal("plane"); + }); + }); + + describe("create3DText @slow", function () { + this.timeout(30000); + + it("should return a string id and create a text mesh in the scene", async function () { + const id = flock.create3DText({ + text: "Hi", + font: "/fonts/FreeSansBold.ttf", + color: "#ffffff", + size: 1, + depth: 0.2, + position: { x: 0, y: 0, z: 0 }, + modelId: "testText3D", + }); + createdIds.push(id); + + expect(id).to.be.a("string"); + + await new Promise((resolve, reject) => { + flock.whenModelReady(id, resolve); + setTimeout( + () => reject(new Error("create3DText timed out")), + 25000, + ); + }); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh).to.exist; + }); + }); + }); +} diff --git a/tests/sound-verification.test.js b/tests/sound-verification.test.js index 46388fd27..351322f15 100644 --- a/tests/sound-verification.test.js +++ b/tests/sound-verification.test.js @@ -426,9 +426,7 @@ export function runSoundVerificationTests(flock) { const instrument = flock.createInstrument("sine"); chai.expect(instrument).to.not.be.undefined; - chai.expect(instrument.oscillator).to.not.be.undefined; - chai.expect(instrument.gainNode).to.not.be.undefined; - chai.expect(instrument.oscillator.type).to.equal("sine"); + chai.expect(instrument.type).to.equal("sine"); }); it("should create different waveform types", function () { @@ -442,8 +440,7 @@ export function runSoundVerificationTests(flock) { types.forEach((type) => { const instrument = flock.createInstrument(type); chai.expect(instrument).to.not.be.undefined; - chai.expect(instrument.oscillator).to.not.be.undefined; - chai.expect(instrument.oscillator.type).to.equal(type); + chai.expect(instrument.type).to.equal(type); }); }); @@ -461,9 +458,11 @@ export function runSoundVerificationTests(flock) { }); chai.expect(instrument).to.not.be.undefined; - chai.expect(instrument.oscillator).to.not.be.undefined; - chai.expect(instrument.gainNode).to.not.be.undefined; - // ADSR is applied to gainNode envelope, not stored as properties + chai.expect(instrument.type).to.equal("sine"); + chai.expect(instrument.attack).to.equal(0.1); + chai.expect(instrument.decay).to.equal(0.2); + chai.expect(instrument.sustain).to.equal(0.7); + chai.expect(instrument.release).to.equal(0.3); }); }); }); diff --git a/tests/sound2.test.js b/tests/sound2.test.js new file mode 100644 index 000000000..e5f09b4a6 --- /dev/null +++ b/tests/sound2.test.js @@ -0,0 +1,73 @@ +import { expect } from "chai"; + +export function runSound2Tests(flock) { + describe("Sound API (BPM and speech) @sound2", function () { + describe("setBPM", function () { + afterEach(function () { + if (flock.scene.metadata) { + delete flock.scene.metadata.bpm; + } + }); + + it("should set bpm on scene metadata when meshName is '__everywhere__'", function () { + flock.setBPM("__everywhere__", 120); + expect(flock.scene.metadata.bpm).to.equal(120); + }); + + it("should clamp invalid bpm to 60", function () { + flock.setBPM("__everywhere__", -5); + expect(flock.scene.metadata.bpm).to.equal(60); + }); + + it("should set bpm on mesh metadata for a named mesh", async function () { + const id = "bpmTestBox"; + flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + + await flock.setBPM(id, 90); + + const mesh = flock.scene.getMeshByName(id); + expect(mesh.metadata.bpm).to.equal(90); + flock.dispose(id); + }); + }); + + describe("speak", function () { + // speak waits for speechSynthesis voices which never load in headless; + // race against a short timeout — if no error is thrown before the + // timeout, the test passes. + function speakWithTimeout(fn, ms = 500) { + return Promise.race([ + fn().catch((e) => { + throw new Error(`speak threw unexpectedly: ${e.message}`); + }), + new Promise((resolve) => setTimeout(resolve, ms)), + ]); + } + + it("should not throw when called with a mesh name and text", async function () { + const id = "speakTestBox"; + flock.createBox(id, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + + try { + await speakWithTimeout(() => flock.speak(id, "hello")); + } finally { + flock.dispose(id); + } + }); + + it("should not throw when called with '__everywhere__'", async function () { + await speakWithTimeout(() => flock.speak("__everywhere__", "hi")); + }); + }); + }); +} diff --git a/tests/tests.html b/tests/tests.html index dfad21b6b..e80ccd183 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -26,9 +26,6 @@ display: block !important; } - #mocha-report { - } - #mocha-report h1 { font-size: 1.1em; } @@ -140,6 +137,13 @@

Flock Test Example

importFn: "runTests", pattern: "Flock API Tests", }, + { + id: "scene", + name: "Scene API Tests", + importPath: "./scene.test.js", + importFn: "runSceneTests", + pattern: "Scene API Tests", + }, { id: "glide", name: "Glide Animation Tests", @@ -161,6 +165,20 @@

Flock Test Example

importFn: "runUITests", pattern: "UIText, UIButton, UIInput, and UISlider function tests", }, + { + id: "printtext", + name: "printText Tests", + importPath: "./printtext.test.js", + importFn: "runPrintTextTests", + pattern: "printText function tests", + }, + { + id: "buttoncontrols", + name: "Button Controls Tests", + importPath: "./buttoncontrols.test.js", + importFn: "runButtonControlsTests", + pattern: "buttonControls function tests", + }, { id: "stress", name: "Stress Tests (Boxes)", @@ -175,6 +193,13 @@

Flock Test Example

importFn: "runCreateObjectTests", pattern: "createObject tests", }, + { + id: "models", + name: "Model Creation Tests", + importPath: "./objects.test.js", + importFn: "runCreateModelTests", + pattern: "createModel tests", + }, { id: "sound", name: "Sound Tests", @@ -245,6 +270,48 @@

Flock Test Example

importFn: "runPhysicsTests", pattern: "@physics", }, + { + id: "sound2", + name: "Sound2 Tests", + importPath: "./sound2.test.js", + importFn: "runSound2Tests", + pattern: "@sound2", + }, + { + id: "camera", + name: "Camera API Tests", + importPath: "./camera.test.js", + importFn: "runCameraTests", + pattern: "@camera", + }, + { + id: "control", + name: "Control API Tests", + importPath: "./control.test.js", + importFn: "runControlTests", + pattern: "@control", + }, + { + id: "xr", + name: "XR API Tests", + importPath: "./xr.test.js", + importFn: "runXRTests", + pattern: "@xr", + }, + { + id: "movement", + name: "Movement API Tests", + importPath: "./movement.test.js", + importFn: "runMovementTests", + pattern: "@movement", + }, + { + id: "events", + name: "Events API Tests", + importPath: "./events.test.js", + importFn: "runEventsTests", + pattern: "Events API", + }, { id: "effects", name: "Effects Tests", @@ -295,6 +362,34 @@

Flock Test Example

importFn: "runXRExportTests", pattern: "XR exportMesh GLB tests", }, + { + id: "meshhierarchy", + name: "Mesh Hierarchy Tests", + importPath: "./mesh-hierarchy.test.js", + importFn: "runMeshHierarchyTests", + pattern: "@meshhierarchy", + }, + { + id: "math", + name: "Math API Tests", + importPath: "./math.test.js", + importFn: "runMathTests", + pattern: "@math", + }, + { + id: "shapes", + name: "Shapes API Tests", + importPath: "./shapes.test.js", + importFn: "runShapesTests", + pattern: "@shapes", + }, + { + id: "sensing", + name: "Sensing API Tests", + importPath: "./sensing.test.js", + importFn: "runSensingTests", + pattern: "@sensing", + }, ]; import * as flockmodule from "../flock.js"; diff --git a/tests/textures/Islands.png b/tests/textures/Islands.png new file mode 100644 index 000000000..97f03bb50 Binary files /dev/null and b/tests/textures/Islands.png differ diff --git a/tests/transform.translate.test.js b/tests/transform.translate.test.js index dd33382af..ede5203c3 100644 --- a/tests/transform.translate.test.js +++ b/tests/transform.translate.test.js @@ -415,4 +415,46 @@ export function runTranslationTests(flock) { expect(distance).to.be.closeTo(0.00141, 0.0001); // sqrt(0.001² + 0.001²) }); }); + + describe("positionAtSingleCoordinate @translation", function () { + let boxId; + + beforeEach(async function () { + boxId = `singleCoordBox_${Date.now()}`; + await flock.createBox(boxId, { + width: 1, + height: 1, + depth: 1, + position: [0, 0, 0], + }); + }); + + afterEach(function () { + try { + flock.dispose(boxId); + } catch (e) { + console.warn(`Failed to dispose ${boxId}:`, e); + } + }); + + it("should set only the x coordinate", async function () { + await flock.positionAtSingleCoordinate(boxId, "x_coordinate", 7); + const mesh = flock.scene.getMeshByName(boxId); + expect(mesh.position.x).to.be.closeTo(7, 0.01); + }); + + it("should set only the y coordinate", async function () { + await flock.positionAtSingleCoordinate(boxId, "y_coordinate", 4); + const mesh = flock.scene.getMeshByName(boxId); + mesh.computeWorldMatrix(true); + const minWorldY = mesh.getBoundingInfo().boundingBox.minimumWorld.y; + expect(minWorldY).to.be.closeTo(4, 0.01); + }); + + it("should set only the z coordinate", async function () { + await flock.positionAtSingleCoordinate(boxId, "z_coordinate", -3); + const mesh = flock.scene.getMeshByName(boxId); + expect(mesh.position.z).to.be.closeTo(-3, 0.01); + }); + }); } diff --git a/tests/uitextbutton.test.js b/tests/uitextbutton.test.js index 902df5e1e..c50a6aab8 100644 --- a/tests/uitextbutton.test.js +++ b/tests/uitextbutton.test.js @@ -224,5 +224,231 @@ export function runUITests(flock) { expect(button.isVisible).to.be.true; }); }); + + // UISlider Tests + describe("UISlider function tests", function () { + it("should create a slider with the correct min, max, and value", function () { + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 100, + value: 50, + x: 100, + y: 100, + }); + + expect(slider).to.exist; + expect(slider.minimum).to.equal(0); + expect(slider.maximum).to.equal(100); + expect(slider.value).to.equal(50); + }); + + it("should use MEDIUM dimensions by default", function () { + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 10, + value: 5, + x: 100, + y: 100, + }); + + expect(slider.width).to.equal("200px"); + expect(slider.height).to.equal("30px"); + }); + + it("should use SMALL dimensions when specified", function () { + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 10, + value: 5, + x: 100, + y: 100, + size: "SMALL", + }); + + expect(slider.width).to.equal("100px"); + expect(slider.height).to.equal("20px"); + }); + + it("should apply the specified colors", function () { + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 10, + value: 5, + x: 100, + y: 100, + textColor: "blue", + backgroundColor: "lightgray", + }); + + expect(slider.color).to.equal("blue"); + expect(slider.background).to.equal("lightgray"); + }); + + it("should use right alignment for negative x", function () { + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 10, + value: 5, + x: -100, + y: 100, + }); + + expect(slider.horizontalAlignment).to.equal( + flock.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT, + ); + }); + + it("should reuse and update an existing slider", function () { + flock.UISlider({ + id: "mySlider", + min: 0, + max: 10, + value: 5, + x: 100, + y: 100, + }); + + const slider = flock.UISlider({ + id: "mySlider", + min: 0, + max: 20, + value: 15, + x: 100, + y: 100, + }); + + expect(slider.minimum).to.equal(0); + expect(slider.maximum).to.equal(20); + expect(slider.value).to.equal(15); + // Only one slider with this id should exist + expect( + flock.scene.UITexture.getControlByName("mySlider"), + ).to.equal(slider); + }); + }); + + // UIInput Tests + describe("UIInput function tests", function () { + it("should create an input and submit button in START mode", async function () { + const inputId = await flock.UIInput({ + text: "Enter name", + x: 100, + y: 100, + id: "myInput", + mode: "START", + }); + + const input = flock.scene.UITexture.getControlByName(inputId); + const button = flock.scene.UITexture.getControlByName( + `submit_${inputId}`, + ); + + expect(input).to.exist; + expect(button).to.exist; + }); + + it("should set the placeholder text on the input", async function () { + await flock.UIInput({ + text: "Enter name", + x: 100, + y: 100, + id: "myInput", + mode: "START", + }); + + const input = flock.scene.UITexture.getControlByName("myInput"); + expect(input.placeholderText).to.equal("Enter name"); + }); + + it("should apply colors and font size to the input", async function () { + await flock.UIInput({ + text: "placeholder", + x: 100, + y: 100, + id: "myInput", + fontSize: 18, + textColor: "red", + backgroundColor: "lightyellow", + mode: "START", + }); + + const input = flock.scene.UITexture.getControlByName("myInput"); + expect(input.color).to.equal("red"); + expect(input.background).to.equal("lightyellow"); + expect(input.fontSize).to.equal("18px"); + }); + + it("should use MEDIUM dimensions by default", async function () { + await flock.UIInput({ + text: "placeholder", + x: 100, + y: 100, + id: "myInput", + mode: "START", + }); + + const input = flock.scene.UITexture.getControlByName("myInput"); + expect(input.width).to.equal("300px"); + expect(input.height).to.equal("50px"); + }); + + it("should position the submit button to the right of the input", async function () { + await flock.UIInput({ + text: "placeholder", + x: 100, + y: 100, + id: "myInput", + mode: "START", + }); + + const button = flock.scene.UITexture.getControlByName("submit_myInput"); + // MEDIUM input width (300) + spacing (10) + x (100) = 410 + expect(button.left).to.equal("410px"); + expect(button.top).to.equal("100px"); + }); + + it("should resolve with the input text when submit button is clicked", async function () { + const promise = flock.UIInput({ + text: "placeholder", + x: 100, + y: 100, + id: "myInput", + }); + + const input = flock.scene.UITexture.getControlByName("myInput"); + const button = flock.scene.UITexture.getControlByName("submit_myInput"); + + input.text = "hello"; + button.onPointerUpObservable.notifyObservers(null); + + const result = await promise; + expect(result).to.equal("hello"); + }); + + it("should resolve with the input text when Enter is pressed", async function () { + const promise = flock.UIInput({ + text: "placeholder", + x: 100, + y: 100, + id: "myInput", + }); + + const input = flock.scene.UITexture.getControlByName("myInput"); + + input.text = "world"; + input.onKeyboardEventProcessedObservable.notifyObservers({ + type: "keydown", + key: "Enter", + }); + + const result = await promise; + expect(result).to.equal("world"); + }); + }); }); } diff --git a/tests/xr.test.js b/tests/xr.test.js new file mode 100644 index 000000000..c2f23dc0a --- /dev/null +++ b/tests/xr.test.js @@ -0,0 +1,43 @@ +import { expect } from "chai"; + +export function runXRTests(flock) { + describe("XR API @xr", function () { + describe("setCameraBackground", function () { + it("should not throw when called with 'user'", function () { + expect(() => flock.setCameraBackground("user")).to.not.throw(); + }); + + it("should not throw when called with 'environment'", function () { + expect(() => flock.setCameraBackground("environment")).to.not.throw(); + }); + }); + + describe("setXRMode", function () { + let originalInitializeXR; + let originalPrintText; + + beforeEach(function () { + // WebXR and i18n are unavailable in headless — stub all three + originalInitializeXR = flock.initializeXR; + flock.initializeXR = async () => {}; + originalPrintText = flock.printText; + flock.printText = () => {}; + window.translate = (key) => key; + }); + + afterEach(function () { + flock.initializeXR = originalInitializeXR; + flock.printText = originalPrintText; + delete window.translate; + }); + + it("should not throw for VR mode", async function () { + await flock.setXRMode("VR"); + }); + + it("should not throw for AR mode", async function () { + await flock.setXRMode("AR"); + }); + }); + }); +} diff --git a/ui/addmeshes.js b/ui/addmeshes.js index c1916714f..afcb44bf0 100644 --- a/ui/addmeshes.js +++ b/ui/addmeshes.js @@ -106,16 +106,6 @@ export function createMeshOnCanvas(block) { .getFieldValue("COLOR"); flock.setSky(color, { clear: true }); break; - case "create_ground": - meshId = "ground"; - meshMap[meshId] = block; - meshBlockIdMap[meshId] = block.id; - color = block - .getInput("COLOR") - .connection.targetBlock() - .getFieldValue("COLOR"); - flock.createGround(color, "ground"); - break; case "create_map": meshId = "ground"; meshMap[meshId] = block;