import test from "node:test"; import assert from "node:assert/strict"; import { Input } from "./input.js"; import { Sculpt, defaults } from "./engine.js"; import { sphere, updateNormals } from "./mesh.js"; import { Renderer } from "./renderer.js"; import { StrokeClient } from "./stroke-client.js"; import { StrokeProcessor } from "./stroke-worker.js"; import { Mailbox } from "./mailbox.js"; // Exercise the real client protocol, with frame delivery under test control. // Structured clone/transfer and asynchronous messages match worker ownership. class ControlledWorker { postMessage(data, transfer = []) { const copy = structuredClone(data, { transfer }); queueMicrotask(() => { if (this.dead) return; if (copy.type === "init") { this.processor = new StrokeProcessor( sphere(copy.level), new Mailbox(copy.row), (message, transfers = []) => { const value = structuredClone(message, { transfer: transfers }); queueMicrotask(() => { if (!this.dead) this.onmessage({ data: value }); }); }, () => 0, ); this.onmessage({ data: { type: "ready" } }); } else if (copy.type === "begin") this.processor.begin(copy); else if (copy.type === "end") this.processor.end(copy.id, copy.version); else if (copy.type === "cancel") this.processor.cancel(copy.id); else if (copy.type === "ack") this.processor.acknowledge(copy.packet); }); } terminate() { this.dead = true; } } const flush = () => new Promise((resolve) => setImmediate(resolve)); async function setup(t) { const oldWorker = globalThis.Worker, oldWindow = globalThis.window; globalThis.Worker = ControlledWorker; globalThis.window = { addEventListener() {} }; t.after(() => { globalThis.Worker = oldWorker; globalThis.window = oldWindow; client.dispose(); }); const row = { buffer: new SharedArrayBuffer(64), descriptor: { offset: 0 } }; row.share = () => ({ buffer: row.buffer, descriptor: row.descriptor }); const client = new StrokeClient(row, 4, (error) => { throw error; }); await client.ready; const engine = new Sculpt(sphere(4)), listeners = new Map(); let commits = 0, message = ""; const rect = { left: 0, top: 0, width: 800, height: 600 }; const captures = new Set(); const viewport = { canvas: { tagName: "CANVAS" }, querySelector() { return this.canvas; }, addEventListener: (name, fn) => listeners.set(name, fn), focus() {}, setPointerCapture(id) { captures.add(id); }, hasPointerCapture(id) { return captures.has(id); }, releasePointerCapture(id) { captures.delete(id); listeners.get("lostpointercapture")({ pointerId: id }); }, getBoundingClientRect: () => ({ ...rect }), }; const host = { engine: () => engine, processor: () => client, error: (e) => { throw e; }, camera: { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] }, settings: { ...defaults, symmetry: false }, ready: () => true, navigation: () => "sculpt", palmRejection: () => false, schedule() {}, invalidate() {}, changed() {}, committed() { commits++; }, message(text) { message = text; }, cursor() {}, pressure() {}, }; const input = new Input(viewport, host); const event = (x = 400, y = 300, type = "pen", pressure = 0.7, id = 1) => ({ clientX: x, clientY: y, pointerType: type, pressure, pointerId: id, button: 0, target: viewport.canvas, preventDefault() {}, }); const frame = async () => { await flush(); client.worker.processor.frame(); await flush(); }; const finish = async () => { let count = 0; while (input.transaction) { await frame(); assert.ok(++count < 1000); } }; return { input, engine, host, client, viewport, captures, event, rect, listeners, frame, finish, get commits() { return commits; }, get message() { return message; }, }; } test("palm rejection ignores touch before capture, focus, cursor or geometry; pen still works", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.host.palmRejection = () => true; a.viewport.focus = a.host.cursor = a.host.pressure = () => { throw Error("Rejected palm reached UI"); }; a.input.down(a.event(400, 300, "touch")); a.input.move(a.event(460, 330, "touch")); a.input.up(a.event(460, 330, "touch")); assert.equal(a.captures.size, 0); assert.equal(a.input.busy, false); assert.deepEqual(a.engine.mesh.positions, before); a.viewport.focus = a.host.cursor = a.host.pressure = () => {}; a.input.down(a.event()); a.input.up(a.event()); await a.finish(); assert.notDeepEqual(a.engine.mesh.positions, before); }); test("pen navigation snapshots rotate/pan/zoom without sculpting", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.host.palmRejection = () => true; for (const mode of ["rotate", "pan", "zoom"]) { a.host.camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0] }; a.host.navigation = () => mode; a.input.down(a.event()); a.host.navigation = () => "sculpt"; a.input.move(a.event(440, 320)); a.input.up(a.event(440, 320)); const c = a.host.camera; assert.equal(c.yaw, mode === "rotate" ? -0.32 : 0); assert.equal(c.pitch, mode === "rotate" ? 0.16 : 0); assert.deepEqual(c.target, mode === "pan" ? [-0.2, 0.1, 0] : [0, 0, 0]); assert.equal(c.zoom, mode === "zoom" ? 1.5 * Math.exp(0.16) : 1.5); assert.equal(a.input.busy, false); } assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 0); }); test("zero pen is a no-op; release flushes newest mouse movement and commits once", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.input.down(a.event(400, 300, "pen", 0)); a.input.up(a.event()); await a.finish(); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 0); a.input.down(a.event(400, 300, "mouse", 0)); a.input.move(a.event(520, 300, "mouse", 0)); a.input.up(a.event(520, 300, "mouse", 0)); assert.equal(a.engine.undoStack.length, 0); await a.finish(); assert.notDeepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 1); assert.equal(a.commits, 2); a.input.up(a.event()); assert.equal(a.commits, 2); }); test("hundreds of moves occupy one slot, no main-thread dabs or per-frame BVH maintenance", async (t) => { const a = await setup(t); let changed = 0; const original = a.engine.changed.bind(a.engine); a.engine.changed = (ids) => { changed++; original(ids); }; a.engine.dab = () => assert.fail("main thread performed a dab"); a.input.down(a.event()); for (let i = 0; i < 1000; i++) a.input.move(a.event(400 + i / 10, 310)); assert.equal(a.input.queue, undefined); assert.equal(a.client.mailbox.words.byteLength, 64); await a.frame(); await a.frame(); assert.equal(changed, 0); a.input.up(a.event()); await a.finish(); assert.equal(changed, 1); assert.equal(a.engine.undoStack.length, 1); }); test("cancel rejects late geometry and rolls back without another animation frame", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.input.down(a.event()); await a.frame(); assert.notDeepEqual(a.engine.mesh.positions, before); a.input.move(a.event(480, 310)); await flush(); a.client.worker.processor.frame(); // geometry message is now in flight a.input.cancel(); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.input.busy, true); await flush(); assert.equal(a.input.busy, false); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 0); }); test("cancel wins over a completion that has not yet committed on main", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.input.down(a.event()); await a.frame(); a.input.up(a.event()); await flush(); a.client.worker.processor.frame(); a.input.cancel(); await flush(); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 0); assert.equal(a.commits, 0); }); test("touch takeover, pen palm rejection, capture loss and pen-over-touch handoff", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.input.down(a.event(400, 300, "touch", 1, 1)); await a.frame(); a.input.down(a.event(450, 320, "touch", 1, 2)); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.input.active.mode, "gesture"); a.input.move(a.event(500, 340, "touch", 1, 2)); assert.notEqual(a.host.camera.zoom, 1.5); a.input.up(a.event(400, 300, "touch", 0, 1)); a.input.up(a.event(500, 340, "touch", 0, 2)); await flush(); assert.equal(a.input.busy, false); a.input.down(a.event(400, 300, "pen", 0.7, 3)); a.input.down(a.event(440, 310, "touch", 1, 4)); assert.equal(a.input.active.id, 3); await a.frame(); a.listeners.get("lostpointercapture")({ pointerId: 3 }); await flush(); assert.equal(a.input.busy, false); a.input.down(a.event(400, 300, "touch", 1, 5)); await a.frame(); a.input.down(a.event(400, 300, "pen", 0.8, 6)); await flush(); assert.equal(a.input.active.id, 6); a.input.up(a.event(400, 300, "pen", 0, 6)); await a.finish(); assert.equal(a.engine.undoStack.length, 1); }); test("resize before release, geometry delivery, or completion preserves existing undo and redo", async (t) => { const a = await setup(t); for (let i = 0; i < 2; i++) { a.input.down(a.event(400 + i * 20, 300)); a.input.up(a.event()); await a.finish(); } a.engine.restore(); const before = a.engine.mesh.positions.slice(), undo = [...a.engine.undoStack], redo = [...a.engine.redoStack]; for (const mode of ["release", "packet", "complete", "grab"]) { a.host.settings.brush = mode === "grab" ? "grab" : "draw"; a.input.down(a.event()); a.input.move(a.event(440, 320)); await a.frame(); await a.frame(); assert.notDeepEqual(a.engine.mesh.positions, before); if (mode === "complete") a.input.up(a.event()); a.rect.width = 650; if (mode === "packet") { a.input.move(a.event(470, 330)); } else a.input.up(a.event()); await a.finish(); assert.deepEqual(a.engine.mesh.positions, before); assert.deepEqual(a.engine.undoStack, undo); assert.deepEqual(a.engine.redoStack, redo); a.rect.width = 800; } }); test("commits repair face-normal caches; undo/redo and adjacent strokes agree with full normals", async (t) => { const a = await setup(t); for (const x of [400, 430, 450]) { a.input.down(a.event(x, 300)); a.input.move(a.event(x + 35, 330)); a.input.up(a.event()); await a.finish(); const expected = sphere(4); expected.positions.set(a.engine.mesh.positions); updateNormals(expected); assert.deepEqual(a.engine.mesh.faceNormals, expected.faceNormals); assert.deepEqual(a.engine.mesh.normals, expected.normals); } for (const redo of [false, true]) { a.engine.restore(redo); const expected = sphere(4); expected.positions.set(a.engine.mesh.positions); updateNormals(expected); assert.deepEqual(a.engine.mesh.normals, expected.normals); } }); test("worker termination rolls back provisional geometry and late packets cannot revive it", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); a.input.down(a.event()); await a.frame(); a.client.dispose(); await flush(); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.input.busy, false); assert.equal(a.engine.undoStack.length, 0); }); test("pending pen termination and stale canvas cannot revive a canceled contact", async (t) => { const a = await setup(t), before = a.engine.mesh.positions.slice(); for (const termination of [ "pointercancel", "lostpointercapture", "pointerup", "resize", "canvas", "unready", "continuation", ]) { a.input.down(a.event(400, 300, "touch", 1, 1)); await a.frame(); const pen = a.event(400, 300, "pen", 0.8, 2); let prevented = false; pen.preventDefault = () => { prevented = true; }; a.input.down(pen); assert.ok(prevented); assert.ok(a.captures.has(2)); const id = a.client.nextId; if (termination === "pointerup") a.input.up(a.event(400, 300, "pen", 0, 2)); else if (termination === "resize") a.rect.left = 10; else if (termination === "canvas") a.viewport.canvas = { tagName: "CANVAS" }; else if (termination === "unready") a.host.ready = () => false; else if (termination === "continuation") { a.client.message({ type: "canceled", id: a.client.job.id }); a.listeners.get("pointercancel")({ pointerId: 2 }); } else a.listeners.get(termination)({ pointerId: 2 }); await flush(); assert.equal(a.client.nextId, id); assert.equal(a.input.busy, false); assert.equal(a.captures.size, 0); assert.equal(a.engine.stroke, null); assert.deepEqual(a.engine.mesh.positions, before); assert.equal(a.engine.undoStack.length, 0); a.rect.left = 0; a.host.ready = () => true; } // A late old-touch cancellation cannot take ownership from the live pen. a.input.down(a.event(400, 300, "touch", 1, 1)); await a.frame(); a.input.down(a.event(400, 300, "pen", 0.8, 2)); a.listeners.get("pointercancel")({ pointerId: 1 }); await flush(); assert.equal(a.input.active.id, 2); a.input.up(a.event(400, 300, "pen", 0, 2)); await a.finish(); assert.equal(a.engine.undoStack.length, 1); // DOM controls never acquire canvas capture or prevent touch defaults. let prevented = false; a.input.down({ ...a.event(), pointerType: "touch", target: { tagName: "BUTTON" }, preventDefault: () => { prevented = true; }, }); assert.equal(prevented, false); assert.equal(a.captures.size, 0); }); test("renderer staging remains immutable while upload is in flight", async () => { const s = new Sculpt(sphere(4)), r = new Renderer(); r.mesh = s.mesh; r.canvas = { width: 800, height: 600 }; r.vertices = { view: new Float32Array((s.mesh.positions.length / 3) * 8) }; r.camera = { view: new Float32Array(20) }; const waits = []; r.core = { syncRows: () => new Promise((resolve, reject) => waits.push({ resolve, reject })), }; const camera = { yaw: 0, pitch: 0, zoom: 1.5, target: [0, 0, 0], color: [0.5, 0.3, 0.2], }; const pending = r.publish(s, camera), staged = r.vertices.view.slice(); s.begin({}); s.dab({ position: [0, 0, 1], normal: [0, 0, 1] }); s.end(); assert.equal(await r.publish(s, camera), false); assert.deepEqual(r.vertices.view, staged); waits.shift().resolve(); await Promise.resolve(); waits.shift().resolve(); await pending; assert.ok(s.dirty.size > 0); const failure = r.publish(s, camera); waits.shift().reject(new Error("upload")); await assert.rejects(failure, /upload/); assert.equal(r.busy, false); });