From c1104cb0b8e9e150de98c25f1ae426b580373a9e Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:56:15 +0100 Subject: [PATCH 1/9] BlocklyShadowUtil tests --- scripts/run-api-tests.mjs | 32 ++++--- tests/blocklyshadowutil.test.js | 150 ++++++++++++++++++++++++++++++++ tests/tests.html | 7 ++ 3 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 tests/blocklyshadowutil.test.js diff --git a/scripts/run-api-tests.mjs b/scripts/run-api-tests.mjs index 9b8e1b97..b9007ae4 100644 --- a/scripts/run-api-tests.mjs +++ b/scripts/run-api-tests.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { chromium } from "playwright"; -import { spawn } from "child_process"; +import { spawn, execFileSync } from "child_process"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; @@ -326,20 +326,21 @@ async function startServer() { const outputBuffer = []; const errorBuffer = []; - // Run npm directly without a shell. This avoids Node DEP0190 - // (deprecation of `shell: true` with an args array). On macOS/Linux/CI, - // `npm` resolves from PATH; on Windows the launcher is `npm.cmd`. - // Alternative if you need shell features (e.g. on Windows): pass the - // command as a single string with `shell: true` and no args array, e.g. - // spawn("npm run dev", { cwd: ..., stdio: "pipe", shell: true, env: ... }) + // Pass the command as a single string with `shell: true` and no args + // array. On Windows, `npm` is a .cmd launcher: spawning "npm" directly + // (no shell) throws ENOENT, and spawning "npm.cmd" directly (no shell) + // throws EINVAL — recent Node versions require shell:true to launch + // .cmd/.bat files at all. Using a single command string (rather than + // shell:true with an args array) avoids the Node DEP0190 deprecation. // `detached: true` puts npm and its child vite/esbuild processes in their // own process group so cleanup() can kill the whole group. Without this, // server.kill() only signals npm, leaving an orphaned vite holding port // 5173 that the next run wrongly "reuses". - server = spawn("npm", ["run", "dev"], { + server = spawn("npm run dev", { cwd: path.resolve(__dirname, ".."), stdio: "pipe", detached: true, + shell: true, env: { ...process.env }, }); @@ -1039,16 +1040,23 @@ async function runTests(suiteId = "all") { function cleanup() { if (server) { console.log("\nšŸ›‘ Stopping development server..."); - // Kill the whole process group (npm + child vite/esbuild). server.kill() - // alone only signals npm and leaves vite orphaned on port 5173. + // Kill the whole tree (npm + child vite/esbuild). server.kill() alone + // only signals the immediate child and leaves vite orphaned on port 5173. + // On Windows, negative-pid process-group kill is a no-op (Windows PIDs + // are never negative) and TerminateProcess doesn't cascade to children, + // so `taskkill /T` is used instead to walk the whole descendant tree. try { - if (server.pid) { + if (server.pid && process.platform === "win32") { + execFileSync("taskkill", ["/pid", String(server.pid), "/T", "/F"], { + stdio: "ignore", + }); + } else if (server.pid) { process.kill(-server.pid, "SIGTERM"); } else { server.kill("SIGTERM"); } } catch { - // Group may already be gone, or platform lacks process groups; fall back. + // Group/tree may already be gone; fall back to signalling the child. server.kill("SIGTERM"); } } diff --git a/tests/blocklyshadowutil.test.js b/tests/blocklyshadowutil.test.js new file mode 100644 index 00000000..c2f80c75 --- /dev/null +++ b/tests/blocklyshadowutil.test.js @@ -0,0 +1,150 @@ +import { expect } from "chai"; +import { + roundPositionValue, + addNumberShadow, + addXYZShadows, + addColourShadow, + addPositionShadows, + addColourShadowSpec, + buildColorsListShadowSpec, +} from "../ui/blocklyshadowutil.js"; + +export function runBlocklyShadowUtilTests() { + describe("ui/blocklyshadowutil @blocklyshadowutil", function () { + describe("roundPositionValue", function () { + it("rounds to 1 decimal place by default", function () { + expect(roundPositionValue(1.23456)).to.equal(1.2); + }); + + it("rounds to the requested number of decimals", function () { + expect(roundPositionValue(1.23456, 3)).to.equal(1.235); + }); + + it("rounds to a whole number when decimals is 0", function () { + expect(roundPositionValue(1.6, 0)).to.equal(2); + }); + }); + + describe("addNumberShadow", function () { + it("adds a math_number shadow with the given value", function () { + const spec = {}; + addNumberShadow(spec, "X", 5); + expect(spec.inputs.X).to.deep.equal({ + shadow: { type: "math_number", fields: { NUM: 5 } }, + }); + }); + + it("initializes spec.inputs if missing", function () { + const spec = {}; + addNumberShadow(spec, "Y", 1); + expect(spec.inputs).to.exist; + }); + + it("preserves existing inputs on the spec", function () { + const spec = { inputs: { Z: { shadow: { type: "math_number", fields: { NUM: 9 } } } } }; + addNumberShadow(spec, "X", 5); + expect(spec.inputs.Z).to.exist; + expect(spec.inputs.X).to.exist; + }); + }); + + describe("addXYZShadows", function () { + it("adds rounded X, Y, Z number shadows from a position", function () { + const spec = {}; + addXYZShadows(spec, { x: 1.23456, y: 2.987, z: -3.05 }); + expect(spec.inputs.X.shadow.fields.NUM).to.equal(1.2); + expect(spec.inputs.Y.shadow.fields.NUM).to.equal(3); + // Math.round rounds -30.5 toward +Infinity (to -30), not away from zero. + expect(spec.inputs.Z.shadow.fields.NUM).to.equal(-3); + }); + + it("defaults missing coordinates to 0", function () { + const spec = {}; + addXYZShadows(spec, {}); + expect(spec.inputs.X.shadow.fields.NUM).to.equal(0); + expect(spec.inputs.Y.shadow.fields.NUM).to.equal(0); + expect(spec.inputs.Z.shadow.fields.NUM).to.equal(0); + }); + + it("defaults to 0,0,0 when pos is undefined", function () { + const spec = {}; + addXYZShadows(spec, undefined); + expect(spec.inputs.X.shadow.fields.NUM).to.equal(0); + expect(spec.inputs.Y.shadow.fields.NUM).to.equal(0); + expect(spec.inputs.Z.shadow.fields.NUM).to.equal(0); + }); + }); + + describe("addColourShadow", function () { + it("adds a colour shadow of the given type with the given hex", function () { + const spec = {}; + addColourShadow(spec, "COLOUR", "colour", "#ff0000"); + expect(spec.inputs.COLOUR).to.deep.equal({ + shadow: { type: "colour", fields: { COLOR: "#ff0000" } }, + }); + }); + }); + + describe("addPositionShadows", function () { + it("adds rounded X, Y, Z number shadows the same as addXYZShadows", function () { + const spec = {}; + addPositionShadows(spec, { x: 1.26, y: -2.04, z: 0 }); + expect(spec.inputs.X.shadow.fields.NUM).to.equal(1.3); + expect(spec.inputs.Y.shadow.fields.NUM).to.equal(-2); + expect(spec.inputs.Z.shadow.fields.NUM).to.equal(0); + }); + }); + + describe("addColourShadowSpec", function () { + it("defaults shadowType to 'colour'", function () { + const spec = {}; + addColourShadowSpec(spec, "COLOUR", "#00ff00"); + expect(spec.inputs.COLOUR.shadow.type).to.equal("colour"); + expect(spec.inputs.COLOUR.shadow.fields.COLOR).to.equal("#00ff00"); + }); + + it("accepts an explicit shadowType", function () { + const spec = {}; + addColourShadowSpec(spec, "SKIN", "#abcdef", "skin_colour"); + expect(spec.inputs.SKIN.shadow.type).to.equal("skin_colour"); + }); + }); + + describe("buildColorsListShadowSpec", function () { + it("uses the object's own colour list when one is registered", function () { + const listSpec = buildColorsListShadowSpec("Star.glb"); + expect(listSpec.extraState.itemCount).to.equal(3); + expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal("#FFD700"); + expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal("#FFD700"); + expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal("#FFD700"); + }); + + it("builds a lists_create_with spec sized to the object's colour list", function () { + // "cat" isn't in objectColours, so this exercises the 3-colour fallback. + const listSpec = buildColorsListShadowSpec("__unknown_object__"); + expect(listSpec.type).to.equal("lists_create_with"); + expect(listSpec.extraState.itemCount).to.equal(3); + expect(listSpec.mutation.items).to.equal(3); + expect(Object.keys(listSpec.inputs)).to.deep.equal([ + "ADD0", + "ADD1", + "ADD2", + ]); + }); + + it("falls back to a black/white/grey palette for unknown objects", function () { + const listSpec = buildColorsListShadowSpec("__unknown_object__"); + expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal("#000000"); + expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal("#FFFFFF"); + expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal("#CCCCCC"); + }); + + it("every generated input is a colour shadow", function () { + const listSpec = buildColorsListShadowSpec("__unknown_object__"); + for (const input of Object.values(listSpec.inputs)) { + expect(input.shadow.type).to.equal("colour"); + } + }); + }); + }); +} diff --git a/tests/tests.html b/tests/tests.html index 756c6849..a8d0e1d5 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -183,6 +183,13 @@

Flock Test Example

importFn: "runNotificationTests", pattern: "error notification banners", }, + { + id: "blocklyshadowutil", + name: "Blockly Shadow Util Tests", + importPath: "./blocklyshadowutil.test.js", + importFn: "runBlocklyShadowUtilTests", + pattern: "ui/blocklyshadowutil", + }, { id: "onscreencontrols", name: "On-Screen Controls Tests", From 92d9df45d80c8320dd7805f57deb795d21a12fbb Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:24:27 +0100 Subject: [PATCH 2/9] More tests --- tests/axis-keyboard.test.js | 360 +++++++++++++++++++++++++++++++++ tests/canvas-utils.test.js | 328 ++++++++++++++++++++++++++++++ tests/contextmenu.test.js | 274 +++++++++++++++++++++++++ tests/gizmo-mobile-hud.test.js | 253 +++++++++++++++++++++++ tests/gizmos.test.js | 174 ++++++++++++++++ tests/tests.html | 28 +++ 6 files changed, 1417 insertions(+) create mode 100644 tests/axis-keyboard.test.js create mode 100644 tests/canvas-utils.test.js create mode 100644 tests/contextmenu.test.js create mode 100644 tests/gizmo-mobile-hud.test.js diff --git a/tests/axis-keyboard.test.js b/tests/axis-keyboard.test.js new file mode 100644 index 00000000..a94976a8 --- /dev/null +++ b/tests/axis-keyboard.test.js @@ -0,0 +1,360 @@ +import { expect } from "chai"; +import { createAxisKeyboardHandler } from "../ui/axis-keyboard.js"; +import { KeyboardDispatcher } from "../main/keyboardDispatcher.js"; + +function topHandler() { + const top = KeyboardDispatcher._modeStack[KeyboardDispatcher._modeStack.length - 1]; + return top?.handler; +} + +function makeEvent(overrides = {}) { + return { + target: document.body, + key: "", + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...overrides, + }; +} + +export function runAxisKeyboardTests(flock) { + describe("ui/axis-keyboard @axiskeyboard", function () { + let stop; + let moves; + let axisChanges; + let confirmed; + let cancelled; + + function make(overrides = {}) { + moves = []; + axisChanges = []; + confirmed = 0; + cancelled = 0; + stop = createAxisKeyboardHandler({ + onMove: (x, y, z) => moves.push([x, y, z]), + onConfirm: () => confirmed++, + onCancel: () => cancelled++, + onAxisChange: (a) => axisChanges.push(a), + stepNormal: 0.1, + stepFast: 1, + ...overrides, + }); + return stop; + } + + afterEach(function () { + // Enter/Escape already pop the mode; calling stop() again is a no-op, + // so this is safe whether or not the test already stopped it. + stop?.(); + stop = null; + }); + + describe("mode registration", function () { + it("pushes exactly one mode onto the KeyboardDispatcher stack", function () { + const before = KeyboardDispatcher._modeStack.length; + make(); + expect(KeyboardDispatcher._modeStack.length).to.equal(before + 1); + }); + + it("stop() pops the mode back off the stack", function () { + const before = KeyboardDispatcher._modeStack.length; + make(); + stop(); + expect(KeyboardDispatcher._modeStack.length).to.equal(before); + }); + + it("stop() is idempotent (a second call does not pop an extra mode)", function () { + const before = KeyboardDispatcher._modeStack.length; + make(); + stop(); + stop(); + expect(KeyboardDispatcher._modeStack.length).to.equal(before); + }); + }); + + describe("axis toggling (x/y/z)", function () { + it("'x' locks to the x axis and reports it via onAxisChange", function () { + make(); + topHandler()(makeEvent({ key: "x" })); + expect(stop.getAxis()).to.equal("x"); + expect(axisChanges).to.deep.equal(["x"]); + }); + + it("pressing 'x' again unlocks back to free movement", function () { + make(); + topHandler()(makeEvent({ key: "x" })); + topHandler()(makeEvent({ key: "x" })); + expect(stop.getAxis()).to.equal(null); + expect(axisChanges).to.deep.equal(["x", null]); + }); + + it("'Y' (uppercase) locks to the y axis", function () { + make(); + topHandler()(makeEvent({ key: "Y" })); + expect(stop.getAxis()).to.equal("y"); + }); + + it("'z' locks to the z axis", function () { + make(); + topHandler()(makeEvent({ key: "z" })); + expect(stop.getAxis()).to.equal("z"); + }); + + it("switching from x to y replaces the lock rather than combining", function () { + make(); + topHandler()(makeEvent({ key: "x" })); + topHandler()(makeEvent({ key: "y" })); + expect(stop.getAxis()).to.equal("y"); + }); + }); + + describe("uniform axis ('u') gated by allowUniform", function () { + it("is ignored when allowUniform is false (default)", function () { + make(); + topHandler()(makeEvent({ key: "u" })); + expect(stop.getAxis()).to.equal(null); + expect(axisChanges).to.deep.equal([]); + }); + + it("does not call preventDefault when ignored", function () { + make(); + const event = makeEvent({ key: "u" }); + topHandler()(event); + expect(event.defaultPrevented).to.equal(false); + }); + + it("locks to 'all' when allowUniform is true", function () { + make({ allowUniform: true }); + topHandler()(makeEvent({ key: "u" })); + expect(stop.getAxis()).to.equal("all"); + expect(axisChanges).to.deep.equal(["all"]); + }); + + it("toggles 'all' back off on a second 'u' press", function () { + make({ allowUniform: true }); + topHandler()(makeEvent({ key: "u" })); + topHandler()(makeEvent({ key: "u" })); + expect(stop.getAxis()).to.equal(null); + }); + }); + + describe("initialAxis / normalizeAxis", function () { + it("collapses an initial 'all' to 'x' when allowUniform is false", function () { + make({ initialAxis: "all", allowUniform: false }); + expect(stop.getAxis()).to.equal("x"); + }); + + it("keeps an initial 'all' when allowUniform is true", function () { + make({ initialAxis: "all", allowUniform: true }); + expect(stop.getAxis()).to.equal("all"); + }); + + it("respects a single-axis initialAxis regardless of allowUniform", function () { + make({ initialAxis: "z" }); + expect(stop.getAxis()).to.equal("z"); + }); + }); + + describe("stop.setAxis", function () { + it("updates the current axis", function () { + make(); + stop.setAxis("y"); + expect(stop.getAxis()).to.equal("y"); + }); + + it("normalizes 'all' to 'x' when allowUniform is false", function () { + make({ allowUniform: false }); + stop.setAxis("all"); + expect(stop.getAxis()).to.equal("x"); + }); + }); + + describe("arrow keys — free movement (no axis locked)", function () { + it("ArrowRight moves +x and reports axis 'x'", function () { + make(); + topHandler()(makeEvent({ key: "ArrowRight" })); + expect(moves).to.deep.equal([[1, 0, 0]]); + expect(axisChanges).to.deep.equal(["x"]); + }); + + it("ArrowLeft moves -x and reports axis 'x'", function () { + make(); + topHandler()(makeEvent({ key: "ArrowLeft" })); + expect(moves).to.deep.equal([[-1, 0, 0]]); + expect(axisChanges).to.deep.equal(["x"]); + }); + + it("ArrowUp moves +z (depth) and reports axis 'z', not y", function () { + make(); + topHandler()(makeEvent({ key: "ArrowUp" })); + expect(moves).to.deep.equal([[0, 0, 1]]); + expect(axisChanges).to.deep.equal(["z"]); + }); + + it("ArrowDown moves -z and reports axis 'z'", function () { + make(); + topHandler()(makeEvent({ key: "ArrowDown" })); + expect(moves).to.deep.equal([[0, 0, -1]]); + expect(axisChanges).to.deep.equal(["z"]); + }); + }); + + describe("arrow keys — axis locked", function () { + it("moves only along the locked axis, ignoring the arrow's own direction axis", function () { + make(); + stop.setAxis("y"); + topHandler()(makeEvent({ key: "ArrowRight" })); + expect(moves).to.deep.equal([[0, 1, 0]]); + }); + + it("ArrowLeft/Down still apply the negative sign on the locked axis", function () { + make(); + stop.setAxis("y"); + topHandler()(makeEvent({ key: "ArrowLeft" })); + expect(moves).to.deep.equal([[0, -1, 0]]); + }); + + it("locking to 'all' moves every axis together", function () { + make({ allowUniform: true }); + stop.setAxis("all"); + topHandler()(makeEvent({ key: "ArrowRight" })); + expect(moves).to.deep.equal([[1, 1, 1]]); + }); + + it("does not fire onAxisChange again while already locked", function () { + make(); + stop.setAxis("y"); + axisChanges.length = 0; + topHandler()(makeEvent({ key: "ArrowRight" })); + expect(axisChanges).to.deep.equal([]); + }); + }); + + describe("step size (shiftKey)", function () { + it("uses stepFast by default (no shift)", function () { + make({ stepNormal: 0.1, stepFast: 5 }); + topHandler()(makeEvent({ key: "ArrowRight" })); + expect(moves).to.deep.equal([[5, 0, 0]]); + }); + + it("uses stepNormal (finer) when shiftKey is held", function () { + make({ stepNormal: 0.1, stepFast: 5 }); + topHandler()(makeEvent({ key: "ArrowRight", shiftKey: true })); + expect(moves).to.deep.equal([[0.1, 0, 0]]); + }); + }); + + describe("PageUp / PageDown", function () { + it("PageUp moves +y when no axis is locked", function () { + make(); + topHandler()(makeEvent({ key: "PageUp" })); + expect(moves).to.deep.equal([[0, 1, 0]]); + expect(axisChanges).to.deep.equal(["y"]); + }); + + it("PageDown moves -y when no axis is locked", function () { + make(); + topHandler()(makeEvent({ key: "PageDown" })); + expect(moves).to.deep.equal([[0, -1, 0]]); + }); + + it("PageUp moves all axes together when locked to 'all'", function () { + make({ allowUniform: true }); + stop.setAxis("all"); + topHandler()(makeEvent({ key: "PageUp" })); + expect(moves).to.deep.equal([[1, 1, 1]]); + }); + + it("is a no-op when locked to a single specific axis (x/y/z)", function () { + make(); + stop.setAxis("x"); + topHandler()(makeEvent({ key: "PageUp" })); + expect(moves).to.deep.equal([]); + }); + }); + + describe("Enter / Space (confirm)", function () { + it("Enter calls onConfirm and stops (pops the mode)", function () { + const before = KeyboardDispatcher._modeStack.length; + make(); + topHandler()(makeEvent({ key: "Enter" })); + expect(confirmed).to.equal(1); + expect(KeyboardDispatcher._modeStack.length).to.equal(before); + }); + + it("Space also confirms", function () { + make(); + topHandler()(makeEvent({ key: " " })); + expect(confirmed).to.equal(1); + }); + + it("getAxis() reads null after stop via confirm", function () { + make(); + topHandler()(makeEvent({ key: "x" })); + topHandler()(makeEvent({ key: "Enter" })); + expect(stop.getAxis()).to.equal(null); + }); + }); + + describe("Escape (cancel)", function () { + it("calls onCancel and stops without calling onConfirm", function () { + make(); + topHandler()(makeEvent({ key: "Escape" })); + expect(cancelled).to.equal(1); + expect(confirmed).to.equal(0); + }); + }); + + describe("ignored input", function () { + it("ignores the event when ctrlKey is held", function () { + make(); + topHandler()(makeEvent({ key: "x", ctrlKey: true })); + expect(stop.getAxis()).to.equal(null); + }); + + it("ignores the event when metaKey is held", function () { + make(); + topHandler()(makeEvent({ key: "ArrowRight", metaKey: true })); + expect(moves).to.deep.equal([]); + }); + + it("ignores the event when altKey is held", function () { + make(); + topHandler()(makeEvent({ key: "x", altKey: true })); + expect(stop.getAxis()).to.equal(null); + }); + + it("ignores keys typed into an element", function () { + make(); + const input = document.createElement("input"); + topHandler()(makeEvent({ key: "ArrowRight", target: input })); + expect(moves).to.deep.equal([]); + }); + + it("ignores keys while focus is inside #codePanel", function () { + make(); + const panel = document.createElement("div"); + panel.id = "codePanel"; + const child = document.createElement("span"); + panel.appendChild(child); + document.body.appendChild(panel); + try { + topHandler()(makeEvent({ key: "ArrowRight", target: child })); + expect(moves).to.deep.equal([]); + } finally { + panel.remove(); + } + }); + }); + }); +} diff --git a/tests/canvas-utils.test.js b/tests/canvas-utils.test.js new file mode 100644 index 00000000..274839ba --- /dev/null +++ b/tests/canvas-utils.test.js @@ -0,0 +1,328 @@ +import { expect } from 'chai'; +import { + getCanvasCircle, + destroyCanvasCircle, + createCanvasCircle, + moveCanvasCircle, + clickCanvasCircle, + startCanvasKeyboardMode, + stopCanvasKeyboardMode, + setCrosshairCursor, + setDefaultCursor, +} from '../ui/canvas-utils.js'; +import { KeyboardDispatcher } from '../main/keyboardDispatcher.js'; + +function topHandler() { + const top = KeyboardDispatcher._modeStack[KeyboardDispatcher._modeStack.length - 1]; + return top?.handler; +} + +function dispatchKeyup(key) { + document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); +} + +function keyEvent(overrides = {}) { + return { + target: document.body, + key: '', + shiftKey: false, + preventDefault() {}, + stopPropagation() {}, + ...overrides, + }; +} + +export function runCanvasUtilsTests(flock) { + describe('ui/canvas-utils @canvasutils', function () { + afterEach(function () { + // These are module-level singletons shared across the whole test + // session, so every test must leave them exactly as it found them. + stopCanvasKeyboardMode(); + destroyCanvasCircle(); + setDefaultCursor(); + }); + + describe('createCanvasCircle / destroyCanvasCircle / getCanvasCircle', function () { + it('creates a single circle element appended to the document', function () { + createCanvasCircle(); + const circle = getCanvasCircle(); + expect(circle).to.exist; + expect(circle.classList.contains('canvas-selector-circle')).to.equal(true); + expect(document.body.contains(circle)).to.equal(true); + }); + + it('does not create a second circle if one already exists', function () { + createCanvasCircle(); + const first = getCanvasCircle(); + createCanvasCircle(); + expect(getCanvasCircle()).to.equal(first); + expect(document.querySelectorAll('.canvas-selector-circle').length).to.equal(1); + }); + + it('destroyCanvasCircle removes it from the document and clears the reference', function () { + createCanvasCircle(); + const circle = getCanvasCircle(); + destroyCanvasCircle(); + expect(getCanvasCircle()).to.equal(null); + expect(document.body.contains(circle)).to.equal(false); + }); + + it('destroyCanvasCircle is a no-op when nothing exists', function () { + expect(() => destroyCanvasCircle()).to.not.throw(); + expect(getCanvasCircle()).to.equal(null); + }); + }); + + describe('moveCanvasCircle', function () { + it('is a no-op (does not throw) when no circle exists', function () { + expect(() => moveCanvasCircle(10, 10)).to.not.throw(); + }); + + it('moves the circle by the given delta', function () { + createCanvasCircle(); + const canvas = flock.scene.getEngine().getRenderingCanvas(); + const before = canvas.getBoundingClientRect(); + moveCanvasCircle(5, -3); + const circle = getCanvasCircle(); + const leftBefore = parseFloat(circle.style.left); + moveCanvasCircle(20, 20); + const leftAfter = parseFloat(circle.style.left); + const topAfter = parseFloat(circle.style.top); + expect(leftAfter - leftBefore).to.be.closeTo(20, 0.01); + // Sense check: the circle is actually positioned relative to the canvas. + expect(leftAfter).to.be.at.least(before.left); + expect(topAfter).to.exist; + }); + + it('clamps position to the canvas bounds on the high side', function () { + createCanvasCircle(); + const canvas = flock.scene.getEngine().getRenderingCanvas(); + const bounds = canvas.getBoundingClientRect(); + moveCanvasCircle(100000, 100000); + const circle = getCanvasCircle(); + expect(parseFloat(circle.style.left)).to.be.closeTo(bounds.left + bounds.width - 10, 0.5); + }); + + it('clamps position to the canvas bounds on the low side', function () { + createCanvasCircle(); + const canvas = flock.scene.getEngine().getRenderingCanvas(); + const bounds = canvas.getBoundingClientRect(); + moveCanvasCircle(-100000, -100000); + const circle = getCanvasCircle(); + expect(parseFloat(circle.style.left)).to.be.closeTo(bounds.left + 10, 0.5); + }); + }); + + describe('clickCanvasCircle', function () { + it('invokes the callback with the current position when a circle exists', function () { + createCanvasCircle(); + moveCanvasCircle(0, 0); + let received = null; + clickCanvasCircle((x, y) => (received = [x, y])); + expect(received).to.be.an('array').with.lengthOf(2); + }); + + it('does not invoke the callback when no circle exists', function () { + let called = false; + clickCanvasCircle(() => (called = true)); + expect(called).to.equal(false); + }); + }); + + describe('setCrosshairCursor / setDefaultCursor', function () { + it('setCrosshairCursor sets body and canvas cursor to crosshair', function () { + setCrosshairCursor(); + expect(document.body.style.cursor).to.equal('crosshair'); + expect(flock.scene.defaultCursor).to.equal('crosshair'); + }); + + it('setDefaultCursor restores the default cursor', function () { + setCrosshairCursor(); + setDefaultCursor(); + expect(document.body.style.cursor).to.equal('default'); + expect(flock.scene.hoverCursor).to.equal('pointer'); + }); + }); + + describe('startCanvasKeyboardMode / stopCanvasKeyboardMode', function () { + it('pushes a KeyboardDispatcher mode while active', function () { + const before = KeyboardDispatcher._modeStack.length; + startCanvasKeyboardMode(() => {}); + expect(KeyboardDispatcher._modeStack.length).to.equal(before + 1); + }); + + it('stopCanvasKeyboardMode pops the mode back off', function () { + const before = KeyboardDispatcher._modeStack.length; + startCanvasKeyboardMode(() => {}); + stopCanvasKeyboardMode(); + expect(KeyboardDispatcher._modeStack.length).to.equal(before); + }); + + it('stopCanvasKeyboardMode is a no-op when not active', function () { + expect(() => stopCanvasKeyboardMode()).to.not.throw(); + }); + + it('does not create the circle immediately by default', function () { + startCanvasKeyboardMode(() => {}); + expect(getCanvasCircle()).to.equal(null); + }); + + it('creates and shows the circle immediately when showCircleImmediately is true', function () { + startCanvasKeyboardMode(() => {}, true); + expect(getCanvasCircle()).to.exist; + expect(document.body.style.cursor).to.equal('none'); + }); + + it('starting a second time while active does not leak an extra mode', function () { + const before = KeyboardDispatcher._modeStack.length; + startCanvasKeyboardMode(() => {}); + startCanvasKeyboardMode(() => {}); + expect(KeyboardDispatcher._modeStack.length).to.equal(before + 1); + }); + + it('restores the default cursor after stopping', function () { + startCanvasKeyboardMode(() => {}, true); + stopCanvasKeyboardMode(); + expect(document.body.style.cursor).to.equal('default'); + }); + }); + + describe('keyboard-mode arrow key movement', function () { + afterEach(function () { + // Clear any keys left "held" by a test that didn't release them. + dispatchKeyup('ArrowRight'); + dispatchKeyup('ArrowLeft'); + dispatchKeyup('ArrowUp'); + dispatchKeyup('ArrowDown'); + }); + + it('ArrowRight creates the circle on demand and moves it +x', function () { + startCanvasKeyboardMode(() => {}); + expect(getCanvasCircle()).to.equal(null); + topHandler()(keyEvent({ key: 'ArrowRight' })); + expect(getCanvasCircle()).to.exist; + dispatchKeyup('ArrowRight'); + }); + + it('ArrowDown moves +y (screen-space down), not -y', function () { + startCanvasKeyboardMode(() => {}, true); + const before = parseFloat(getCanvasCircle().style.top); + topHandler()(keyEvent({ key: 'ArrowDown' })); + const after = parseFloat(getCanvasCircle().style.top); + expect(after - before).to.be.closeTo(10, 0.01); + dispatchKeyup('ArrowDown'); + }); + + it('shiftKey uses the finer 2px step instead of the normal 10px', function () { + startCanvasKeyboardMode(() => {}, true); + const before = parseFloat(getCanvasCircle().style.left); + topHandler()(keyEvent({ key: 'ArrowRight', shiftKey: true })); + const after = parseFloat(getCanvasCircle().style.left); + expect(after - before).to.be.closeTo(2, 0.01); + dispatchKeyup('ArrowRight'); + }); + + it('combines two simultaneously held arrow keys', function () { + // Each keydown re-applies moveDistance for every currently-held arrow + // key (this is what makes OS key-repeat continue moving the circle), + // so pressing Right then Up while Right is still held moves x twice. + startCanvasKeyboardMode(() => {}, true); + const before = { + x: parseFloat(getCanvasCircle().style.left), + y: parseFloat(getCanvasCircle().style.top), + }; + topHandler()(keyEvent({ key: 'ArrowRight' })); + topHandler()(keyEvent({ key: 'ArrowUp' })); + const after = { + x: parseFloat(getCanvasCircle().style.left), + y: parseFloat(getCanvasCircle().style.top), + }; + expect(after.x - before.x).to.be.closeTo(20, 0.01); + expect(after.y - before.y).to.be.closeTo(-10, 0.01); + }); + + it('releasing one held key via keyup stops it contributing to movement', function () { + startCanvasKeyboardMode(() => {}, true); + topHandler()(keyEvent({ key: 'ArrowRight' })); + dispatchKeyup('ArrowRight'); + const before = parseFloat(getCanvasCircle().style.left); + topHandler()(keyEvent({ key: 'ArrowUp' })); + const after = parseFloat(getCanvasCircle().style.left); + // ArrowRight was released, so a fresh ArrowUp press should not move x. + expect(after).to.be.closeTo(before, 0.01); + }); + }); + + describe('Tab / Escape exit the mode', function () { + it('Tab stops keyboard mode', function () { + startCanvasKeyboardMode(() => {}); + const event = keyEvent({ key: 'Tab' }); + topHandler()(event); + expect(KeyboardDispatcher._modeStack.some((m) => m.name === 'canvas-cursor')).to.equal( + false + ); + }); + + it('Escape stops keyboard mode', function () { + startCanvasKeyboardMode(() => {}); + topHandler()(keyEvent({ key: 'Escape' })); + expect(KeyboardDispatcher._modeStack.some((m) => m.name === 'canvas-cursor')).to.equal( + false + ); + }); + }); + + describe('Enter / Space click behaviour', function () { + it('clicks through to the callback when there is no hit checker', function () { + let called = false; + startCanvasKeyboardMode(() => (called = true), true); + topHandler()(keyEvent({ key: 'Enter' })); + expect(called).to.equal(true); + }); + + it('clicks through when the hit checker approves the position', function () { + let called = false; + startCanvasKeyboardMode( + () => (called = true), + true, + () => true + ); + topHandler()(keyEvent({ key: ' ' })); + expect(called).to.equal(true); + }); + + it('does not click and flags an invalid press when the hit checker rejects the position', function () { + let called = false; + startCanvasKeyboardMode( + () => (called = true), + true, + () => false + ); + topHandler()(keyEvent({ key: 'Enter' })); + expect(called).to.equal(false); + expect( + getCanvasCircle().classList.contains('canvas-selector-circle--invalid-press') + ).to.equal(true); + }); + + it('ignores Enter when focus is on a real button, letting the button handle it', function () { + startCanvasKeyboardMode(() => {}, true); + const button = document.createElement('button'); + document.body.appendChild(button); + try { + let prevented = false; + topHandler()( + keyEvent({ + key: 'Enter', + target: button, + preventDefault: () => (prevented = true), + }) + ); + expect(prevented).to.equal(false); + } finally { + button.remove(); + } + }); + }); + }); +} diff --git a/tests/contextmenu.test.js b/tests/contextmenu.test.js new file mode 100644 index 00000000..00a39732 --- /dev/null +++ b/tests/contextmenu.test.js @@ -0,0 +1,274 @@ +import { expect } from "chai"; +import * as Blockly from "blockly"; +import { initContextMenus } from "../ui/contextmenu.js"; +import { setBlockLocked, isBlockLocked } from "../ui/blocklyutil.js"; +import { defineControlBlocks } from "../blocks/control.js"; + +export function runContextMenuTests(flock) { + describe("ui/contextmenu @contextmenu", function () { + this.timeout(10000); + + let workspace; + let container; + let createdBlocks; + + before(function () { + // The full app registers block types during its Blockly-init sequence, + // which this lightweight test harness doesn't run — register the one + // block type these tests need directly. + defineControlBlocks(); + container = document.createElement("div"); + container.style.width = "300px"; + container.style.height = "200px"; + document.body.appendChild(container); + workspace = Blockly.inject(container, { collapse: true }); + initContextMenus(workspace); + }); + + after(function () { + workspace?.dispose(); + container?.remove(); + }); + + beforeEach(function () { + createdBlocks = []; + }); + + afterEach(function () { + createdBlocks.forEach((b) => { + if (!b.disposed) b.dispose(); + }); + createdBlocks = []; + }); + + function makeBlock(type = "wait") { + const block = workspace.newBlock(type); + block.initSvg(); + block.render(); + createdBlocks.push(block); + return block; + } + + function getItem(id) { + return Blockly.ContextMenuRegistry.registry.getItem(id); + } + + describe("detachBlockWithShortcut", function () { + it("is hidden for a block in a flyout", function () { + const block = makeBlock(); + block.isInFlyout = true; + expect(getItem("detachBlockWithShortcut").preconditionFn({ block })).to.equal( + "hidden", + ); + }); + + it("is disabled for a top-level block with no parent", function () { + const block = makeBlock(); + expect(getItem("detachBlockWithShortcut").preconditionFn({ block })).to.equal( + "disabled", + ); + }); + + it("is enabled once the block is connected below a parent", function () { + const parent = makeBlock(); + const child = makeBlock(); + parent.nextConnection.connect(child.previousConnection); + expect( + getItem("detachBlockWithShortcut").preconditionFn({ block: child }), + ).to.equal("enabled"); + }); + + it("callback unplugs the block from its parent", function () { + const parent = makeBlock(); + const child = makeBlock(); + parent.nextConnection.connect(child.previousConnection); + getItem("detachBlockWithShortcut").callback({ block: child }); + expect(child.previousConnection.isConnected()).to.equal(false); + }); + }); + + describe("viewBlockInCanvas", function () { + it("is hidden for a block with no associated mesh", function () { + const block = makeBlock(); + expect(getItem("viewBlockInCanvas").preconditionFn({ block })).to.equal( + "hidden", + ); + }); + + it("is hidden for a block in a flyout", function () { + const block = makeBlock(); + block.isInFlyout = true; + expect(getItem("viewBlockInCanvas").preconditionFn({ block })).to.equal( + "hidden", + ); + }); + }); + + describe("blockLock", function () { + it("stays enabled for a plain, unlocked block", function () { + const block = makeBlock(); + expect(getItem("blockLock").preconditionFn({ block })).to.equal("enabled"); + }); + + it("stays enabled even when the block is already locked (so it can be unlocked)", function () { + const block = makeBlock(); + setBlockLocked(block, true); + expect(getItem("blockLock").preconditionFn({ block })).to.equal("enabled"); + }); + + it("callback toggles the locked state on and back off", function () { + const block = makeBlock(); + expect(isBlockLocked(block)).to.equal(false); + getItem("blockLock").callback({ block }); + expect(isBlockLocked(block)).to.equal(true); + getItem("blockLock").callback({ block }); + expect(isBlockLocked(block)).to.equal(false); + }); + + it("displayText differs between the locked and unlocked states", function () { + const block = makeBlock(); + const unlockedText = getItem("blockLock").displayText({ block }); + setBlockLocked(block, true); + const lockedText = getItem("blockLock").displayText({ block }); + expect(unlockedText).to.not.equal(lockedText); + }); + }); + + describe("disableMutatingItemsWhenLocked", function () { + it("disables blockInline and blockDisable once the block is locked", function () { + const block = makeBlock(); + setBlockLocked(block, true); + for (const id of ["blockInline", "blockDisable"]) { + const item = getItem(id); + expect(item, `expected ${id} to be registered`).to.exist; + expect(item.preconditionFn({ block })).to.equal("disabled"); + } + }); + + it("disables detachBlockWithShortcut even when otherwise connected", function () { + const parent = makeBlock(); + const child = makeBlock(); + parent.nextConnection.connect(child.previousConnection); + setBlockLocked(child, true); + expect( + getItem("detachBlockWithShortcut").preconditionFn({ block: child }), + ).to.equal("disabled"); + }); + + it("leaves the item's normal behaviour intact when unlocked", function () { + const block = makeBlock(); + expect(getItem("blockInline").preconditionFn({ block })).to.not.equal( + "disabled", + ); + }); + }); + + describe("flockCollapseExpandWorkspace", function () { + it("is hidden when the workspace has no top-level blocks", function () { + expect(workspace.getTopBlocks(false)).to.have.lengthOf(0); + expect( + getItem("flockCollapseExpandWorkspace").preconditionFn({ workspace }), + ).to.equal("hidden"); + }); + + it("is enabled once a top-level block exists", function () { + makeBlock(); + expect( + getItem("flockCollapseExpandWorkspace").preconditionFn({ workspace }), + ).to.equal("enabled"); + }); + + it("callback collapses all top blocks, then expands them again on a second call", function () { + const block = makeBlock(); + const item = getItem("flockCollapseExpandWorkspace"); + item.callback({ workspace }); + expect(block.isCollapsed()).to.equal(true); + item.callback({ workspace }); + expect(block.isCollapsed()).to.equal(false); + }); + }); + + describe("workspaceDelete rename", function () { + it("relabels the built-in delete-all item away from its default text", function () { + const item = getItem("workspaceDelete"); + expect(item).to.exist; + const text = item.displayText({ workspace }); + expect(text).to.be.a("string").and.not.equal("DELETE_ALL_BLOCKS"); + }); + }); + + describe("workspaceFindInWorkspace", function () { + it("is always enabled", function () { + expect(getItem("workspaceFindInWorkspace").preconditionFn({})).to.equal( + "enabled", + ); + }); + + it("callback opens window.flockWorkspaceSearch", function () { + let opened = false; + const saved = window.flockWorkspaceSearch; + window.flockWorkspaceSearch = { open: () => (opened = true) }; + try { + getItem("workspaceFindInWorkspace").callback({}); + expect(opened).to.equal(true); + } finally { + window.flockWorkspaceSearch = saved; + } + }); + }); + + describe("clipboard: cut / copy / paste", function () { + it("blockCopy is hidden in a flyout and enabled otherwise", function () { + const block = makeBlock(); + expect(getItem("blockCopy").preconditionFn({ block })).to.equal("enabled"); + block.isInFlyout = true; + expect(getItem("blockCopy").preconditionFn({ block })).to.equal("hidden"); + }); + + it("blockCut is disabled on a locked block", function () { + const block = makeBlock(); + setBlockLocked(block, true); + expect(getItem("blockCut").preconditionFn({ block })).to.equal("disabled"); + }); + + it("blockCut disposes the block after copying it", function () { + const block = makeBlock(); + getItem("blockCut").callback({ block }); + expect(block.disposed).to.equal(true); + createdBlocks = createdBlocks.filter((b) => b !== block); + }); + + it("blockPaste is disabled with no copied data", function () { + Blockly.clipboard.getLastCopiedData?.(); + // Force a known empty-clipboard state via a fresh copy/cut cycle isn't + // reliable across test order, so only assert the shape of the result. + const block = makeBlock(); + const result = getItem("blockPaste").preconditionFn({ block }); + expect(["enabled", "disabled"]).to.include(result); + }); + + it("blockPaste becomes enabled after a copy, and pastes a new block stacked after the target", function () { + const source = makeBlock(); + getItem("blockCopy").callback({ block: source }); + expect(getItem("blockPaste").preconditionFn({ block: source })).to.equal( + "enabled", + ); + + const target = makeBlock(); + const before = workspace.getAllBlocks(false).length; + getItem("blockPaste").callback({ block: target }); + const after = workspace.getAllBlocks(false).length; + expect(after).to.equal(before + 1); + + // The pasted block should be freshly created (not one of ours to dispose + // manually) — sweep it up via the workspace so afterEach doesn't miss it. + for (const b of workspace.getAllBlocks(false)) { + if (!createdBlocks.includes(b)) createdBlocks.push(b); + } + + // It should have landed connected below the target, not floating loose. + expect(target.nextConnection.isConnected()).to.equal(true); + }); + }); + }); +} diff --git a/tests/gizmo-mobile-hud.test.js b/tests/gizmo-mobile-hud.test.js new file mode 100644 index 00000000..052a1c2d --- /dev/null +++ b/tests/gizmo-mobile-hud.test.js @@ -0,0 +1,253 @@ +import { expect } from "chai"; +import { createGizmoMobileHud } from "../ui/gizmo-mobile-hud.js"; + +function findHud(flock) { + return flock.scene.textures.find((t) => t.name === "GizmoHUD"); +} + +function findControl(flock, name) { + const hud = findHud(flock); + return hud?.getDescendants(false, (c) => c.name === name)[0] ?? null; +} + +export function runGizmoMobileHudTests(flock) { + describe("ui/gizmo-mobile-hud @gizmomobilehud", function () { + let stop; + let moves; + let axisChanges; + + function make(overrides = {}) { + moves = []; + axisChanges = []; + stop = createGizmoMobileHud({ + onMove: (x, y, z) => moves.push([x, y, z]), + onAxisChange: (a) => axisChanges.push(a), + stepNormal: 0.1, + stepFast: 1, + ...overrides, + }); + return stop; + } + + afterEach(function () { + stop?.(); + stop = null; + flock.controlsTexture = undefined; + flock._joystickSource = undefined; + }); + + describe("guard clause", function () { + it("returns null when flock.GUI is unavailable", function () { + const savedGUI = flock.GUI; + flock.GUI = undefined; + try { + const result = createGizmoMobileHud({ onMove: () => {} }); + expect(result).to.equal(null); + } finally { + flock.GUI = savedGUI; + } + }); + }); + + describe("lifecycle", function () { + it("creates a fullscreen 'GizmoHUD' texture on the scene", function () { + make(); + expect(findHud(flock)).to.exist; + }); + + it("stop() disposes the HUD texture", function () { + make(); + stop(); + expect(findHud(flock)).to.not.exist; + stop = null; + }); + + it("stop() is idempotent", function () { + make(); + expect(() => { + stop(); + stop(); + }).to.not.throw(); + stop = null; + }); + + it("hides existing on-screen controls while active and restores them on stop", function () { + flock.controlsTexture = { rootContainer: { isVisible: true } }; + make(); + expect(flock.controlsTexture.rootContainer.isVisible).to.equal(false); + stop(); + expect(flock.controlsTexture.rootContainer.isVisible).to.equal(true); + stop = null; + }); + + it("pauses the joystick source while active and resumes it on stop", function () { + let paused = false; + flock._joystickSource = { + pause: () => (paused = true), + resume: () => (paused = false), + }; + make(); + expect(paused).to.equal(true); + stop(); + expect(paused).to.equal(false); + stop = null; + }); + }); + + describe("axis buttons", function () { + it("creates exactly x/y/z buttons by default (no uniform)", function () { + make(); + expect(findControl(flock, "gizmo-axis-x")).to.exist; + expect(findControl(flock, "gizmo-axis-y")).to.exist; + expect(findControl(flock, "gizmo-axis-z")).to.exist; + expect(findControl(flock, "gizmo-axis-all")).to.not.exist; + }); + + it("also creates the uniform 'all' button when showUniform is true", function () { + make({ showUniform: true }); + expect(findControl(flock, "gizmo-axis-all")).to.exist; + }); + + it("clicking an axis button fires onAxisChange with that axis", function () { + make(); + findControl(flock, "gizmo-axis-y").onPointerUpObservable.notifyObservers(); + expect(axisChanges).to.deep.equal(["y"]); + }); + + it("clicking the already-selected axis button does not re-fire onAxisChange", function () { + make({ initialAxis: "x" }); + findControl(flock, "gizmo-axis-x").onPointerUpObservable.notifyObservers(); + expect(axisChanges).to.deep.equal([]); + }); + + it("stop.setAxis switches the active axis without throwing", function () { + make(); + expect(() => stop.setAxis("z")).to.not.throw(); + }); + + it("stop.setAxis ignores an unknown axis key", function () { + make({ initialAxis: "x" }); + stop.setAxis("bogus"); + // Selecting y afterwards should still work normally if the bogus + // setAxis call was safely ignored rather than corrupting state. + findControl(flock, "gizmo-axis-y").onPointerUpObservable.notifyObservers(); + expect(axisChanges).to.deep.equal(["y"]); + }); + }); + + describe("mode: 'arrows'", function () { + it("creates a negative and positive arrow button", function () { + make({ mode: "arrows" }); + expect(findControl(flock, "gizmo-arrow--1")).to.exist; + expect(findControl(flock, "gizmo-arrow-1")).to.exist; + }); + + it("pressing the positive arrow immediately moves +stepNormal on the current axis", function () { + make({ mode: "arrows", initialAxis: "x", stepNormal: 0.1, stepFast: 1 }); + findControl(flock, "gizmo-arrow-1").onPointerDownObservable.notifyObservers(); + expect(moves).to.deep.equal([[0.1, 0, 0]]); + }); + + it("pressing the negative arrow immediately moves -stepNormal", function () { + make({ mode: "arrows", initialAxis: "y", stepNormal: 0.1, stepFast: 1 }); + findControl(flock, "gizmo-arrow--1").onPointerDownObservable.notifyObservers(); + expect(moves).to.deep.equal([[0, -0.1, 0]]); + }); + + it("releasing the arrow stops the repeat without throwing", function () { + make({ mode: "arrows" }); + const btn = findControl(flock, "gizmo-arrow-1"); + btn.onPointerDownObservable.notifyObservers(); + expect(() => btn.onPointerUpObservable.notifyObservers()).to.not.throw(); + }); + + it("moves all three axes together when locked to 'all'", function () { + make({ + mode: "arrows", + showUniform: true, + initialAxis: "all", + stepNormal: 0.1, + stepFast: 1, + }); + findControl(flock, "gizmo-arrow-1").onPointerDownObservable.notifyObservers(); + expect(moves).to.deep.equal([[0.1, 0.1, 0.1]]); + }); + }); + + describe("mode: 'slider' (default)", function () { + it("creates the track and thumb controls", function () { + make(); + expect(findControl(flock, "gizmoTrack")).to.exist; + expect(findControl(flock, "gizmoThumb")).to.exist; + }); + + it("does not throw when dragged via real pointer events on the canvas", function () { + make({ initialAxis: "x" }); + const canvas = flock.canvas; + const rect = canvas.getBoundingClientRect(); + const down = new PointerEvent("pointerdown", { + pointerId: 1, + clientX: rect.left + rect.width / 4 + 20, + clientY: rect.bottom - 10, + }); + const move = new PointerEvent("pointermove", { + pointerId: 1, + clientX: rect.left + rect.width / 4 + 40, + clientY: rect.bottom - 10, + }); + const up = new PointerEvent("pointerup", { pointerId: 1 }); + expect(() => { + canvas.dispatchEvent(down); + canvas.dispatchEvent(move); + canvas.dispatchEvent(up); + }).to.not.throw(); + }); + + it("a pointerdown right of center moves the axis in the positive direction", function () { + make({ initialAxis: "x", getValues: () => ({ x: 0, y: 0, z: 0 }) }); + const canvas = flock.canvas; + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + pointerId: 2, + clientX: rect.left + rect.width / 4 + 30, + clientY: rect.bottom - 10, + }), + ); + canvas.dispatchEvent(new PointerEvent("pointerup", { pointerId: 2 })); + expect(moves.length).to.equal(1); + expect(moves[0][0]).to.be.above(0); + }); + + it("a pointerdown left of center moves the axis in the negative direction", function () { + make({ initialAxis: "x", getValues: () => ({ x: 0, y: 0, z: 0 }) }); + const canvas = flock.canvas; + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + pointerId: 3, + clientX: rect.left + rect.width / 4 - 30, + clientY: rect.bottom - 10, + }), + ); + canvas.dispatchEvent(new PointerEvent("pointerup", { pointerId: 3 })); + expect(moves.length).to.equal(1); + expect(moves[0][0]).to.be.below(0); + }); + + it("ignores pointerdown outside the slider's bounding box", function () { + make({ initialAxis: "x" }); + const canvas = flock.canvas; + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + pointerId: 4, + clientX: rect.left + rect.width - 5, + clientY: rect.top + 5, + }), + ); + expect(moves).to.deep.equal([]); + }); + }); + }); +} diff --git a/tests/gizmos.test.js b/tests/gizmos.test.js index 1c6733d2..621a8b66 100644 --- a/tests/gizmos.test.js +++ b/tests/gizmos.test.js @@ -10,6 +10,8 @@ import { configureRotationGizmo, configureScaleGizmo, viewMeshWithCamera, + toggleGizmo, + enableGizmos, } from '../ui/gizmos.js'; export function runGizmoTests(flock) { @@ -252,6 +254,64 @@ export function runGizmoTests(flock) { }); }); + // ─── toggleGizmo ───────────────────────────────────────────────────────── + + describe('toggleGizmo', function () { + let buttons; + + function addButton(id, { active = false } = {}) { + const btn = document.createElement('button'); + btn.id = id; + btn.className = active ? 'gizmo-button active' : 'gizmo-button'; + document.body.appendChild(btn); + buttons.push(btn); + return btn; + } + + beforeEach(function () { + buttons = []; + }); + + afterEach(function () { + buttons.forEach((b) => b.remove()); + buttons = []; + }); + + it('activates the position gizmo and highlights its button', function () { + addButton('positionButton'); + toggleGizmo('position'); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.true; + expect(mgr.positionGizmoEnabled).to.be.true; + }); + + it('pressing the same gizmo again toggles it off', function () { + addButton('positionButton'); + toggleGizmo('position'); + toggleGizmo('position'); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.false; + expect(mgr.positionGizmoEnabled).to.be.false; + }); + + it('switching to a different gizmo un-highlights the previous button', function () { + addButton('positionButton'); + addButton('rotationButton'); + toggleGizmo('position'); + toggleGizmo('rotation'); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.false; + expect(document.getElementById('rotationButton').classList.contains('active')).to.be.true; + }); + + it('turning a gizmo off disables every gizmo flag, not just its own', function () { + addButton('positionButton'); + toggleGizmo('position'); + expect(mgr.positionGizmoEnabled).to.be.true; + toggleGizmo('position'); + expect(mgr.positionGizmoEnabled).to.be.false; + expect(mgr.rotationGizmoEnabled).to.be.false; + expect(mgr.scaleGizmoEnabled).to.be.false; + }); + }); + // ─── viewMeshWithCamera: orbit view ────────────────────────────────────── describe('viewMeshWithCamera (orbit view)', function () { @@ -361,6 +421,120 @@ export function runGizmoTests(flock) { }); }); + // ─── enableGizmos ──────────────────────────────────────────────────────── + + describe('enableGizmos', function () { + const REQUIRED_IDS = [ + 'positionButton', + 'rotationButton', + 'scaleButton', + 'selectButton', + 'duplicateButton', + 'deleteButton', + 'cameraButton', + 'showShapesButton', + 'scrollModelsLeftButton', + 'scrollModelsRightButton', + 'scrollObjectsLeftButton', + 'scrollObjectsRightButton', + 'scrollCharactersLeftButton', + 'scrollCharactersRightButton', + ]; + // eyeButton isn't in the "required" guard list, but enableGizmos reads it + // unconditionally (no optional chaining) once past that guard, so it must + // exist too or enableGizmos throws. + const EXTRA_IDS = ['eyeButton']; + + let created; + + beforeEach(function () { + created = []; + }); + + afterEach(function () { + created.forEach((el) => el.remove()); + created = []; + }); + + function addButton(id, { disabled = true } = {}) { + const btn = document.createElement('button'); + btn.id = id; + if (disabled) btn.setAttribute('disabled', ''); + document.body.appendChild(btn); + created.push(btn); + return btn; + } + + it('does nothing when a required button is missing from the DOM', function () { + REQUIRED_IDS.slice(1).forEach((id) => addButton(id)); + EXTRA_IDS.forEach((id) => addButton(id)); + expect(() => enableGizmos()).to.not.throw(); + expect(document.getElementById(REQUIRED_IDS[1]).hasAttribute('disabled')).to.be.true; + }); + + it('removes the disabled attribute from every button once all required ones exist', function () { + REQUIRED_IDS.forEach((id) => addButton(id)); + EXTRA_IDS.forEach((id) => addButton(id)); + enableGizmos(); + REQUIRED_IDS.forEach((id) => { + expect(document.getElementById(id).hasAttribute('disabled'), id).to.be.false; + }); + }); + + it('wires the position button click through to toggleGizmo', function () { + REQUIRED_IDS.forEach((id) => addButton(id)); + EXTRA_IDS.forEach((id) => addButton(id)); + enableGizmos(); + document.getElementById('positionButton').click(); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.true; + expect(mgr.positionGizmoEnabled).to.be.true; + }); + + it('wires the "show shapes" button to exitGizmoState + window.showShapes', function () { + REQUIRED_IDS.forEach((id) => addButton(id)); + EXTRA_IDS.forEach((id) => addButton(id)); + mgr.positionGizmoEnabled = true; + let called = false; + const saved = window.showShapes; + window.showShapes = () => (called = true); + try { + enableGizmos(); + document.getElementById('showShapesButton').click(); + expect(called).to.be.true; + expect(mgr.positionGizmoEnabled).to.be.false; + } finally { + window.showShapes = saved; + } + }); + + it('wires the scroll buttons to window.scrollModels/scrollObjects/scrollCharacters', function () { + REQUIRED_IDS.forEach((id) => addButton(id)); + EXTRA_IDS.forEach((id) => addButton(id)); + const calls = []; + const saved = { + scrollModels: window.scrollModels, + scrollObjects: window.scrollObjects, + scrollCharacters: window.scrollCharacters, + }; + window.scrollModels = (dir) => calls.push(['models', dir]); + window.scrollObjects = (dir) => calls.push(['objects', dir]); + window.scrollCharacters = (dir) => calls.push(['characters', dir]); + try { + enableGizmos(); + document.getElementById('scrollModelsLeftButton').click(); + document.getElementById('scrollObjectsRightButton').click(); + document.getElementById('scrollCharactersLeftButton').click(); + expect(calls).to.deep.equal([ + ['models', -1], + ['objects', 1], + ['characters', -1], + ]); + } finally { + Object.assign(window, saved); + } + }); + }); + // ─── disposeGizmoManager ───────────────────────────────────────────────── describe('disposeGizmoManager', function () { diff --git a/tests/tests.html b/tests/tests.html index a8d0e1d5..6ab92dab 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -190,6 +190,34 @@

Flock Test Example

importFn: "runBlocklyShadowUtilTests", pattern: "ui/blocklyshadowutil", }, + { + id: "axiskeyboard", + name: "Axis Keyboard Handler Tests", + importPath: "./axis-keyboard.test.js", + importFn: "runAxisKeyboardTests", + pattern: "ui/axis-keyboard", + }, + { + id: "canvasutils", + name: "Canvas Utils Tests", + importPath: "./canvas-utils.test.js", + importFn: "runCanvasUtilsTests", + pattern: "ui/canvas-utils", + }, + { + id: "gizmomobilehud", + name: "Gizmo Mobile HUD Tests", + importPath: "./gizmo-mobile-hud.test.js", + importFn: "runGizmoMobileHudTests", + pattern: "ui/gizmo-mobile-hud", + }, + { + id: "contextmenu", + name: "Context Menu Tests", + importPath: "./contextmenu.test.js", + importFn: "runContextMenuTests", + pattern: "ui/contextmenu", + }, { id: "onscreencontrols", name: "On-Screen Controls Tests", From ce48d1fc9d694d9b1a61d669559754a9076afb58 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:33:16 +0100 Subject: [PATCH 3/9] Fix EyeButton issue --- tests/gizmos.test.js | 15 +++++++++++---- ui/gizmos.js | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/gizmos.test.js b/tests/gizmos.test.js index 621a8b66..a4d0f1e0 100644 --- a/tests/gizmos.test.js +++ b/tests/gizmos.test.js @@ -432,6 +432,7 @@ export function runGizmoTests(flock) { 'duplicateButton', 'deleteButton', 'cameraButton', + 'eyeButton', 'showShapesButton', 'scrollModelsLeftButton', 'scrollModelsRightButton', @@ -440,10 +441,7 @@ export function runGizmoTests(flock) { 'scrollCharactersLeftButton', 'scrollCharactersRightButton', ]; - // eyeButton isn't in the "required" guard list, but enableGizmos reads it - // unconditionally (no optional chaining) once past that guard, so it must - // exist too or enableGizmos throws. - const EXTRA_IDS = ['eyeButton']; + const EXTRA_IDS = []; let created; @@ -472,6 +470,15 @@ export function runGizmoTests(flock) { expect(document.getElementById(REQUIRED_IDS[1]).hasAttribute('disabled')).to.be.true; }); + it('does nothing (and does not throw) when only eyeButton is missing', function () { + // Regression test: eyeButton used to be absent from the required-button + // guard but was still accessed unconditionally further down, so a + // missing eyeButton alone used to pass the guard and then throw. + REQUIRED_IDS.filter((id) => id !== 'eyeButton').forEach((id) => addButton(id)); + expect(() => enableGizmos()).to.not.throw(); + expect(document.getElementById('positionButton').hasAttribute('disabled')).to.be.true; + }); + it('removes the disabled attribute from every button once all required ones exist', function () { REQUIRED_IDS.forEach((id) => addButton(id)); EXTRA_IDS.forEach((id) => addButton(id)); diff --git a/ui/gizmos.js b/ui/gizmos.js index a5ed91c1..fc59585f 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -2451,6 +2451,7 @@ export function enableGizmos() { duplicateButton, deleteButton, cameraButton, + eyeButton, showShapesButton, scrollModelsLeftButton, scrollModelsRightButton, From 7b91106a9f71177b5fa90386829507a19f098b7e Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:40:14 +0100 Subject: [PATCH 4/9] Fix issues from /codereview --- tests/axis-keyboard.test.js | 242 ++++++++++----------- tests/blocklyshadowutil.test.js | 102 +++++---- tests/canvas-utils.test.js | 49 ++--- tests/contextmenu.test.js | 200 ++++++++--------- tests/gizmo-mobile-hud.test.js | 136 ++++++------ tests/gizmos.test.js | 11 +- tests/utils/keyboardDispatcherTestUtils.js | 49 +++++ 7 files changed, 402 insertions(+), 387 deletions(-) create mode 100644 tests/utils/keyboardDispatcherTestUtils.js diff --git a/tests/axis-keyboard.test.js b/tests/axis-keyboard.test.js index a94976a8..05e4e0cf 100644 --- a/tests/axis-keyboard.test.js +++ b/tests/axis-keyboard.test.js @@ -1,34 +1,10 @@ -import { expect } from "chai"; -import { createAxisKeyboardHandler } from "../ui/axis-keyboard.js"; -import { KeyboardDispatcher } from "../main/keyboardDispatcher.js"; - -function topHandler() { - const top = KeyboardDispatcher._modeStack[KeyboardDispatcher._modeStack.length - 1]; - return top?.handler; -} - -function makeEvent(overrides = {}) { - return { - target: document.body, - key: "", - shiftKey: false, - ctrlKey: false, - metaKey: false, - altKey: false, - defaultPrevented: false, - propagationStopped: false, - preventDefault() { - this.defaultPrevented = true; - }, - stopPropagation() { - this.propagationStopped = true; - }, - ...overrides, - }; -} +import { expect } from 'chai'; +import { createAxisKeyboardHandler } from '../ui/axis-keyboard.js'; +import { KeyboardDispatcher } from '../main/keyboardDispatcher.js'; +import { topHandler, makeKeyEvent as makeEvent } from './utils/keyboardDispatcherTestUtils.js'; export function runAxisKeyboardTests(flock) { - describe("ui/axis-keyboard @axiskeyboard", function () { + describe('ui/axis-keyboard @axiskeyboard', function () { let stop; let moves; let axisChanges; @@ -59,21 +35,21 @@ export function runAxisKeyboardTests(flock) { stop = null; }); - describe("mode registration", function () { - it("pushes exactly one mode onto the KeyboardDispatcher stack", function () { + describe('mode registration', function () { + it('pushes exactly one mode onto the KeyboardDispatcher stack', function () { const before = KeyboardDispatcher._modeStack.length; make(); expect(KeyboardDispatcher._modeStack.length).to.equal(before + 1); }); - it("stop() pops the mode back off the stack", function () { + it('stop() pops the mode back off the stack', function () { const before = KeyboardDispatcher._modeStack.length; make(); stop(); expect(KeyboardDispatcher._modeStack.length).to.equal(before); }); - it("stop() is idempotent (a second call does not pop an extra mode)", function () { + it('stop() is idempotent (a second call does not pop an extra mode)', function () { const before = KeyboardDispatcher._modeStack.length; make(); stop(); @@ -82,274 +58,274 @@ export function runAxisKeyboardTests(flock) { }); }); - describe("axis toggling (x/y/z)", function () { + describe('axis toggling (x/y/z)', function () { it("'x' locks to the x axis and reports it via onAxisChange", function () { make(); - topHandler()(makeEvent({ key: "x" })); - expect(stop.getAxis()).to.equal("x"); - expect(axisChanges).to.deep.equal(["x"]); + topHandler()(makeEvent({ key: 'x' })); + expect(stop.getAxis()).to.equal('x'); + expect(axisChanges).to.deep.equal(['x']); }); it("pressing 'x' again unlocks back to free movement", function () { make(); - topHandler()(makeEvent({ key: "x" })); - topHandler()(makeEvent({ key: "x" })); + topHandler()(makeEvent({ key: 'x' })); + topHandler()(makeEvent({ key: 'x' })); expect(stop.getAxis()).to.equal(null); - expect(axisChanges).to.deep.equal(["x", null]); + expect(axisChanges).to.deep.equal(['x', null]); }); it("'Y' (uppercase) locks to the y axis", function () { make(); - topHandler()(makeEvent({ key: "Y" })); - expect(stop.getAxis()).to.equal("y"); + topHandler()(makeEvent({ key: 'Y' })); + expect(stop.getAxis()).to.equal('y'); }); it("'z' locks to the z axis", function () { make(); - topHandler()(makeEvent({ key: "z" })); - expect(stop.getAxis()).to.equal("z"); + topHandler()(makeEvent({ key: 'z' })); + expect(stop.getAxis()).to.equal('z'); }); - it("switching from x to y replaces the lock rather than combining", function () { + it('switching from x to y replaces the lock rather than combining', function () { make(); - topHandler()(makeEvent({ key: "x" })); - topHandler()(makeEvent({ key: "y" })); - expect(stop.getAxis()).to.equal("y"); + topHandler()(makeEvent({ key: 'x' })); + topHandler()(makeEvent({ key: 'y' })); + expect(stop.getAxis()).to.equal('y'); }); }); describe("uniform axis ('u') gated by allowUniform", function () { - it("is ignored when allowUniform is false (default)", function () { + it('is ignored when allowUniform is false (default)', function () { make(); - topHandler()(makeEvent({ key: "u" })); + topHandler()(makeEvent({ key: 'u' })); expect(stop.getAxis()).to.equal(null); expect(axisChanges).to.deep.equal([]); }); - it("does not call preventDefault when ignored", function () { + it('does not call preventDefault when ignored', function () { make(); - const event = makeEvent({ key: "u" }); + const event = makeEvent({ key: 'u' }); topHandler()(event); expect(event.defaultPrevented).to.equal(false); }); it("locks to 'all' when allowUniform is true", function () { make({ allowUniform: true }); - topHandler()(makeEvent({ key: "u" })); - expect(stop.getAxis()).to.equal("all"); - expect(axisChanges).to.deep.equal(["all"]); + topHandler()(makeEvent({ key: 'u' })); + expect(stop.getAxis()).to.equal('all'); + expect(axisChanges).to.deep.equal(['all']); }); it("toggles 'all' back off on a second 'u' press", function () { make({ allowUniform: true }); - topHandler()(makeEvent({ key: "u" })); - topHandler()(makeEvent({ key: "u" })); + topHandler()(makeEvent({ key: 'u' })); + topHandler()(makeEvent({ key: 'u' })); expect(stop.getAxis()).to.equal(null); }); }); - describe("initialAxis / normalizeAxis", function () { + describe('initialAxis / normalizeAxis', function () { it("collapses an initial 'all' to 'x' when allowUniform is false", function () { - make({ initialAxis: "all", allowUniform: false }); - expect(stop.getAxis()).to.equal("x"); + make({ initialAxis: 'all', allowUniform: false }); + expect(stop.getAxis()).to.equal('x'); }); it("keeps an initial 'all' when allowUniform is true", function () { - make({ initialAxis: "all", allowUniform: true }); - expect(stop.getAxis()).to.equal("all"); + make({ initialAxis: 'all', allowUniform: true }); + expect(stop.getAxis()).to.equal('all'); }); - it("respects a single-axis initialAxis regardless of allowUniform", function () { - make({ initialAxis: "z" }); - expect(stop.getAxis()).to.equal("z"); + it('respects a single-axis initialAxis regardless of allowUniform', function () { + make({ initialAxis: 'z' }); + expect(stop.getAxis()).to.equal('z'); }); }); - describe("stop.setAxis", function () { - it("updates the current axis", function () { + describe('stop.setAxis', function () { + it('updates the current axis', function () { make(); - stop.setAxis("y"); - expect(stop.getAxis()).to.equal("y"); + stop.setAxis('y'); + expect(stop.getAxis()).to.equal('y'); }); it("normalizes 'all' to 'x' when allowUniform is false", function () { make({ allowUniform: false }); - stop.setAxis("all"); - expect(stop.getAxis()).to.equal("x"); + stop.setAxis('all'); + expect(stop.getAxis()).to.equal('x'); }); }); - describe("arrow keys — free movement (no axis locked)", function () { + describe('arrow keys — free movement (no axis locked)', function () { it("ArrowRight moves +x and reports axis 'x'", function () { make(); - topHandler()(makeEvent({ key: "ArrowRight" })); + topHandler()(makeEvent({ key: 'ArrowRight' })); expect(moves).to.deep.equal([[1, 0, 0]]); - expect(axisChanges).to.deep.equal(["x"]); + expect(axisChanges).to.deep.equal(['x']); }); it("ArrowLeft moves -x and reports axis 'x'", function () { make(); - topHandler()(makeEvent({ key: "ArrowLeft" })); + topHandler()(makeEvent({ key: 'ArrowLeft' })); expect(moves).to.deep.equal([[-1, 0, 0]]); - expect(axisChanges).to.deep.equal(["x"]); + expect(axisChanges).to.deep.equal(['x']); }); it("ArrowUp moves +z (depth) and reports axis 'z', not y", function () { make(); - topHandler()(makeEvent({ key: "ArrowUp" })); + topHandler()(makeEvent({ key: 'ArrowUp' })); expect(moves).to.deep.equal([[0, 0, 1]]); - expect(axisChanges).to.deep.equal(["z"]); + expect(axisChanges).to.deep.equal(['z']); }); it("ArrowDown moves -z and reports axis 'z'", function () { make(); - topHandler()(makeEvent({ key: "ArrowDown" })); + topHandler()(makeEvent({ key: 'ArrowDown' })); expect(moves).to.deep.equal([[0, 0, -1]]); - expect(axisChanges).to.deep.equal(["z"]); + expect(axisChanges).to.deep.equal(['z']); }); }); - describe("arrow keys — axis locked", function () { + describe('arrow keys — axis locked', function () { it("moves only along the locked axis, ignoring the arrow's own direction axis", function () { make(); - stop.setAxis("y"); - topHandler()(makeEvent({ key: "ArrowRight" })); + stop.setAxis('y'); + topHandler()(makeEvent({ key: 'ArrowRight' })); expect(moves).to.deep.equal([[0, 1, 0]]); }); - it("ArrowLeft/Down still apply the negative sign on the locked axis", function () { + it('ArrowLeft/Down still apply the negative sign on the locked axis', function () { make(); - stop.setAxis("y"); - topHandler()(makeEvent({ key: "ArrowLeft" })); + stop.setAxis('y'); + topHandler()(makeEvent({ key: 'ArrowLeft' })); expect(moves).to.deep.equal([[0, -1, 0]]); }); it("locking to 'all' moves every axis together", function () { make({ allowUniform: true }); - stop.setAxis("all"); - topHandler()(makeEvent({ key: "ArrowRight" })); + stop.setAxis('all'); + topHandler()(makeEvent({ key: 'ArrowRight' })); expect(moves).to.deep.equal([[1, 1, 1]]); }); - it("does not fire onAxisChange again while already locked", function () { + it('does not fire onAxisChange again while already locked', function () { make(); - stop.setAxis("y"); + stop.setAxis('y'); axisChanges.length = 0; - topHandler()(makeEvent({ key: "ArrowRight" })); + topHandler()(makeEvent({ key: 'ArrowRight' })); expect(axisChanges).to.deep.equal([]); }); }); - describe("step size (shiftKey)", function () { - it("uses stepFast by default (no shift)", function () { + describe('step size (shiftKey)', function () { + it('uses stepFast by default (no shift)', function () { make({ stepNormal: 0.1, stepFast: 5 }); - topHandler()(makeEvent({ key: "ArrowRight" })); + topHandler()(makeEvent({ key: 'ArrowRight' })); expect(moves).to.deep.equal([[5, 0, 0]]); }); - it("uses stepNormal (finer) when shiftKey is held", function () { + it('uses stepNormal (finer) when shiftKey is held', function () { make({ stepNormal: 0.1, stepFast: 5 }); - topHandler()(makeEvent({ key: "ArrowRight", shiftKey: true })); + topHandler()(makeEvent({ key: 'ArrowRight', shiftKey: true })); expect(moves).to.deep.equal([[0.1, 0, 0]]); }); }); - describe("PageUp / PageDown", function () { - it("PageUp moves +y when no axis is locked", function () { + describe('PageUp / PageDown', function () { + it('PageUp moves +y when no axis is locked', function () { make(); - topHandler()(makeEvent({ key: "PageUp" })); + topHandler()(makeEvent({ key: 'PageUp' })); expect(moves).to.deep.equal([[0, 1, 0]]); - expect(axisChanges).to.deep.equal(["y"]); + expect(axisChanges).to.deep.equal(['y']); }); - it("PageDown moves -y when no axis is locked", function () { + it('PageDown moves -y when no axis is locked', function () { make(); - topHandler()(makeEvent({ key: "PageDown" })); + topHandler()(makeEvent({ key: 'PageDown' })); expect(moves).to.deep.equal([[0, -1, 0]]); }); it("PageUp moves all axes together when locked to 'all'", function () { make({ allowUniform: true }); - stop.setAxis("all"); - topHandler()(makeEvent({ key: "PageUp" })); + stop.setAxis('all'); + topHandler()(makeEvent({ key: 'PageUp' })); expect(moves).to.deep.equal([[1, 1, 1]]); }); - it("is a no-op when locked to a single specific axis (x/y/z)", function () { + it('is a no-op when locked to a single specific axis (x/y/z)', function () { make(); - stop.setAxis("x"); - topHandler()(makeEvent({ key: "PageUp" })); + stop.setAxis('x'); + topHandler()(makeEvent({ key: 'PageUp' })); expect(moves).to.deep.equal([]); }); }); - describe("Enter / Space (confirm)", function () { - it("Enter calls onConfirm and stops (pops the mode)", function () { + describe('Enter / Space (confirm)', function () { + it('Enter calls onConfirm and stops (pops the mode)', function () { const before = KeyboardDispatcher._modeStack.length; make(); - topHandler()(makeEvent({ key: "Enter" })); + topHandler()(makeEvent({ key: 'Enter' })); expect(confirmed).to.equal(1); expect(KeyboardDispatcher._modeStack.length).to.equal(before); }); - it("Space also confirms", function () { + it('Space also confirms', function () { make(); - topHandler()(makeEvent({ key: " " })); + topHandler()(makeEvent({ key: ' ' })); expect(confirmed).to.equal(1); }); - it("getAxis() reads null after stop via confirm", function () { + it('getAxis() reads null after stop via confirm', function () { make(); - topHandler()(makeEvent({ key: "x" })); - topHandler()(makeEvent({ key: "Enter" })); + topHandler()(makeEvent({ key: 'x' })); + topHandler()(makeEvent({ key: 'Enter' })); expect(stop.getAxis()).to.equal(null); }); }); - describe("Escape (cancel)", function () { - it("calls onCancel and stops without calling onConfirm", function () { + describe('Escape (cancel)', function () { + it('calls onCancel and stops without calling onConfirm', function () { make(); - topHandler()(makeEvent({ key: "Escape" })); + topHandler()(makeEvent({ key: 'Escape' })); expect(cancelled).to.equal(1); expect(confirmed).to.equal(0); }); }); - describe("ignored input", function () { - it("ignores the event when ctrlKey is held", function () { + describe('ignored input', function () { + it('ignores the event when ctrlKey is held', function () { make(); - topHandler()(makeEvent({ key: "x", ctrlKey: true })); + topHandler()(makeEvent({ key: 'x', ctrlKey: true })); expect(stop.getAxis()).to.equal(null); }); - it("ignores the event when metaKey is held", function () { + it('ignores the event when metaKey is held', function () { make(); - topHandler()(makeEvent({ key: "ArrowRight", metaKey: true })); + topHandler()(makeEvent({ key: 'ArrowRight', metaKey: true })); expect(moves).to.deep.equal([]); }); - it("ignores the event when altKey is held", function () { + it('ignores the event when altKey is held', function () { make(); - topHandler()(makeEvent({ key: "x", altKey: true })); + topHandler()(makeEvent({ key: 'x', altKey: true })); expect(stop.getAxis()).to.equal(null); }); - it("ignores keys typed into an element", function () { + it('ignores keys typed into an element', function () { make(); - const input = document.createElement("input"); - topHandler()(makeEvent({ key: "ArrowRight", target: input })); + const input = document.createElement('input'); + topHandler()(makeEvent({ key: 'ArrowRight', target: input })); expect(moves).to.deep.equal([]); }); - it("ignores keys while focus is inside #codePanel", function () { + it('ignores keys while focus is inside #codePanel', function () { make(); - const panel = document.createElement("div"); - panel.id = "codePanel"; - const child = document.createElement("span"); + const panel = document.createElement('div'); + panel.id = 'codePanel'; + const child = document.createElement('span'); panel.appendChild(child); document.body.appendChild(panel); try { - topHandler()(makeEvent({ key: "ArrowRight", target: child })); + topHandler()(makeEvent({ key: 'ArrowRight', target: child })); expect(moves).to.deep.equal([]); } finally { panel.remove(); diff --git a/tests/blocklyshadowutil.test.js b/tests/blocklyshadowutil.test.js index c2f80c75..434c820d 100644 --- a/tests/blocklyshadowutil.test.js +++ b/tests/blocklyshadowutil.test.js @@ -1,4 +1,4 @@ -import { expect } from "chai"; +import { expect } from 'chai'; import { roundPositionValue, addNumberShadow, @@ -7,49 +7,49 @@ import { addPositionShadows, addColourShadowSpec, buildColorsListShadowSpec, -} from "../ui/blocklyshadowutil.js"; +} from '../ui/blocklyshadowutil.js'; export function runBlocklyShadowUtilTests() { - describe("ui/blocklyshadowutil @blocklyshadowutil", function () { - describe("roundPositionValue", function () { - it("rounds to 1 decimal place by default", function () { + describe('ui/blocklyshadowutil @blocklyshadowutil', function () { + describe('roundPositionValue', function () { + it('rounds to 1 decimal place by default', function () { expect(roundPositionValue(1.23456)).to.equal(1.2); }); - it("rounds to the requested number of decimals", function () { + it('rounds to the requested number of decimals', function () { expect(roundPositionValue(1.23456, 3)).to.equal(1.235); }); - it("rounds to a whole number when decimals is 0", function () { + it('rounds to a whole number when decimals is 0', function () { expect(roundPositionValue(1.6, 0)).to.equal(2); }); }); - describe("addNumberShadow", function () { - it("adds a math_number shadow with the given value", function () { + describe('addNumberShadow', function () { + it('adds a math_number shadow with the given value', function () { const spec = {}; - addNumberShadow(spec, "X", 5); + addNumberShadow(spec, 'X', 5); expect(spec.inputs.X).to.deep.equal({ - shadow: { type: "math_number", fields: { NUM: 5 } }, + shadow: { type: 'math_number', fields: { NUM: 5 } }, }); }); - it("initializes spec.inputs if missing", function () { + it('initializes spec.inputs if missing', function () { const spec = {}; - addNumberShadow(spec, "Y", 1); + addNumberShadow(spec, 'Y', 1); expect(spec.inputs).to.exist; }); - it("preserves existing inputs on the spec", function () { - const spec = { inputs: { Z: { shadow: { type: "math_number", fields: { NUM: 9 } } } } }; - addNumberShadow(spec, "X", 5); + it('preserves existing inputs on the spec', function () { + const spec = { inputs: { Z: { shadow: { type: 'math_number', fields: { NUM: 9 } } } } }; + addNumberShadow(spec, 'X', 5); expect(spec.inputs.Z).to.exist; expect(spec.inputs.X).to.exist; }); }); - describe("addXYZShadows", function () { - it("adds rounded X, Y, Z number shadows from a position", function () { + describe('addXYZShadows', function () { + it('adds rounded X, Y, Z number shadows from a position', function () { const spec = {}; addXYZShadows(spec, { x: 1.23456, y: 2.987, z: -3.05 }); expect(spec.inputs.X.shadow.fields.NUM).to.equal(1.2); @@ -58,7 +58,7 @@ export function runBlocklyShadowUtilTests() { expect(spec.inputs.Z.shadow.fields.NUM).to.equal(-3); }); - it("defaults missing coordinates to 0", function () { + it('defaults missing coordinates to 0', function () { const spec = {}; addXYZShadows(spec, {}); expect(spec.inputs.X.shadow.fields.NUM).to.equal(0); @@ -66,7 +66,7 @@ export function runBlocklyShadowUtilTests() { expect(spec.inputs.Z.shadow.fields.NUM).to.equal(0); }); - it("defaults to 0,0,0 when pos is undefined", function () { + it('defaults to 0,0,0 when pos is undefined', function () { const spec = {}; addXYZShadows(spec, undefined); expect(spec.inputs.X.shadow.fields.NUM).to.equal(0); @@ -75,18 +75,18 @@ export function runBlocklyShadowUtilTests() { }); }); - describe("addColourShadow", function () { - it("adds a colour shadow of the given type with the given hex", function () { + describe('addColourShadow', function () { + it('adds a colour shadow of the given type with the given hex', function () { const spec = {}; - addColourShadow(spec, "COLOUR", "colour", "#ff0000"); + addColourShadow(spec, 'COLOUR', 'colour', '#ff0000'); expect(spec.inputs.COLOUR).to.deep.equal({ - shadow: { type: "colour", fields: { COLOR: "#ff0000" } }, + shadow: { type: 'colour', fields: { COLOR: '#ff0000' } }, }); }); }); - describe("addPositionShadows", function () { - it("adds rounded X, Y, Z number shadows the same as addXYZShadows", function () { + describe('addPositionShadows', function () { + it('adds rounded X, Y, Z number shadows the same as addXYZShadows', function () { const spec = {}; addPositionShadows(spec, { x: 1.26, y: -2.04, z: 0 }); expect(spec.inputs.X.shadow.fields.NUM).to.equal(1.3); @@ -95,54 +95,50 @@ export function runBlocklyShadowUtilTests() { }); }); - describe("addColourShadowSpec", function () { + describe('addColourShadowSpec', function () { it("defaults shadowType to 'colour'", function () { const spec = {}; - addColourShadowSpec(spec, "COLOUR", "#00ff00"); - expect(spec.inputs.COLOUR.shadow.type).to.equal("colour"); - expect(spec.inputs.COLOUR.shadow.fields.COLOR).to.equal("#00ff00"); + addColourShadowSpec(spec, 'COLOUR', '#00ff00'); + expect(spec.inputs.COLOUR.shadow.type).to.equal('colour'); + expect(spec.inputs.COLOUR.shadow.fields.COLOR).to.equal('#00ff00'); }); - it("accepts an explicit shadowType", function () { + it('accepts an explicit shadowType', function () { const spec = {}; - addColourShadowSpec(spec, "SKIN", "#abcdef", "skin_colour"); - expect(spec.inputs.SKIN.shadow.type).to.equal("skin_colour"); + addColourShadowSpec(spec, 'SKIN', '#abcdef', 'skin_colour'); + expect(spec.inputs.SKIN.shadow.type).to.equal('skin_colour'); }); }); - describe("buildColorsListShadowSpec", function () { + describe('buildColorsListShadowSpec', function () { it("uses the object's own colour list when one is registered", function () { - const listSpec = buildColorsListShadowSpec("Star.glb"); + const listSpec = buildColorsListShadowSpec('Star.glb'); expect(listSpec.extraState.itemCount).to.equal(3); - expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal("#FFD700"); - expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal("#FFD700"); - expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal("#FFD700"); + expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal('#FFD700'); + expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal('#FFD700'); + expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal('#FFD700'); }); it("builds a lists_create_with spec sized to the object's colour list", function () { // "cat" isn't in objectColours, so this exercises the 3-colour fallback. - const listSpec = buildColorsListShadowSpec("__unknown_object__"); - expect(listSpec.type).to.equal("lists_create_with"); + const listSpec = buildColorsListShadowSpec('__unknown_object__'); + expect(listSpec.type).to.equal('lists_create_with'); expect(listSpec.extraState.itemCount).to.equal(3); expect(listSpec.mutation.items).to.equal(3); - expect(Object.keys(listSpec.inputs)).to.deep.equal([ - "ADD0", - "ADD1", - "ADD2", - ]); + expect(Object.keys(listSpec.inputs)).to.deep.equal(['ADD0', 'ADD1', 'ADD2']); }); - it("falls back to a black/white/grey palette for unknown objects", function () { - const listSpec = buildColorsListShadowSpec("__unknown_object__"); - expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal("#000000"); - expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal("#FFFFFF"); - expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal("#CCCCCC"); + it('falls back to a black/white/grey palette for unknown objects', function () { + const listSpec = buildColorsListShadowSpec('__unknown_object__'); + expect(listSpec.inputs.ADD0.shadow.fields.COLOR).to.equal('#000000'); + expect(listSpec.inputs.ADD1.shadow.fields.COLOR).to.equal('#FFFFFF'); + expect(listSpec.inputs.ADD2.shadow.fields.COLOR).to.equal('#CCCCCC'); }); - it("every generated input is a colour shadow", function () { - const listSpec = buildColorsListShadowSpec("__unknown_object__"); + it('every generated input is a colour shadow', function () { + const listSpec = buildColorsListShadowSpec('__unknown_object__'); for (const input of Object.values(listSpec.inputs)) { - expect(input.shadow.type).to.equal("colour"); + expect(input.shadow.type).to.equal('colour'); } }); }); diff --git a/tests/canvas-utils.test.js b/tests/canvas-utils.test.js index 274839ba..cbb5da13 100644 --- a/tests/canvas-utils.test.js +++ b/tests/canvas-utils.test.js @@ -11,28 +11,14 @@ import { setDefaultCursor, } from '../ui/canvas-utils.js'; import { KeyboardDispatcher } from '../main/keyboardDispatcher.js'; - -function topHandler() { - const top = KeyboardDispatcher._modeStack[KeyboardDispatcher._modeStack.length - 1]; - return top?.handler; -} - -function dispatchKeyup(key) { - document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); -} - -function keyEvent(overrides = {}) { - return { - target: document.body, - key: '', - shiftKey: false, - preventDefault() {}, - stopPropagation() {}, - ...overrides, - }; -} +import { + topHandler, + makeKeyEvent as keyEvent, + dispatchKeyup, +} from './utils/keyboardDispatcherTestUtils.js'; export function runCanvasUtilsTests(flock) { + /* Do NOT assume that the CanvasCircle is in any particular place - set it at the start of each test */ describe('ui/canvas-utils @canvasutils', function () { afterEach(function () { // These are module-level singletons shared across the whole test @@ -114,12 +100,25 @@ export function runCanvasUtilsTests(flock) { }); describe('clickCanvasCircle', function () { - it('invokes the callback with the current position when a circle exists', function () { + it('invokes the callback with the actual current circle position', function () { createCanvasCircle(); - moveCanvasCircle(0, 0); - let received = null; - clickCanvasCircle((x, y) => (received = [x, y])); - expect(received).to.be.an('array').with.lengthOf(2); + // canvasCirclePosition is a module-level singleton that persists across + // tests, so anchor to a known-safe interior position first — otherwise + // leftover position from an earlier clamping test could sit right at + // a boundary and silently absorb this test's delta. + moveCanvasCircle(100000, 100000); + moveCanvasCircle(-310, -175); + + let before = null; + clickCanvasCircle((x, y) => (before = [x, y])); + + moveCanvasCircle(15, -7); + + let after = null; + clickCanvasCircle((x, y) => (after = [x, y])); + + expect(after[0] - before[0]).to.be.closeTo(15, 0.01); + expect(after[1] - before[1]).to.be.closeTo(-7, 0.01); }); it('does not invoke the callback when no circle exists', function () { diff --git a/tests/contextmenu.test.js b/tests/contextmenu.test.js index 00a39732..6bca6d0f 100644 --- a/tests/contextmenu.test.js +++ b/tests/contextmenu.test.js @@ -1,25 +1,32 @@ -import { expect } from "chai"; -import * as Blockly from "blockly"; -import { initContextMenus } from "../ui/contextmenu.js"; -import { setBlockLocked, isBlockLocked } from "../ui/blocklyutil.js"; -import { defineControlBlocks } from "../blocks/control.js"; +import { expect } from 'chai'; +import * as Blockly from 'blockly'; +import { initContextMenus } from '../ui/contextmenu.js'; +import { setBlockLocked, isBlockLocked } from '../ui/blocklyutil.js'; +import { defineControlBlocks } from '../blocks/control.js'; +import { translate } from '../main/translation.js'; export function runContextMenuTests(flock) { - describe("ui/contextmenu @contextmenu", function () { + describe('ui/contextmenu @contextmenu', function () { this.timeout(10000); let workspace; let container; let createdBlocks; + let previousMainWorkspace; before(function () { // The full app registers block types during its Blockly-init sequence, // which this lightweight test harness doesn't run — register the one // block type these tests need directly. defineControlBlocks(); - container = document.createElement("div"); - container.style.width = "300px"; - container.style.height = "200px"; + // Blockly.inject() installs its result as the global main workspace; + // save whatever it was so after() can put it back rather than leaving + // a disposed workspace as "main" for suites that run later in the same + // browser session. + previousMainWorkspace = Blockly.getMainWorkspace?.(); + container = document.createElement('div'); + container.style.width = '300px'; + container.style.height = '200px'; document.body.appendChild(container); workspace = Blockly.inject(container, { collapse: true }); initContextMenus(workspace); @@ -28,6 +35,13 @@ export function runContextMenuTests(flock) { after(function () { workspace?.dispose(); container?.remove(); + // setMainWorkspace(null) itself throws (it dereferences the workspace + // it's given), so only restore when there was a real prior workspace — + // otherwise leave things as Blockly's own inject/dispose left them, + // matching pre-existing behaviour for that case. + if (previousMainWorkspace) { + Blockly.common?.setMainWorkspace?.(previousMainWorkspace); + } }); beforeEach(function () { @@ -41,7 +55,7 @@ export function runContextMenuTests(flock) { createdBlocks = []; }); - function makeBlock(type = "wait") { + function makeBlock(type = 'wait') { const block = workspace.newBlock(type); block.initSvg(); block.render(); @@ -53,134 +67,124 @@ export function runContextMenuTests(flock) { return Blockly.ContextMenuRegistry.registry.getItem(id); } - describe("detachBlockWithShortcut", function () { - it("is hidden for a block in a flyout", function () { + describe('detachBlockWithShortcut', function () { + it('is hidden for a block in a flyout', function () { const block = makeBlock(); block.isInFlyout = true; - expect(getItem("detachBlockWithShortcut").preconditionFn({ block })).to.equal( - "hidden", - ); + expect(getItem('detachBlockWithShortcut').preconditionFn({ block })).to.equal('hidden'); }); - it("is disabled for a top-level block with no parent", function () { + it('is disabled for a top-level block with no parent', function () { const block = makeBlock(); - expect(getItem("detachBlockWithShortcut").preconditionFn({ block })).to.equal( - "disabled", - ); + expect(getItem('detachBlockWithShortcut').preconditionFn({ block })).to.equal('disabled'); }); - it("is enabled once the block is connected below a parent", function () { + it('is enabled once the block is connected below a parent', function () { const parent = makeBlock(); const child = makeBlock(); parent.nextConnection.connect(child.previousConnection); - expect( - getItem("detachBlockWithShortcut").preconditionFn({ block: child }), - ).to.equal("enabled"); + expect(getItem('detachBlockWithShortcut').preconditionFn({ block: child })).to.equal( + 'enabled' + ); }); - it("callback unplugs the block from its parent", function () { + it('callback unplugs the block from its parent', function () { const parent = makeBlock(); const child = makeBlock(); parent.nextConnection.connect(child.previousConnection); - getItem("detachBlockWithShortcut").callback({ block: child }); + getItem('detachBlockWithShortcut').callback({ block: child }); expect(child.previousConnection.isConnected()).to.equal(false); }); }); - describe("viewBlockInCanvas", function () { - it("is hidden for a block with no associated mesh", function () { + describe('viewBlockInCanvas', function () { + it('is hidden for a block with no associated mesh', function () { const block = makeBlock(); - expect(getItem("viewBlockInCanvas").preconditionFn({ block })).to.equal( - "hidden", - ); + expect(getItem('viewBlockInCanvas').preconditionFn({ block })).to.equal('hidden'); }); - it("is hidden for a block in a flyout", function () { + it('is hidden for a block in a flyout', function () { const block = makeBlock(); block.isInFlyout = true; - expect(getItem("viewBlockInCanvas").preconditionFn({ block })).to.equal( - "hidden", - ); + expect(getItem('viewBlockInCanvas').preconditionFn({ block })).to.equal('hidden'); }); }); - describe("blockLock", function () { - it("stays enabled for a plain, unlocked block", function () { + describe('blockLock', function () { + it('stays enabled for a plain, unlocked block', function () { const block = makeBlock(); - expect(getItem("blockLock").preconditionFn({ block })).to.equal("enabled"); + expect(getItem('blockLock').preconditionFn({ block })).to.equal('enabled'); }); - it("stays enabled even when the block is already locked (so it can be unlocked)", function () { + it('stays enabled even when the block is already locked (so it can be unlocked)', function () { const block = makeBlock(); setBlockLocked(block, true); - expect(getItem("blockLock").preconditionFn({ block })).to.equal("enabled"); + expect(getItem('blockLock').preconditionFn({ block })).to.equal('enabled'); }); - it("callback toggles the locked state on and back off", function () { + it('callback toggles the locked state on and back off', function () { const block = makeBlock(); expect(isBlockLocked(block)).to.equal(false); - getItem("blockLock").callback({ block }); + getItem('blockLock').callback({ block }); expect(isBlockLocked(block)).to.equal(true); - getItem("blockLock").callback({ block }); + getItem('blockLock').callback({ block }); expect(isBlockLocked(block)).to.equal(false); }); - it("displayText differs between the locked and unlocked states", function () { + it('displayText differs between the locked and unlocked states', function () { const block = makeBlock(); - const unlockedText = getItem("blockLock").displayText({ block }); + const unlockedText = getItem('blockLock').displayText({ block }); setBlockLocked(block, true); - const lockedText = getItem("blockLock").displayText({ block }); + const lockedText = getItem('blockLock').displayText({ block }); expect(unlockedText).to.not.equal(lockedText); }); }); - describe("disableMutatingItemsWhenLocked", function () { - it("disables blockInline and blockDisable once the block is locked", function () { + describe('disableMutatingItemsWhenLocked', function () { + it('disables blockInline and blockDisable once the block is locked', function () { const block = makeBlock(); setBlockLocked(block, true); - for (const id of ["blockInline", "blockDisable"]) { + for (const id of ['blockInline', 'blockDisable']) { const item = getItem(id); expect(item, `expected ${id} to be registered`).to.exist; - expect(item.preconditionFn({ block })).to.equal("disabled"); + expect(item.preconditionFn({ block })).to.equal('disabled'); } }); - it("disables detachBlockWithShortcut even when otherwise connected", function () { + it('disables detachBlockWithShortcut even when otherwise connected', function () { const parent = makeBlock(); const child = makeBlock(); parent.nextConnection.connect(child.previousConnection); setBlockLocked(child, true); - expect( - getItem("detachBlockWithShortcut").preconditionFn({ block: child }), - ).to.equal("disabled"); + expect(getItem('detachBlockWithShortcut').preconditionFn({ block: child })).to.equal( + 'disabled' + ); }); it("leaves the item's normal behaviour intact when unlocked", function () { const block = makeBlock(); - expect(getItem("blockInline").preconditionFn({ block })).to.not.equal( - "disabled", - ); + expect(getItem('blockInline').preconditionFn({ block })).to.not.equal('disabled'); }); }); - describe("flockCollapseExpandWorkspace", function () { - it("is hidden when the workspace has no top-level blocks", function () { + describe('flockCollapseExpandWorkspace', function () { + it('is hidden when the workspace has no top-level blocks', function () { expect(workspace.getTopBlocks(false)).to.have.lengthOf(0); - expect( - getItem("flockCollapseExpandWorkspace").preconditionFn({ workspace }), - ).to.equal("hidden"); + expect(getItem('flockCollapseExpandWorkspace').preconditionFn({ workspace })).to.equal( + 'hidden' + ); }); - it("is enabled once a top-level block exists", function () { + it('is enabled once a top-level block exists', function () { makeBlock(); - expect( - getItem("flockCollapseExpandWorkspace").preconditionFn({ workspace }), - ).to.equal("enabled"); + expect(getItem('flockCollapseExpandWorkspace').preconditionFn({ workspace })).to.equal( + 'enabled' + ); }); - it("callback collapses all top blocks, then expands them again on a second call", function () { + it('callback collapses all top blocks, then expands them again on a second call', function () { const block = makeBlock(); - const item = getItem("flockCollapseExpandWorkspace"); + const item = getItem('flockCollapseExpandWorkspace'); item.callback({ workspace }); expect(block.isCollapsed()).to.equal(true); item.callback({ workspace }); @@ -188,28 +192,26 @@ export function runContextMenuTests(flock) { }); }); - describe("workspaceDelete rename", function () { - it("relabels the built-in delete-all item away from its default text", function () { - const item = getItem("workspaceDelete"); + describe('workspaceDelete rename', function () { + it('relabels the built-in delete-all item to the translated flock label', function () { + const item = getItem('workspaceDelete'); expect(item).to.exist; const text = item.displayText({ workspace }); - expect(text).to.be.a("string").and.not.equal("DELETE_ALL_BLOCKS"); + expect(text).to.equal(translate('context_delete_all_blocks_option')); }); }); - describe("workspaceFindInWorkspace", function () { - it("is always enabled", function () { - expect(getItem("workspaceFindInWorkspace").preconditionFn({})).to.equal( - "enabled", - ); + describe('workspaceFindInWorkspace', function () { + it('is always enabled', function () { + expect(getItem('workspaceFindInWorkspace').preconditionFn({})).to.equal('enabled'); }); - it("callback opens window.flockWorkspaceSearch", function () { + it('callback opens window.flockWorkspaceSearch', function () { let opened = false; const saved = window.flockWorkspaceSearch; window.flockWorkspaceSearch = { open: () => (opened = true) }; try { - getItem("workspaceFindInWorkspace").callback({}); + getItem('workspaceFindInWorkspace').callback({}); expect(opened).to.equal(true); } finally { window.flockWorkspaceSearch = saved; @@ -217,46 +219,46 @@ export function runContextMenuTests(flock) { }); }); - describe("clipboard: cut / copy / paste", function () { - it("blockCopy is hidden in a flyout and enabled otherwise", function () { + describe('clipboard: cut / copy / paste', function () { + it('blockCopy is hidden in a flyout and enabled otherwise', function () { const block = makeBlock(); - expect(getItem("blockCopy").preconditionFn({ block })).to.equal("enabled"); + expect(getItem('blockCopy').preconditionFn({ block })).to.equal('enabled'); block.isInFlyout = true; - expect(getItem("blockCopy").preconditionFn({ block })).to.equal("hidden"); + expect(getItem('blockCopy').preconditionFn({ block })).to.equal('hidden'); }); - it("blockCut is disabled on a locked block", function () { + it('blockCut is disabled on a locked block', function () { const block = makeBlock(); setBlockLocked(block, true); - expect(getItem("blockCut").preconditionFn({ block })).to.equal("disabled"); + expect(getItem('blockCut').preconditionFn({ block })).to.equal('disabled'); }); - it("blockCut disposes the block after copying it", function () { + it('blockCut disposes the block after copying it', function () { const block = makeBlock(); - getItem("blockCut").callback({ block }); + getItem('blockCut').callback({ block }); expect(block.disposed).to.equal(true); createdBlocks = createdBlocks.filter((b) => b !== block); }); - it("blockPaste is disabled with no copied data", function () { - Blockly.clipboard.getLastCopiedData?.(); - // Force a known empty-clipboard state via a fresh copy/cut cycle isn't - // reliable across test order, so only assert the shape of the result. - const block = makeBlock(); - const result = getItem("blockPaste").preconditionFn({ block }); - expect(["enabled", "disabled"]).to.include(result); + it('blockPaste is disabled with no copied data', function () { + const saved = Blockly.clipboard.getLastCopiedData(); + Blockly.clipboard.setLastCopiedData(null); + try { + const block = makeBlock(); + expect(getItem('blockPaste').preconditionFn({ block })).to.equal('disabled'); + } finally { + Blockly.clipboard.setLastCopiedData(saved); + } }); - it("blockPaste becomes enabled after a copy, and pastes a new block stacked after the target", function () { + it('blockPaste becomes enabled after a copy, and pastes a new block stacked after the target', function () { const source = makeBlock(); - getItem("blockCopy").callback({ block: source }); - expect(getItem("blockPaste").preconditionFn({ block: source })).to.equal( - "enabled", - ); + getItem('blockCopy').callback({ block: source }); + expect(getItem('blockPaste').preconditionFn({ block: source })).to.equal('enabled'); const target = makeBlock(); const before = workspace.getAllBlocks(false).length; - getItem("blockPaste").callback({ block: target }); + getItem('blockPaste').callback({ block: target }); const after = workspace.getAllBlocks(false).length; expect(after).to.equal(before + 1); diff --git a/tests/gizmo-mobile-hud.test.js b/tests/gizmo-mobile-hud.test.js index 052a1c2d..560d322c 100644 --- a/tests/gizmo-mobile-hud.test.js +++ b/tests/gizmo-mobile-hud.test.js @@ -1,8 +1,8 @@ -import { expect } from "chai"; -import { createGizmoMobileHud } from "../ui/gizmo-mobile-hud.js"; +import { expect } from 'chai'; +import { createGizmoMobileHud } from '../ui/gizmo-mobile-hud.js'; function findHud(flock) { - return flock.scene.textures.find((t) => t.name === "GizmoHUD"); + return flock.scene.textures.find((t) => t.name === 'GizmoHUD'); } function findControl(flock, name) { @@ -11,7 +11,7 @@ function findControl(flock, name) { } export function runGizmoMobileHudTests(flock) { - describe("ui/gizmo-mobile-hud @gizmomobilehud", function () { + describe('ui/gizmo-mobile-hud @gizmomobilehud', function () { let stop; let moves; let axisChanges; @@ -36,8 +36,8 @@ export function runGizmoMobileHudTests(flock) { flock._joystickSource = undefined; }); - describe("guard clause", function () { - it("returns null when flock.GUI is unavailable", function () { + describe('guard clause', function () { + it('returns null when flock.GUI is unavailable', function () { const savedGUI = flock.GUI; flock.GUI = undefined; try { @@ -49,20 +49,20 @@ export function runGizmoMobileHudTests(flock) { }); }); - describe("lifecycle", function () { + describe('lifecycle', function () { it("creates a fullscreen 'GizmoHUD' texture on the scene", function () { make(); expect(findHud(flock)).to.exist; }); - it("stop() disposes the HUD texture", function () { + it('stop() disposes the HUD texture', function () { make(); stop(); expect(findHud(flock)).to.not.exist; stop = null; }); - it("stop() is idempotent", function () { + it('stop() is idempotent', function () { make(); expect(() => { stop(); @@ -71,7 +71,7 @@ export function runGizmoMobileHudTests(flock) { stop = null; }); - it("hides existing on-screen controls while active and restores them on stop", function () { + it('hides existing on-screen controls while active and restores them on stop', function () { flock.controlsTexture = { rootContainer: { isVisible: true } }; make(); expect(flock.controlsTexture.rootContainer.isVisible).to.equal(false); @@ -80,7 +80,7 @@ export function runGizmoMobileHudTests(flock) { stop = null; }); - it("pauses the joystick source while active and resumes it on stop", function () { + it('pauses the joystick source while active and resumes it on stop', function () { let paused = false; flock._joystickSource = { pause: () => (paused = true), @@ -94,108 +94,108 @@ export function runGizmoMobileHudTests(flock) { }); }); - describe("axis buttons", function () { - it("creates exactly x/y/z buttons by default (no uniform)", function () { + describe('axis buttons', function () { + it('creates exactly x/y/z buttons by default (no uniform)', function () { make(); - expect(findControl(flock, "gizmo-axis-x")).to.exist; - expect(findControl(flock, "gizmo-axis-y")).to.exist; - expect(findControl(flock, "gizmo-axis-z")).to.exist; - expect(findControl(flock, "gizmo-axis-all")).to.not.exist; + expect(findControl(flock, 'gizmo-axis-x')).to.exist; + expect(findControl(flock, 'gizmo-axis-y')).to.exist; + expect(findControl(flock, 'gizmo-axis-z')).to.exist; + expect(findControl(flock, 'gizmo-axis-all')).to.not.exist; }); it("also creates the uniform 'all' button when showUniform is true", function () { make({ showUniform: true }); - expect(findControl(flock, "gizmo-axis-all")).to.exist; + expect(findControl(flock, 'gizmo-axis-all')).to.exist; }); - it("clicking an axis button fires onAxisChange with that axis", function () { + it('clicking an axis button fires onAxisChange with that axis', function () { make(); - findControl(flock, "gizmo-axis-y").onPointerUpObservable.notifyObservers(); - expect(axisChanges).to.deep.equal(["y"]); + findControl(flock, 'gizmo-axis-y').onPointerUpObservable.notifyObservers(); + expect(axisChanges).to.deep.equal(['y']); }); - it("clicking the already-selected axis button does not re-fire onAxisChange", function () { - make({ initialAxis: "x" }); - findControl(flock, "gizmo-axis-x").onPointerUpObservable.notifyObservers(); + it('clicking the already-selected axis button does not re-fire onAxisChange', function () { + make({ initialAxis: 'x' }); + findControl(flock, 'gizmo-axis-x').onPointerUpObservable.notifyObservers(); expect(axisChanges).to.deep.equal([]); }); - it("stop.setAxis switches the active axis without throwing", function () { + it('stop.setAxis switches the active axis without throwing', function () { make(); - expect(() => stop.setAxis("z")).to.not.throw(); + expect(() => stop.setAxis('z')).to.not.throw(); }); - it("stop.setAxis ignores an unknown axis key", function () { - make({ initialAxis: "x" }); - stop.setAxis("bogus"); + it('stop.setAxis ignores an unknown axis key', function () { + make({ initialAxis: 'x' }); + stop.setAxis('bogus'); // Selecting y afterwards should still work normally if the bogus // setAxis call was safely ignored rather than corrupting state. - findControl(flock, "gizmo-axis-y").onPointerUpObservable.notifyObservers(); - expect(axisChanges).to.deep.equal(["y"]); + findControl(flock, 'gizmo-axis-y').onPointerUpObservable.notifyObservers(); + expect(axisChanges).to.deep.equal(['y']); }); }); describe("mode: 'arrows'", function () { - it("creates a negative and positive arrow button", function () { - make({ mode: "arrows" }); - expect(findControl(flock, "gizmo-arrow--1")).to.exist; - expect(findControl(flock, "gizmo-arrow-1")).to.exist; + it('creates a negative and positive arrow button', function () { + make({ mode: 'arrows' }); + expect(findControl(flock, 'gizmo-arrow--1')).to.exist; + expect(findControl(flock, 'gizmo-arrow-1')).to.exist; }); - it("pressing the positive arrow immediately moves +stepNormal on the current axis", function () { - make({ mode: "arrows", initialAxis: "x", stepNormal: 0.1, stepFast: 1 }); - findControl(flock, "gizmo-arrow-1").onPointerDownObservable.notifyObservers(); + it('pressing the positive arrow immediately moves +stepNormal on the current axis', function () { + make({ mode: 'arrows', initialAxis: 'x', stepNormal: 0.1, stepFast: 1 }); + findControl(flock, 'gizmo-arrow-1').onPointerDownObservable.notifyObservers(); expect(moves).to.deep.equal([[0.1, 0, 0]]); }); - it("pressing the negative arrow immediately moves -stepNormal", function () { - make({ mode: "arrows", initialAxis: "y", stepNormal: 0.1, stepFast: 1 }); - findControl(flock, "gizmo-arrow--1").onPointerDownObservable.notifyObservers(); + it('pressing the negative arrow immediately moves -stepNormal', function () { + make({ mode: 'arrows', initialAxis: 'y', stepNormal: 0.1, stepFast: 1 }); + findControl(flock, 'gizmo-arrow--1').onPointerDownObservable.notifyObservers(); expect(moves).to.deep.equal([[0, -0.1, 0]]); }); - it("releasing the arrow stops the repeat without throwing", function () { - make({ mode: "arrows" }); - const btn = findControl(flock, "gizmo-arrow-1"); + it('releasing the arrow stops the repeat without throwing', function () { + make({ mode: 'arrows' }); + const btn = findControl(flock, 'gizmo-arrow-1'); btn.onPointerDownObservable.notifyObservers(); expect(() => btn.onPointerUpObservable.notifyObservers()).to.not.throw(); }); it("moves all three axes together when locked to 'all'", function () { make({ - mode: "arrows", + mode: 'arrows', showUniform: true, - initialAxis: "all", + initialAxis: 'all', stepNormal: 0.1, stepFast: 1, }); - findControl(flock, "gizmo-arrow-1").onPointerDownObservable.notifyObservers(); + findControl(flock, 'gizmo-arrow-1').onPointerDownObservable.notifyObservers(); expect(moves).to.deep.equal([[0.1, 0.1, 0.1]]); }); }); describe("mode: 'slider' (default)", function () { - it("creates the track and thumb controls", function () { + it('creates the track and thumb controls', function () { make(); - expect(findControl(flock, "gizmoTrack")).to.exist; - expect(findControl(flock, "gizmoThumb")).to.exist; + expect(findControl(flock, 'gizmoTrack')).to.exist; + expect(findControl(flock, 'gizmoThumb')).to.exist; }); - it("does not throw when dragged via real pointer events on the canvas", function () { - make({ initialAxis: "x" }); + it('does not throw when dragged via real pointer events on the canvas', function () { + make({ initialAxis: 'x' }); const canvas = flock.canvas; const rect = canvas.getBoundingClientRect(); - const down = new PointerEvent("pointerdown", { + const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: rect.left + rect.width / 4 + 20, clientY: rect.bottom - 10, }); - const move = new PointerEvent("pointermove", { + const move = new PointerEvent('pointermove', { pointerId: 1, clientX: rect.left + rect.width / 4 + 40, clientY: rect.bottom - 10, }); - const up = new PointerEvent("pointerup", { pointerId: 1 }); + const up = new PointerEvent('pointerup', { pointerId: 1 }); expect(() => { canvas.dispatchEvent(down); canvas.dispatchEvent(move); @@ -203,48 +203,48 @@ export function runGizmoMobileHudTests(flock) { }).to.not.throw(); }); - it("a pointerdown right of center moves the axis in the positive direction", function () { - make({ initialAxis: "x", getValues: () => ({ x: 0, y: 0, z: 0 }) }); + it('a pointerdown right of center moves the axis in the positive direction', function () { + make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) }); const canvas = flock.canvas; const rect = canvas.getBoundingClientRect(); canvas.dispatchEvent( - new PointerEvent("pointerdown", { + new PointerEvent('pointerdown', { pointerId: 2, clientX: rect.left + rect.width / 4 + 30, clientY: rect.bottom - 10, - }), + }) ); - canvas.dispatchEvent(new PointerEvent("pointerup", { pointerId: 2 })); + canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 2 })); expect(moves.length).to.equal(1); expect(moves[0][0]).to.be.above(0); }); - it("a pointerdown left of center moves the axis in the negative direction", function () { - make({ initialAxis: "x", getValues: () => ({ x: 0, y: 0, z: 0 }) }); + it('a pointerdown left of center moves the axis in the negative direction', function () { + make({ initialAxis: 'x', getValues: () => ({ x: 0, y: 0, z: 0 }) }); const canvas = flock.canvas; const rect = canvas.getBoundingClientRect(); canvas.dispatchEvent( - new PointerEvent("pointerdown", { + new PointerEvent('pointerdown', { pointerId: 3, clientX: rect.left + rect.width / 4 - 30, clientY: rect.bottom - 10, - }), + }) ); - canvas.dispatchEvent(new PointerEvent("pointerup", { pointerId: 3 })); + canvas.dispatchEvent(new PointerEvent('pointerup', { pointerId: 3 })); expect(moves.length).to.equal(1); expect(moves[0][0]).to.be.below(0); }); it("ignores pointerdown outside the slider's bounding box", function () { - make({ initialAxis: "x" }); + make({ initialAxis: 'x' }); const canvas = flock.canvas; const rect = canvas.getBoundingClientRect(); canvas.dispatchEvent( - new PointerEvent("pointerdown", { + new PointerEvent('pointerdown', { pointerId: 4, clientX: rect.left + rect.width - 5, clientY: rect.top + 5, - }), + }) ); expect(moves).to.deep.equal([]); }); diff --git a/tests/gizmos.test.js b/tests/gizmos.test.js index a4d0f1e0..b3df5cb0 100644 --- a/tests/gizmos.test.js +++ b/tests/gizmos.test.js @@ -441,7 +441,6 @@ export function runGizmoTests(flock) { 'scrollCharactersLeftButton', 'scrollCharactersRightButton', ]; - const EXTRA_IDS = []; let created; @@ -465,15 +464,13 @@ export function runGizmoTests(flock) { it('does nothing when a required button is missing from the DOM', function () { REQUIRED_IDS.slice(1).forEach((id) => addButton(id)); - EXTRA_IDS.forEach((id) => addButton(id)); expect(() => enableGizmos()).to.not.throw(); expect(document.getElementById(REQUIRED_IDS[1]).hasAttribute('disabled')).to.be.true; }); it('does nothing (and does not throw) when only eyeButton is missing', function () { - // Regression test: eyeButton used to be absent from the required-button - // guard but was still accessed unconditionally further down, so a - // missing eyeButton alone used to pass the guard and then throw. + // eyeButton is accessed unconditionally later in this function, so it + // must be part of the required-button guard. REQUIRED_IDS.filter((id) => id !== 'eyeButton').forEach((id) => addButton(id)); expect(() => enableGizmos()).to.not.throw(); expect(document.getElementById('positionButton').hasAttribute('disabled')).to.be.true; @@ -481,7 +478,6 @@ export function runGizmoTests(flock) { it('removes the disabled attribute from every button once all required ones exist', function () { REQUIRED_IDS.forEach((id) => addButton(id)); - EXTRA_IDS.forEach((id) => addButton(id)); enableGizmos(); REQUIRED_IDS.forEach((id) => { expect(document.getElementById(id).hasAttribute('disabled'), id).to.be.false; @@ -490,7 +486,6 @@ export function runGizmoTests(flock) { it('wires the position button click through to toggleGizmo', function () { REQUIRED_IDS.forEach((id) => addButton(id)); - EXTRA_IDS.forEach((id) => addButton(id)); enableGizmos(); document.getElementById('positionButton').click(); expect(document.getElementById('positionButton').classList.contains('active')).to.be.true; @@ -499,7 +494,6 @@ export function runGizmoTests(flock) { it('wires the "show shapes" button to exitGizmoState + window.showShapes', function () { REQUIRED_IDS.forEach((id) => addButton(id)); - EXTRA_IDS.forEach((id) => addButton(id)); mgr.positionGizmoEnabled = true; let called = false; const saved = window.showShapes; @@ -516,7 +510,6 @@ export function runGizmoTests(flock) { it('wires the scroll buttons to window.scrollModels/scrollObjects/scrollCharacters', function () { REQUIRED_IDS.forEach((id) => addButton(id)); - EXTRA_IDS.forEach((id) => addButton(id)); const calls = []; const saved = { scrollModels: window.scrollModels, diff --git a/tests/utils/keyboardDispatcherTestUtils.js b/tests/utils/keyboardDispatcherTestUtils.js new file mode 100644 index 00000000..ff7aa310 --- /dev/null +++ b/tests/utils/keyboardDispatcherTestUtils.js @@ -0,0 +1,49 @@ +import { KeyboardDispatcher } from '../../main/keyboardDispatcher.js'; + +/** + * Returns the handler function at the top of KeyboardDispatcher's internal + * mode stack — the mode most recently pushed by pushMode(), and the one that + * would receive the next real keydown event. Tests call this directly rather + * than dispatching a real DOM event through the whole input pipeline. + * @returns {Function|undefined} + */ +export function topHandler() { + const top = KeyboardDispatcher._modeStack[KeyboardDispatcher._modeStack.length - 1]; + return top?.handler; +} + +/** + * Builds a fake keyboard event sufficient for directly invoking a handler + * retrieved via topHandler(). Only the fields the various keyboard-mode + * handlers in this codebase actually read are included. + * @param {object} overrides + */ +export function makeKeyEvent(overrides = {}) { + return { + target: document.body, + key: '', + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...overrides, + }; +} + +/** + * Dispatches a real keyup event on document — for handlers (like + * ui/canvas-utils.js's) that listen for keyup directly rather than through + * KeyboardDispatcher, so it can't be exercised via topHandler()/makeKeyEvent. + * @param {string} key + */ +export function dispatchKeyup(key) { + document.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); +} From c8e10e4d861a870b5f6c8ead608a29edbf6fb3e6 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:56:02 +0100 Subject: [PATCH 5/9] Add KeyboardUI tests --- accessibility/keyboardui.js | 2 +- tests/keyboardui.test.js | 523 ++++++++++++++++++++++++++++++++++++ tests/tests.html | 7 + 3 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 tests/keyboardui.test.js diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js index 5a3f3ba1..4293a662 100644 --- a/accessibility/keyboardui.js +++ b/accessibility/keyboardui.js @@ -963,4 +963,4 @@ if (document.getElementById('info-panel-tabs')) { ShortcutsPanel.init(); } -export { InfoPanel, ShortcutsPanel, GizmoMenuManager }; +export { InfoPanel, ShortcutsPanel, GizmoMenuManager, AreaManager }; diff --git a/tests/keyboardui.test.js b/tests/keyboardui.test.js new file mode 100644 index 00000000..a392216a --- /dev/null +++ b/tests/keyboardui.test.js @@ -0,0 +1,523 @@ +import { expect } from 'chai'; +import { + GizmoMenuManager, + InfoPanel, + ShortcutsPanel, + AreaManager, +} from '../accessibility/keyboardui.js'; +import { KeyboardDispatcher } from '../main/keyboardDispatcher.js'; + +export function runKeyboardUiTests(flock) { + describe('accessibility/keyboardui @keyboardui', function () { + before(function () { + // tests/tests.html doesn't load the app's style.css, so the `.hidden` + // class AreaManager/GizmoMenuManager toggle never actually maps to + // `display: none` here — which breaks ContextManager's OVERLAY + // detection (it checks getComputedStyle().display). Inject the one + // rule these tests actually depend on, matching style.css's own rule. + if (!document.getElementById('keyboardui-test-hidden-rule')) { + const style = document.createElement('style'); + style.id = 'keyboardui-test-hidden-rule'; + style.textContent = '.hidden { display: none !important; }'; + document.head.appendChild(style); + } + }); + + describe('module init sanity', function () { + it('GizmoMenuManager overlay already exists (init() ran on import)', function () { + expect(document.getElementById('gizmo-menu-overlay')).to.exist; + }); + + it('AreaManager overlay already exists (init() ran on import)', function () { + expect(document.getElementById('area-menu-overlay')).to.exist; + }); + + it('InfoPanel/ShortcutsPanel did not auto-init (no #info-panel-tabs in this harness)', function () { + expect(InfoPanel._tablist).to.not.exist; + expect(ShortcutsPanel.panel).to.equal(null); + }); + }); + + describe('GizmoMenuManager', function () { + let created; + + function addButton(id, { disabled = false } = {}) { + const btn = document.createElement('button'); + btn.id = id; + btn.disabled = disabled; + document.body.appendChild(btn); + created.push(btn); + return btn; + } + + beforeEach(function () { + created = []; + GizmoMenuManager.toggle(false); + }); + + afterEach(function () { + GizmoMenuManager.toggle(false); + created.forEach((el) => el.remove()); + created = []; + }); + + it('isOpen() reflects the hidden class', function () { + expect(GizmoMenuManager.isOpen()).to.equal(false); + GizmoMenuManager.toggle(true); + expect(GizmoMenuManager.isOpen()).to.equal(true); + }); + + it('toggle(true) focuses showShapesButton when nothing is already focused inside #gizmoButtons', function () { + addButton('showShapesButton'); + GizmoMenuManager.toggle(true); + expect(document.activeElement.id).to.equal('showShapesButton'); + }); + + it('toggle(true) does not steal focus from something already focused inside #gizmoButtons', function () { + const container = document.createElement('div'); + container.id = 'gizmoButtons'; + document.body.appendChild(container); + created.push(container); + const btn = document.createElement('button'); + btn.id = 'positionButton'; + container.appendChild(btn); + btn.focus(); + addButton('showShapesButton'); + GizmoMenuManager.toggle(true); + expect(document.activeElement).to.equal(btn); + }); + + it('registerCloseHook fires when the menu opens', function () { + let called = false; + GizmoMenuManager.registerCloseHook(() => (called = true)); + GizmoMenuManager.toggle(true); + expect(called).to.equal(true); + }); + + it('a close hook that throws does not prevent the menu opening', function () { + GizmoMenuManager.registerCloseHook(() => { + throw new Error('boom'); + }); + expect(() => GizmoMenuManager.toggle(true)).to.not.throw(); + expect(GizmoMenuManager.isOpen()).to.equal(true); + }); + + it('activateButton focuses and clicks an enabled button', function () { + let clicked = false; + const btn = addButton('positionButton'); + btn.addEventListener('click', () => (clicked = true)); + GizmoMenuManager.activateButton({ id: 'positionButton', label: '3' }); + expect(document.activeElement).to.equal(btn); + expect(clicked).to.equal(true); + }); + + it('activateButton does not click a disabled button', function () { + let clicked = false; + const btn = addButton('positionButton', { disabled: true }); + btn.addEventListener('click', () => (clicked = true)); + GizmoMenuManager.activateButton({ id: 'positionButton', label: '3' }); + expect(clicked).to.equal(false); + }); + + it('activateButton is a safe no-op when the button does not exist', function () { + expect(() => GizmoMenuManager.activateButton({ id: 'nope', label: '1' })).to.not.throw(); + }); + + describe('Digit-key activation (via KeyboardDispatcher registry)', function () { + it('does nothing while the menu is closed', function () { + let clicked = false; + const btn = addButton('positionButton'); + btn.addEventListener('click', () => (clicked = true)); + KeyboardDispatcher._registry['*:Digit3'](); + expect(clicked).to.equal(false); + }); + + it('activates the matching button while the menu is open', function () { + addButton('showShapesButton'); + let clicked = false; + const btn = addButton('positionButton'); + btn.addEventListener('click', () => (clicked = true)); + GizmoMenuManager.toggle(true); + KeyboardDispatcher._registry['*:Digit3'](); + expect(clicked).to.equal(true); + }); + }); + + describe('Mod+KeyG handler', function () { + it('opens the menu when the current context allows it', function () { + const event = { preventDefault() {}, stopPropagation() {} }; + KeyboardDispatcher._registry['*:Mod+KeyG'](event); + expect(GizmoMenuManager.isOpen()).to.equal(true); + }); + + it('does nothing while focus is in a text input (TYPING context)', function () { + const input = document.createElement('input'); + document.body.appendChild(input); + created.push(input); + input.focus(); + const event = { preventDefault() {}, stopPropagation() {} }; + KeyboardDispatcher._registry['*:Mod+KeyG'](event); + expect(GizmoMenuManager.isOpen()).to.equal(false); + }); + + it('does nothing while the area menu overlay is open (OVERLAY context)', function () { + AreaManager.toggle(true); + const event = { preventDefault() {}, stopPropagation() {} }; + KeyboardDispatcher._registry['*:Mod+KeyG'](event); + expect(GizmoMenuManager.isOpen()).to.equal(false); + AreaManager.toggle(false); + }); + }); + }); + + describe('AreaManager', function () { + afterEach(function () { + AreaManager.toggle(false); + GizmoMenuManager.toggle(false); + }); + + it('toggle(true)/toggle(false) show and hide the overlay', function () { + AreaManager.toggle(true); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(false); + AreaManager.toggle(false); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(true); + }); + + it('opening closes the gizmo menu if it was open', function () { + GizmoMenuManager.toggle(true); + AreaManager.toggle(true); + expect(GizmoMenuManager.isOpen()).to.equal(false); + }); + + it('Mod+KeyB (via KeyboardDispatcher) toggles the overlay open and closed', function () { + const event = { preventDefault() {}, stopPropagation() {} }; + KeyboardDispatcher._registry['*:Mod+KeyB'](event); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(false); + KeyboardDispatcher._registry['*:Mod+KeyB'](event); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(true); + }); + + it('Escape (via KeyboardDispatcher) closes the overlay', function () { + AreaManager.toggle(true); + KeyboardDispatcher._registry['OVERLAY:Escape'](); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(true); + }); + + it('Digit3 (the Canvas area) focuses the render canvas and closes the overlay', function () { + AreaManager.toggle(true); + const event = { preventDefault() {} }; + KeyboardDispatcher._registry['OVERLAY:Digit3'](event); + expect(document.activeElement).to.equal(flock.canvas); + expect(AreaManager.overlay.classList.contains('hidden')).to.equal(true); + }); + + describe('effectiveAreas', function () { + it('swaps area 5 for the view-toggle pill in a narrow layout', function () { + const saved = window.matchMedia; + window.matchMedia = () => ({ matches: true }); + try { + const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5'); + expect(area5.selector).to.equal('#viewToggle'); + } finally { + window.matchMedia = saved; + } + }); + + it('keeps area 5 as the info panel tabs in a wide layout', function () { + const saved = window.matchMedia; + window.matchMedia = () => ({ matches: false }); + try { + const area5 = AreaManager.effectiveAreas.find((a) => a.label === '5'); + expect(area5.selector).to.equal('#info-panel-tabs'); + } finally { + window.matchMedia = saved; + } + }); + + it('swaps area 9 for the reload button when one is connected to the DOM', function () { + const reload = document.createElement('button'); + reload.id = 'reload-btn'; + document.body.appendChild(reload); + try { + const area9 = AreaManager.effectiveAreas.find((a) => a.label === '9'); + expect(area9.selector).to.equal('#reload-btn'); + } finally { + reload.remove(); + } + }); + }); + }); + + describe('InfoPanel + ShortcutsPanel', function () { + let panelRoot; + + before(function () { + // Neither auto-initialized in this harness (no #info-panel-tabs at + // import time) — build the DOM skeleton index.html normally provides + // and initialize them here, the same way main.js's bootstrap does. + panelRoot = document.createElement('div'); + panelRoot.innerHTML = + '
' + + '
'; + document.body.appendChild(panelRoot); + InfoPanel.init(); + ShortcutsPanel.init(); + }); + + after(function () { + panelRoot.remove(); + }); + + describe('InfoPanel', function () { + afterEach(function () { + if (InfoPanel._activeId) InfoPanel.deactivate(InfoPanel._activeId); + }); + + it('register() creates a tab button and panel wired together via aria attributes', function () { + const panel = InfoPanel.register('mytab', 'My Tab'); + const btn = document.getElementById('info-tab-btn-mytab'); + expect(btn.textContent).to.equal('My Tab'); + expect(btn.getAttribute('aria-controls')).to.equal('info-tab-panel-mytab'); + expect(panel.getAttribute('aria-labelledby')).to.equal('info-tab-btn-mytab'); + expect(panel.classList.contains('hidden')).to.equal(true); + }); + + it('activate() shows the panel and marks the tab selected', function () { + InfoPanel.register('mytab2', 'Tab 2'); + InfoPanel.activate('mytab2'); + expect( + document.getElementById('info-tab-btn-mytab2').getAttribute('aria-selected') + ).to.equal('true'); + expect( + document.getElementById('info-tab-panel-mytab2').classList.contains('hidden') + ).to.equal(false); + }); + + it('activating a second tab deactivates the first', function () { + InfoPanel.register('tabA', 'A'); + InfoPanel.register('tabB', 'B'); + InfoPanel.activate('tabA'); + InfoPanel.activate('tabB'); + expect( + document.getElementById('info-tab-btn-tabA').classList.contains('active') + ).to.equal(false); + expect( + document.getElementById('info-tab-panel-tabA').classList.contains('hidden') + ).to.equal(true); + expect( + document.getElementById('info-tab-btn-tabB').classList.contains('active') + ).to.equal(true); + }); + + it('deactivate() hides the panel and clears the active id', function () { + InfoPanel.register('tabC', 'C'); + InfoPanel.activate('tabC'); + InfoPanel.deactivate('tabC'); + expect( + document.getElementById('info-tab-btn-tabC').classList.contains('active') + ).to.equal(false); + expect(InfoPanel._activeId).to.equal(null); + }); + + it('toggle() flips between activate and deactivate', function () { + InfoPanel.register('tabD', 'D'); + InfoPanel.toggle('tabD'); + expect(InfoPanel._activeId).to.equal('tabD'); + InfoPanel.toggle('tabD'); + expect(InfoPanel._activeId).to.equal(null); + }); + + it('activate/deactivate/toggle on an unknown id are safe no-ops', function () { + expect(() => InfoPanel.activate('nope')).to.not.throw(); + expect(() => InfoPanel.deactivate('nope')).to.not.throw(); + expect(() => InfoPanel.toggle('nope')).to.not.throw(); + }); + }); + + describe('ShortcutsPanel show/hide/toggle', function () { + afterEach(function () { + ShortcutsPanel.hide(); + }); + + it('show() activates the shortcuts tab', function () { + ShortcutsPanel.show(); + expect(InfoPanel._activeId).to.equal('shortcuts'); + }); + + it('hide() deactivates it', function () { + ShortcutsPanel.show(); + ShortcutsPanel.hide(); + expect(InfoPanel._activeId).to.not.equal('shortcuts'); + }); + + it('toggle() flips between show and hide', function () { + ShortcutsPanel.toggle(); + expect(InfoPanel._activeId).to.equal('shortcuts'); + ShortcutsPanel.toggle(); + expect(InfoPanel._activeId).to.not.equal('shortcuts'); + }); + + it('renders shortcuts grouped by category with -wrapped keys', function () { + ShortcutsPanel.show(); + const list = ShortcutsPanel.panel.querySelector('#shortcuts-list'); + expect(list.querySelectorAll('.shortcuts-category').length).to.be.above(0); + expect(list.querySelectorAll('kbd').length).to.be.above(0); + }); + }); + + describe('adjustFontSize', function () { + afterEach(function () { + ShortcutsPanel.hide(); + }); + + it('increases the font size and clamps at the top of the scale', function () { + ShortcutsPanel.show(); + const savedSize = ShortcutsPanel.fontSize; + const savedStorage = localStorage.getItem('flock-shortcuts-font-size'); + try { + ShortcutsPanel.fontSize = 1.2; + ShortcutsPanel.adjustFontSize(1); + expect(ShortcutsPanel.fontSize).to.be.above(1.2); + expect(localStorage.getItem('flock-shortcuts-font-size')).to.equal( + String(ShortcutsPanel.fontSize) + ); + for (let i = 0; i < 10; i++) ShortcutsPanel.adjustFontSize(1); + const maxed = ShortcutsPanel.fontSize; + ShortcutsPanel.adjustFontSize(1); + expect(ShortcutsPanel.fontSize).to.equal(maxed); + expect(ShortcutsPanel.panel.querySelector('.shortcuts-increase-btn').disabled).to.equal( + true + ); + } finally { + ShortcutsPanel.fontSize = savedSize; + if (savedStorage === null) localStorage.removeItem('flock-shortcuts-font-size'); + else localStorage.setItem('flock-shortcuts-font-size', savedStorage); + } + }); + + it('decreases the font size and clamps at the bottom of the scale', function () { + ShortcutsPanel.show(); + const savedSize = ShortcutsPanel.fontSize; + const savedStorage = localStorage.getItem('flock-shortcuts-font-size'); + try { + ShortcutsPanel.fontSize = 1.2; + for (let i = 0; i < 10; i++) ShortcutsPanel.adjustFontSize(-1); + const minned = ShortcutsPanel.fontSize; + ShortcutsPanel.adjustFontSize(-1); + expect(ShortcutsPanel.fontSize).to.equal(minned); + expect(ShortcutsPanel.panel.querySelector('.shortcuts-decrease-btn').disabled).to.equal( + true + ); + } finally { + ShortcutsPanel.fontSize = savedSize; + if (savedStorage === null) localStorage.removeItem('flock-shortcuts-font-size'); + else localStorage.setItem('flock-shortcuts-font-size', savedStorage); + } + }); + }); + + describe('enterModal / exitModal', function () { + afterEach(function () { + ShortcutsPanel.exitModal(); + ShortcutsPanel.hide(); + }); + + it('reparents the panel to and marks it a modal dialog', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + expect(ShortcutsPanel.panel.parentNode).to.equal(document.body); + expect(ShortcutsPanel.panel.classList.contains('shortcuts-modal')).to.equal(true); + expect(ShortcutsPanel.panel.getAttribute('aria-modal')).to.equal('true'); + }); + + it('adds a visible close button that hides the panel when clicked', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + const closeBtn = ShortcutsPanel.panel.querySelector('.shortcuts-modal-close'); + expect(closeBtn).to.exist; + closeBtn.click(); + expect(InfoPanel._activeId).to.not.equal('shortcuts'); + }); + + it('is idempotent (a second call does not add a second backdrop)', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + ShortcutsPanel.enterModal(); + expect(document.querySelectorAll('.shortcuts-modal-backdrop').length).to.equal(1); + }); + + it('exitModal restores the panel to its docked location and removes modal attributes', function () { + ShortcutsPanel.show(); + const dockedParent = ShortcutsPanel.panel.parentNode; + ShortcutsPanel.enterModal(); + ShortcutsPanel.exitModal(); + expect(ShortcutsPanel.panel.parentNode).to.equal(dockedParent); + expect(ShortcutsPanel.panel.classList.contains('shortcuts-modal')).to.equal(false); + expect(ShortcutsPanel.panel.hasAttribute('aria-modal')).to.equal(false); + expect(document.querySelector('.shortcuts-modal-backdrop')).to.not.exist; + }); + }); + + describe('trapFocus', function () { + afterEach(function () { + ShortcutsPanel.exitModal(); + ShortcutsPanel.hide(); + }); + + it('Tab on the last focusable element wraps to the first', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + const focusables = ShortcutsPanel.focusableElements(); + focusables[focusables.length - 1].focus(); + const event = { key: 'Tab', shiftKey: false, preventDefault() {}, stopPropagation() {} }; + ShortcutsPanel.trapFocus(event); + expect(document.activeElement).to.equal(focusables[0]); + }); + + it('Shift+Tab on the first focusable element wraps to the last', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + const focusables = ShortcutsPanel.focusableElements(); + focusables[0].focus(); + const event = { key: 'Tab', shiftKey: true, preventDefault() {}, stopPropagation() {} }; + ShortcutsPanel.trapFocus(event); + expect(document.activeElement).to.equal(focusables[focusables.length - 1]); + }); + + it('ignores non-Tab keys', function () { + ShortcutsPanel.show(); + ShortcutsPanel.enterModal(); + let prevented = false; + ShortcutsPanel.trapFocus({ key: 'a', preventDefault: () => (prevented = true) }); + expect(prevented).to.equal(false); + }); + }); + + describe('panel keydown handling (Escape / arrow scroll)', function () { + afterEach(function () { + ShortcutsPanel.hide(); + }); + + it('Escape hides the panel', function () { + ShortcutsPanel.show(); + ShortcutsPanel.panel.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + expect(InfoPanel._activeId).to.not.equal('shortcuts'); + }); + + it('ArrowUp/ArrowDown scroll without throwing', function () { + ShortcutsPanel.show(); + expect(() => { + ShortcutsPanel.panel.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }) + ); + ShortcutsPanel.panel.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + }).to.not.throw(); + }); + }); + }); + }); +} diff --git a/tests/tests.html b/tests/tests.html index 6ab92dab..500a7e31 100644 --- a/tests/tests.html +++ b/tests/tests.html @@ -218,6 +218,13 @@

Flock Test Example

importFn: "runContextMenuTests", pattern: "ui/contextmenu", }, + { + id: "keyboardui", + name: "Keyboard UI Tests", + importPath: "./keyboardui.test.js", + importFn: "runKeyboardUiTests", + pattern: "accessibility/keyboardui", + }, { id: "onscreencontrols", name: "On-Screen Controls Tests", From 1ac55239fbe22ab8546243d0169b8c8afaf5f6d9 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:12:55 +0100 Subject: [PATCH 6/9] Gate spawn to windows only --- scripts/run-api-tests.mjs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/scripts/run-api-tests.mjs b/scripts/run-api-tests.mjs index b9007ae4..7a10fb76 100644 --- a/scripts/run-api-tests.mjs +++ b/scripts/run-api-tests.mjs @@ -326,23 +326,28 @@ async function startServer() { const outputBuffer = []; const errorBuffer = []; - // Pass the command as a single string with `shell: true` and no args - // array. On Windows, `npm` is a .cmd launcher: spawning "npm" directly - // (no shell) throws ENOENT, and spawning "npm.cmd" directly (no shell) + // On Windows, `npm` is a .cmd launcher: spawning "npm" directly (no + // shell) throws ENOENT, and spawning "npm.cmd" directly (no shell) // throws EINVAL — recent Node versions require shell:true to launch - // .cmd/.bat files at all. Using a single command string (rather than - // shell:true with an args array) avoids the Node DEP0190 deprecation. + // .cmd/.bat files at all. That's Windows-only; on POSIX, `npm` resolves + // and execs directly from PATH, so keep the original argv-array spawn + // there rather than routing everything through `sh -c` unconditionally. + // Using a single command string with shell:true (rather than shell:true + // with an args array) avoids the Node DEP0190 deprecation. // `detached: true` puts npm and its child vite/esbuild processes in their // own process group so cleanup() can kill the whole group. Without this, // server.kill() only signals npm, leaving an orphaned vite holding port // 5173 that the next run wrongly "reuses". - server = spawn("npm run dev", { + const spawnOptions = { cwd: path.resolve(__dirname, ".."), stdio: "pipe", detached: true, - shell: true, env: { ...process.env }, - }); + }; + server = + process.platform === "win32" + ? spawn("npm run dev", { ...spawnOptions, shell: true }) + : spawn("npm", ["run", "dev"], spawnOptions); server.stdout.on("data", (data) => { const output = data.toString(); From 83c05f6a3f33a807630020eacae305763dd98902 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:40:32 +0100 Subject: [PATCH 7/9] Fix some ambiguous before cases --- tests/blocklyshadowutil.test.js | 1 - tests/gizmos.test.js | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/blocklyshadowutil.test.js b/tests/blocklyshadowutil.test.js index 434c820d..714d2358 100644 --- a/tests/blocklyshadowutil.test.js +++ b/tests/blocklyshadowutil.test.js @@ -120,7 +120,6 @@ export function runBlocklyShadowUtilTests() { }); it("builds a lists_create_with spec sized to the object's colour list", function () { - // "cat" isn't in objectColours, so this exercises the 3-colour fallback. const listSpec = buildColorsListShadowSpec('__unknown_object__'); expect(listSpec.type).to.equal('lists_create_with'); expect(listSpec.extraState.itemCount).to.equal(3); diff --git a/tests/gizmos.test.js b/tests/gizmos.test.js index b3df5cb0..8281c231 100644 --- a/tests/gizmos.test.js +++ b/tests/gizmos.test.js @@ -81,6 +81,7 @@ export function runGizmoTests(flock) { it('detaches when passed null', function () { const box = makeBox(); mgr.attachToMesh(box); + expect(gizmoManager.attachedMesh).to.equal(box); mgr.attachToMesh(null); expect(gizmoManager.attachedMesh).to.be.null; }); @@ -287,6 +288,8 @@ export function runGizmoTests(flock) { it('pressing the same gizmo again toggles it off', function () { addButton('positionButton'); toggleGizmo('position'); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.true; + expect(mgr.positionGizmoEnabled).to.be.true; toggleGizmo('position'); expect(document.getElementById('positionButton').classList.contains('active')).to.be.false; expect(mgr.positionGizmoEnabled).to.be.false; @@ -296,6 +299,8 @@ export function runGizmoTests(flock) { addButton('positionButton'); addButton('rotationButton'); toggleGizmo('position'); + expect(document.getElementById('positionButton').classList.contains('active')).to.be.true; + expect(document.getElementById('rotationButton').classList.contains('active')).to.be.false; toggleGizmo('rotation'); expect(document.getElementById('positionButton').classList.contains('active')).to.be.false; expect(document.getElementById('rotationButton').classList.contains('active')).to.be.true; @@ -305,6 +310,11 @@ export function runGizmoTests(flock) { addButton('positionButton'); toggleGizmo('position'); expect(mgr.positionGizmoEnabled).to.be.true; + // Force the other flags on first, so asserting they're off afterward + // actually demonstrates the toggle-off reset them, rather than + // trivially passing because they were already false. + mgr.rotationGizmoEnabled = true; + mgr.scaleGizmoEnabled = true; toggleGizmo('position'); expect(mgr.positionGizmoEnabled).to.be.false; expect(mgr.rotationGizmoEnabled).to.be.false; From e5949ec149390720b80792d214560526756627d5 Mon Sep 17 00:00:00 2001 From: Laura Sach <5183697+lawsie@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:49:18 +0100 Subject: [PATCH 8/9] Fix and remove pointless test --- tests/gizmos.test.js | 8 -------- tests/keyboardui.test.js | 12 +++++++++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/gizmos.test.js b/tests/gizmos.test.js index 8281c231..153c9830 100644 --- a/tests/gizmos.test.js +++ b/tests/gizmos.test.js @@ -478,14 +478,6 @@ export function runGizmoTests(flock) { expect(document.getElementById(REQUIRED_IDS[1]).hasAttribute('disabled')).to.be.true; }); - it('does nothing (and does not throw) when only eyeButton is missing', function () { - // eyeButton is accessed unconditionally later in this function, so it - // must be part of the required-button guard. - REQUIRED_IDS.filter((id) => id !== 'eyeButton').forEach((id) => addButton(id)); - expect(() => enableGizmos()).to.not.throw(); - expect(document.getElementById('positionButton').hasAttribute('disabled')).to.be.true; - }); - it('removes the disabled attribute from every button once all required ones exist', function () { REQUIRED_IDS.forEach((id) => addButton(id)); enableGizmos(); diff --git a/tests/keyboardui.test.js b/tests/keyboardui.test.js index a392216a..fb2807b9 100644 --- a/tests/keyboardui.test.js +++ b/tests/keyboardui.test.js @@ -112,11 +112,17 @@ export function runKeyboardUiTests(flock) { }); it('activateButton does not click a disabled button', function () { - let clicked = false; + // A real disabled