// Run explicitly in a fresh /sculpt page: // await (await import('/assets/sculpt/browser-check.js')).check() // Synthetic input checks logic, not physical pen feel. Hooks are always restored. import { Renderer } from "./renderer.js"; import { Navigation } from "./navigation.js"; export async function check() { const $ = (id) => document.getElementById(id), results = []; const assert = (value, message) => { if (!value) throw new Error(message); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const wait = async (fn) => { const start = performance.now(); while (!fn()) { if (performance.now() - start > 15000) throw new Error("Timed out waiting for UI"); await sleep(25); } }; const viewport = $("viewport"), capture = viewport.setPointerCapture, createURL = URL.createObjectURL, publish = Renderer.prototype.publish; const navRoot = $("view-navigation"), navCapture = navRoot.setPointerCapture, navRender = Navigation.prototype.render; let nav; navRoot.setPointerCapture = () => {}; Navigation.prototype.render = function () { nav = this; return navRender.call(this); }; let blob, engine, camera, processor, publications = 0, failNext = false; viewport.setPointerCapture = () => {}; URL.createObjectURL = function (value) { blob = value; return createURL.call(this, value); }; Renderer.prototype.publish = function (...args) { engine = args[0]; camera = args[1]; processor = this.processor; publications++; if (failNext) { failNext = false; return Promise.reject(new Error("Synthetic upload failure")); } return publish.apply(this, args); }; const saved = async () => { await wait(() => !$("save").disabled); $("save").click(); return JSON.parse(await blob.text()); }; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const pointer = (type, x, y, pressure = 0.7, pointerType = "pen", id = 7) => { const r = viewport.getBoundingClientRect(); viewport.querySelector("canvas").dispatchEvent( new PointerEvent(type, { bubbles: true, pointerId: id, pointerType, clientX: r.left + r.width * x, clientY: r.top + r.height * y, pressure, button: 0, buttons: type === "pointerup" ? 0 : 1, }), ); }; const stroke = async (p) => { pointer("pointerdown", 0.55, 0.44, p); pointer("pointermove", 0.59, 0.51, p); pointer("pointerup", 0.59, 0.51, 0); await wait(() => !$("save").disabled); await sleep(100); }; try { await wait(() => $("loading").hidden && $("error").hidden); const initialCanvas = viewport.querySelector("canvas"); const bounds = JSON.stringify(viewport.getBoundingClientRect()); assert( $("tools-panel").hidden && $("inspector").hidden, "Panels must start closed", ); const hover = (id, type = "pointerenter") => $(id).dispatchEvent( new PointerEvent(type, { pointerType: "pen", buttons: 0 }), ); hover("tools-toggle"); assert(!$("tools-panel").hidden, "Pen hover did not reveal brushes"); const palette = $("tools-panel").getBoundingClientRect(); assert( palette.left >= 50 && palette.width <= 216, "Brush popover overlaps rail or has oversized width", ); assert( document.querySelectorAll(".tool-rail [data-brush] svg").length === 5, "Brush rail must have five icons", ); assert( $("radius").style.getPropertyValue("--slider-value").startsWith("32.14"), "Basecoat size fill is not synchronized", ); pointer("pointerdown", 0.8, 0.8, 1, "touch", 91); pointer("pointermove", 0.82, 0.81, 1, "touch", 91); pointer("pointerup", 0.82, 0.81, 0, "touch", 91); assert(!$("tools-panel").hidden, "Rejected palm dismissed hover tools"); hover("tools-toggle", "pointerleave"); await sleep(100); hover("tools-panel"); await sleep(300); assert(!$("tools-panel").hidden, "Crossing panel gap closed tools"); hover("tools-panel", "pointerleave"); await sleep(300); assert($("tools-panel").hidden, "Hover panel did not dismiss"); $("settings-toggle").click(); hover("settings-toggle", "pointerleave"); await sleep(300); assert(!$("inspector").hidden, "Tapped panel did not stay open"); pointer("pointerdown", 0.2, 0.8, 1, "touch", 91); pointer("pointerup", 0.2, 0.8, 0, "touch", 91); assert(!$("inspector").hidden, "Rejected palm dismissed pinned settings"); $("pen-settings").open = true; assert( $("reset-pressure").getBoundingClientRect().bottom <= $("pen-settings").nextElementSibling.getBoundingClientRect().top, "Expanded pen controls overlap display disclosure", ); $("gain").dispatchEvent( new PointerEvent("pointerdown", { pointerType: "pen", bubbles: true }), ); pointer("pointerdown", 0.2, 0.8, 1, "touch", 91); assert( !$("inspector").hidden, "Rejected palm interrupted slider interaction", ); pointer("pointerup", 0.2, 0.8, 0, "touch", 91); $("gain").focus(); $("gain").dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), ); assert( $("inspector").hidden && document.activeElement === $("settings-toggle"), "Escape did not restore launcher focus", ); $("pen-settings").open = false; assert( initialCanvas === viewport.querySelector("canvas") && bounds === JSON.stringify(viewport.getBoundingClientRect()), "Menus resized/replaced canvas", ); results.push( "pen hover / gap grace / auto-hide / tap pin / palm-safe panels and slider / Escape focus / unchanged canvas", ); $("new").click(); $("density").value = "4"; $("new-dialog").close("create"); await wait( () => viewport.querySelector("canvas") !== initialCanvas && $("loading").hidden && !$("new").disabled, ); await sleep(100); const original = await saved(); await stroke(0); assert(same(original, await saved()), "Zero pen pressure altered geometry"); results.push("zero pen pressure"); await stroke(0.8); const edited = await saved(); assert(!same(original, edited), "Pen stroke did not deform mesh"); assert(!$("undo").disabled, "Undo unavailable"); $("undo").click(); assert(same(original, await saved()), "Undo not exact"); $("redo").click(); assert(same(edited, await saved()), "Redo not exact"); results.push("pen sculpt / exact undo / redo"); pointer("pointerdown", 0.48, 0.5, 0.9); await sleep(100); pointer("pointermove", 0.52, 0.55, 0.9); viewport.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 7 })); assert(same(edited, await saved()), "Cancel did not restore mesh"); results.push("pointer cancellation"); for (const settling of [false, true]) { const before = engine.mesh.positions.slice(); const history = [...engine.undoStack]; const redoHistory = [...engine.redoStack]; pointer("pointerdown", 0.48, 0.5, 0.9); pointer("pointermove", 0.56, 0.55, 0.9); await wait(() => engine.mesh.positions.some((v, i) => v !== before[i])); $("frame").focus(); if (settling) pointer("pointerup", 0.56, 0.55, 0); $("frame").dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, }), ); assert( engine.mesh.positions.every((v, i) => v === before[i]), "Focused navigation swallowed sculpt cancellation", ); if (!settling) pointer("pointerup", 0.56, 0.55, 0); await wait(() => !$("save").disabled); assert( engine.mesh.positions.every((v, i) => v === before[i]) && engine.undoStack.length === history.length && engine.undoStack.every((v, i) => v === history[i]) && engine.redoStack.length === redoHistory.length && engine.redoStack.every((v, i) => v === redoHistory[i]), "Focused navigation cancellation changed geometry/history after settlement", ); } results.push( "focused navigation Escape rolls back active and settling sculpt", ); assert($("palm-rejection").checked, "Palm rejection must default on"); await sleep(100); const beforePalm = structuredClone(camera); pointer("pointerdown", 0.48, 0.5, 1, "touch", 1); pointer("pointermove", 0.58, 0.54, 1, "touch", 1); pointer("pointerup", 0.58, 0.54, 0, "touch", 1); await sleep(100); assert( same(beforePalm, camera) && same(edited, await saved()), "Palm changed canvas", ); results.push("default palm rejection"); $("palm-rejection").checked = false; $("palm-rejection").dispatchEvent(new Event("change")); pointer("pointerdown", 0.48, 0.5, 1, "touch", 1); await sleep(100); pointer("pointerdown", 0.56, 0.52, 1, "touch", 2); pointer("pointermove", 0.58, 0.54, 1, "touch", 2); pointer("pointerup", 0.48, 0.5, 0, "touch", 1); pointer("pointerup", 0.58, 0.54, 0, "touch", 2); assert(same(edited, await saved()), "Pinch left sculpt marks"); await wait(() => camera.zoom !== beforePalm.zoom); $("palm-rejection").checked = true; $("palm-rejection").dispatchEvent(new Event("change")); results.push("two-touch rollback/navigation"); $("quality").value = "economy"; $("quality").dispatchEvent(new Event("change")); await wait(() => $("loading").hidden && !$("quality").disabled); assert(same(edited, await saved()), "Quality changed geometry"); assert(!$("undo").disabled, "Quality lost history"); results.push("quality preserves mesh/history"); const oldCanvas = viewport.querySelector("canvas"); viewport.style.width = "85%"; await wait( () => viewport.querySelector("canvas") !== oldCanvas && $("loading").hidden, ); assert(same(edited, await saved()), "Resize changed geometry"); assert(!$("undo").disabled, "Resize lost history"); const smallCanvas = viewport.querySelector("canvas"); viewport.style.width = ""; await wait( () => viewport.querySelector("canvas") !== smallCanvas && $("loading").hidden, ); results.push("resize preserves mesh/history"); const positions = engine.mesh.positions.slice(), undo = [...engine.undoStack], redo = [...engine.redoStack], bytes = engine.historyBytes; pointer("pointerdown", 0.48, 0.5, 0.9); pointer("pointermove", 0.56, 0.55, 0.9); await wait(() => engine.mesh.positions.some((v, i) => v !== positions[i])); const beforeResize = viewport.querySelector("canvas"); viewport.style.width = "90%"; // Release in this task, before ResizeObserver or the 200 ms debounce runs. pointer("pointerup", 0.56, 0.55, 0); const exact = () => engine.mesh.positions.every((v, i) => v === positions[i]) && engine.undoStack.length === undo.length && engine.undoStack.every((v, i) => v === undo[i]) && engine.redoStack.length === redo.length && engine.redoStack.every((v, i) => v === redo[i]) && engine.historyBytes === bytes; assert( exact(), "Resize/release committed or retained provisional geometry", ); pointer("pointerdown", 0.5, 0.5, 0.9); assert(!engine.stroke, "Stroke started against a stale renderer size"); pointer("pointerup", 0.5, 0.5, 0); await wait( () => viewport.querySelector("canvas") !== beforeResize && $("loading").hidden, ); assert(exact(), "Resize recreation changed history or geometry"); await stroke(0.8); assert( !same(edited, await saved()), "New stroke failed after resize recovery", ); $("undo").click(); assert(same(edited, await saved()), "Recovery stroke undo failed"); const resizedCanvas = viewport.querySelector("canvas"); viewport.style.width = ""; await wait( () => viewport.querySelector("canvas") !== resizedCanvas && $("loading").hidden, ); results.push( "resize during stroke: immediate exact rollback / stale-start gate / recovery", ); const dt = new DataTransfer(); dt.items.add(new File(['{"version":42}'], "invalid.yawn-sculpt")); $("file").files = dt.files; $("file").dispatchEvent(new Event("change")); await wait(() => !$("open").disabled); assert(same(edited, await saved()), "Invalid project replaced geometry"); results.push("invalid project preservation"); const native = JSON.stringify(edited), valid = new DataTransfer(); valid.items.add(new File([native], "roundtrip.yawn-sculpt")); $("file").files = valid.files; $("file").dispatchEvent(new Event("change")); await wait(() => !$("open").disabled && $("loading").hidden); assert(same(edited, await saved()), "Project roundtrip changed geometry"); results.push("native project roundtrip"); $("export").click(); const obj = await blob.text(); assert( obj.split("\n").filter((l) => l.startsWith("v ")).length === 2562, "OBJ vertex count", ); assert( obj.split("\n").filter((l) => l.startsWith("f ")).length === 5120, "OBJ face count", ); results.push("OBJ export"); const navPointer = (mode, type, x = 100, y = 100) => document.querySelector(`[data-nav="${mode}"]`).dispatchEvent( new PointerEvent(type, { bubbles: true, pointerType: "pen", pointerId: 90, button: 0, clientX: x, clientY: y, }), ); await wait(() => nav); for (const mode of ["orbit", "pan", "zoom"]) { const before = structuredClone(nav.host.camera); navPointer(mode, "pointerdown"); assert(nav.busy, "Navigation did not capture gesture"); pointer("pointerdown", 0.5, 0.5); assert(!engine.stroke, "Canvas sculpt started during navigation"); viewport.dispatchEvent( new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true, }), ); assert(same(nav.host.camera, before), "Wheel competed with navigation"); navPointer(mode, "pointermove", 126, 119); assert(!same(nav.host.camera, before), "Navigation did not move camera"); navRoot.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), ); assert( !nav.busy && same(nav.host.camera, before), "Escape did not restore camera", ); navPointer(mode, "pointerdown"); navPointer(mode, "pointerup", 126, 119); assert( !nav.busy && !same(nav.host.camera, before), "Navigation release did not finish", ); await sleep(80); } const navBefore = structuredClone(nav.host.camera); pointer("pointerdown", 0.5, 0.5); navPointer("orbit", "pointerdown"); assert(!nav.busy, "Navigation started during sculpt"); pointer("pointerup", 0.5, 0.5, 0); navPointer("orbit", "pointerdown"); assert(!nav.busy, "Navigation started during worker settlement"); await wait(() => !$("save").disabled); $("undo").click(); assert( same(nav.host.camera, navBefore), "Rejected navigation changed camera", ); const viewBefore = structuredClone(camera), aspectBefore = viewport.clientWidth / viewport.clientHeight; const shortZoom = viewBefore.zoom * Math.min(1, aspectBefore); const resizeTo = async (width, height) => { const previous = viewport.querySelector("canvas"); viewport.style.width = width; viewport.style.height = height; await wait( () => viewport.querySelector("canvas") !== previous && $("loading").hidden, ); await sleep(100); }; await resizeTo("600px", "1000px"); assert( Math.abs(camera.zoom * 0.6 - shortZoom) < 1e-9, "Portrait changed apparent zoom", ); assert( same(camera.target, viewBefore.target) && camera.yaw === viewBefore.yaw && camera.pitch === viewBefore.pitch, "Orientation reset camera pose", ); await resizeTo("", ""); assert( Math.abs(camera.zoom - viewBefore.zoom) < 1e-9, "Orientation roundtrip changed zoom", ); assert(same(edited, await saved()), "Navigation changed geometry"); document.querySelector('[data-brush="draw"]').click(); results.push( "direct navigation / mutual exclusion / Escape rollback / orientation / geometry intact", ); await sleep(300); const idle = publications; await sleep(500); assert(publications === idle, "Idle repeated publications"); results.push("idle: zero publications over 500 ms"); failNext = true; $("frame").click(); await wait(() => !$("error").hidden); assert(!$("save").disabled, "Error disabled save"); assert(same(edited, await saved()), "Upload failure lost geometry"); $("retry").click(); await wait(() => $("loading").hidden && $("error").hidden); assert(same(edited, await saved()), "Retry lost geometry"); results.push("upload failure / save / retry"); const recovered = engine.mesh.positions.slice(); pointer("pointerdown", 0.5, 0.5, 0.9); await wait(() => engine.mesh.positions.some((v, i) => v !== recovered[i])); // Publication captures the renderer's client after recreation; geometry can // arrive first. Never inject into the previous, already-disposed worker. await wait(() => processor && !processor.disposed && processor.job); const oldProcessor = processor; oldProcessor.worker.dispatchEvent(new Event("error")); await wait(() => !$("error").hidden && !$("save").disabled); assert( same(edited, await saved()), "Worker failure retained provisional geometry", ); assert(oldProcessor.disposed, "Failed worker was not terminated"); $("retry").click(); await wait( () => $("loading").hidden && $("error").hidden && processor !== oldProcessor, ); await stroke(0.8); assert( !same(edited, await saved()), "New worker failed to sculpt after retry", ); $("undo").click(); assert(same(edited, await saved()), "Recovered worker undo failed"); results.push( "sculpt worker failure / exact rollback / new worker / sculpt / undo", ); return { passed: results.length, checks: results, publications, physicalPenVerified: false, }; } finally { viewport.setPointerCapture = capture; URL.createObjectURL = createURL; Renderer.prototype.publish = publish; navRoot.setPointerCapture = navCapture; Navigation.prototype.render = navRender; } }