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