import test from "node:test"; import assert from "node:assert/strict"; import { Navigation, axes, dragView } from "./navigation.js"; import { cameraBasis, dot } from "./math.js"; const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-10, `${a} != ${b}`); test("all six cardinal cameras and pole departures have orthonormal finite bases", () => { for (const a of axes) { const b = cameraBasis(a.yaw, a.pitch); a.vector.forEach((v, k) => near(b.forward[k], v)); near(dot(b.right, b.right), 1); near(dot(b.up, b.up), 1); near(dot(b.right, b.up), 0); const c = {}; dragView(c, { yaw: a.yaw, pitch: a.pitch }, "orbit", 7, -3, { width: 800, height: 600, }); assert.ok(cameraBasis(c.yaw, c.pitch).up.every(Number.isFinite)); } }); test("drag uses snapshot totals, asymmetric pan, bounded zoom and no target drift", () => { const start = { yaw: 0, pitch: 0, zoom: 2, target: [3, 4, 5] }, c = structuredClone(start), rect = { width: 800, height: 400 }; dragView(c, start, "pan", 30, -10, rect); assert.deepEqual(c.target, [2.7, 3.9, 5]); dragView(c, start, "pan", 60, 20, rect); assert.deepEqual(c.target, [2.4, 4.2, 5]); dragView(c, start, "orbit", 25, -10, rect); near(c.yaw, -0.2); near(c.pitch, -0.08); assert.deepEqual(c.target, [2.4, 4.2, 5]); dragView(c, start, "zoom", 0, 50, rect); near(c.zoom, 2 * Math.exp(0.4)); dragView(c, start, "zoom", 0, 1e5, rect); near(c.zoom, 14); }); function fixture() { const n = Object.create(Navigation.prototype), captures = new Set(); let ready = true; const rect = { left: 0, top: 0, width: 800, height: 600 }, camera = { yaw: 0.3, pitch: 0.2, zoom: 2, target: [1, 2, 3] }; n.host = { camera, ready: () => ready, start() {}, changed() {}, invalidate() {}, }; n.viewport = { getBoundingClientRect: () => ({ ...rect }) }; n.root = { setPointerCapture: (id) => captures.add(id), hasPointerCapture: (id) => captures.has(id), releasePointerCapture: (id) => { captures.delete(id); if (n.active?.id === id) n.cancel(); }, }; const event = (x = 100, y = 100, id = 1, axis) => ({ pointerId: id, clientX: x, clientY: y, button: 0, preventDefault() {}, target: { closest: (s) => s === "[data-nav]" ? { dataset: { nav: "orbit" }, focus() {} } : axis === undefined ? null : { dataset: { axis } }, }, }); return { n, camera, rect, captures, event, ready: (v) => (ready = v) }; } test("navigation admission, single pointer, release coordinates, cancel and viewport rollback", () => { const { n, camera, rect, captures, event, ready } = fixture(), before = structuredClone(camera); ready(false); n.down(event()); assert.equal(n.busy, false); ready(true); n.down(event()); n.move(event(120, 110, 2)); assert.deepEqual(camera, before); n.up(event(125, 115)); near(camera.yaw, 0.1); near(camera.pitch, 0.32); assert.equal(n.busy, false); assert.equal(captures.size, 0); const committed = structuredClone(camera); n.down(event()); n.move(event(180, 140)); n.cancel(); assert.deepEqual(camera, committed); n.down(event()); n.move(event(200, 160)); rect.width = 700; n.up(event(210, 170)); assert.deepEqual(camera, committed); assert.equal(n.busy, false); }); test("Escape is consumed only by an active navigation gesture", () => { const { n, camera, captures, event, ready } = fixture(); const before = structuredClone(camera); let prevented = 0, stopped = 0; const escape = { key: "Escape", preventDefault: () => prevented++, stopPropagation: () => stopped++, }; n.key(escape); ready(false); n.key(escape); assert.equal(prevented, 0); assert.equal(stopped, 0); assert.deepEqual(camera, before); ready(true); n.down(event()); n.move(event(180, 140)); n.key(escape); n.up(event(200, 160)); assert.deepEqual(camera, before); assert.equal(n.busy, false); assert.equal(captures.size, 0); assert.equal(prevented, 1); assert.equal(stopped, 1); }); test("axis clicks snap/flip without changing zoom or target; drags never snap", () => { const { n, camera, event } = fixture(); n.down(event(100, 100, 1, "0")); n.up(event(101, 101)); near(camera.yaw, Math.PI / 2); near(camera.pitch, 0); n.down(event(100, 100, 1, "0")); n.up(event(100, 100)); near(camera.yaw, -Math.PI / 2); assert.deepEqual(camera.target, [1, 2, 3]); near(camera.zoom, 2); n.down(event(100, 100, 1, "1")); n.up(event(140, 115)); near(camera.yaw, -Math.PI / 2 - 0.32); near(camera.pitch, 0.12); });