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