diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js
index 5a3f3ba1a..4293a6621 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/scripts/run-api-tests.mjs b/scripts/run-api-tests.mjs
index 9b8e1b978..7a10fb769 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,22 +326,28 @@ 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: ... })
+ // 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. 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,
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();
@@ -1039,16 +1045,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/axis-keyboard.test.js b/tests/axis-keyboard.test.js
new file mode 100644
index 000000000..05e4e0cfb
--- /dev/null
+++ b/tests/axis-keyboard.test.js
@@ -0,0 +1,336 @@
+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 () {
+ 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/blocklyshadowutil.test.js b/tests/blocklyshadowutil.test.js
new file mode 100644
index 000000000..714d23589
--- /dev/null
+++ b/tests/blocklyshadowutil.test.js
@@ -0,0 +1,145 @@
+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 () {
+ 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/canvas-utils.test.js b/tests/canvas-utils.test.js
new file mode 100644
index 000000000..cbb5da137
--- /dev/null
+++ b/tests/canvas-utils.test.js
@@ -0,0 +1,327 @@
+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';
+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
+ // 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 actual current circle position', function () {
+ createCanvasCircle();
+ // 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 () {
+ 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 000000000..75b49e52a
--- /dev/null
+++ b/tests/contextmenu.test.js
@@ -0,0 +1,276 @@
+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 () {
+ 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();
+ // 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);
+ });
+
+ 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 () {
+ 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 to the translated flock label', function () {
+ const item = getItem('workspaceDelete');
+ expect(item).to.exist;
+ const text = item.displayText({ workspace });
+ 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');
+ });
+
+ 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 () {
+ 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 () {
+ 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 000000000..560d322c8
--- /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 1c6733d25..153c9830b 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) {
@@ -79,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;
});
@@ -252,6 +255,73 @@ 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');
+ 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;
+ });
+
+ it('switching to a different gizmo un-highlights the previous button', function () {
+ 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;
+ });
+
+ it('turning a gizmo off disables every gizmo flag, not just its own', function () {
+ 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;
+ expect(mgr.scaleGizmoEnabled).to.be.false;
+ });
+ });
+
// āāā viewMeshWithCamera: orbit view āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
describe('viewMeshWithCamera (orbit view)', function () {
@@ -361,6 +431,112 @@ export function runGizmoTests(flock) {
});
});
+ // āāā enableGizmos āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+ describe('enableGizmos', function () {
+ const REQUIRED_IDS = [
+ 'positionButton',
+ 'rotationButton',
+ 'scaleButton',
+ 'selectButton',
+ 'duplicateButton',
+ 'deleteButton',
+ 'cameraButton',
+ 'eyeButton',
+ 'showShapesButton',
+ 'scrollModelsLeftButton',
+ 'scrollModelsRightButton',
+ 'scrollObjectsLeftButton',
+ 'scrollObjectsRightButton',
+ 'scrollCharactersLeftButton',
+ 'scrollCharactersRightButton',
+ ];
+
+ 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));
+ 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));
+ 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));
+ 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));
+ 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));
+ 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/keyboardui.test.js b/tests/keyboardui.test.js
new file mode 100644
index 000000000..fb2807b9b
--- /dev/null
+++ b/tests/keyboardui.test.js
@@ -0,0 +1,529 @@
+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 () {
+ // A real disabled