Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Sandbox clock smoke — `npm run check:sandbox`.
*
* Drives the REAL tick()/step()/wake() out of public/pattern-sandbox.html with
* a controlled clock. The script text is taken verbatim; the page exposes its
* `live` state as globalThis.__pfLive for exactly this.
*
* What this guards, and why it needs a fake clock rather than a browser: the
* live preview turns wall-clock time into simulation time, and getting that
* wrong is invisible in every way a normal test looks. dt used to be clamped to
* 50 ms and handed over once per frame, so a preview rendering at 10fps
* advanced the simulation 50 ms per 100 ms of real time — a flocking pattern
* moved at half speed on the wall and full speed on its own page, and nothing
* errored. The numbers below are the arithmetic that made it visible.
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const SANDBOX = path.join(process.cwd(), "public", "pattern-sandbox.html");
const script = fs.readFileSync(SANDBOX, "utf8").match(/<script>([\s\S]*)<\/script>/)?.[1];
if (!script) {
console.error("Could not find the sandbox script block in", SANDBOX);
process.exit(1);
}
let failures = 0;
function check(label: string, actual: unknown, expected: unknown) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) failures += 1;
console.log(
`${ok ? " ok " : "FAIL "}${label}${ok ? "" : `\n got ${JSON.stringify(actual)}\n want ${JSON.stringify(expected)}`}`,
);
}
type Posted = { type?: string; ok?: boolean; error?: string; dataUrl?: string };
type Harness = {
live: () => { runtime: { params: Record<string, number> } };
send: (data: unknown) => void;
/** Advance the wall clock and run the pending frame, if one is scheduled. */
frame: (ms: number) => void;
asleep: () => boolean;
/** What the pattern reported: accumulated dt, as seconds. */
simSeconds: () => number | null;
/** Everything posted back to the parent. */
posted: Posted[];
};
function boot(): Harness {
let painted: Uint8ClampedArray | null = null;
let pending: ((now: number) => void) | null = null;
let handler: ((event: { data: unknown }) => void) | null = null;
let clock = 1000;
const posted: Posted[] = [];
const ctx = {
createImageData: (w: number, h: number) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
putImageData: (img: { data: Uint8ClampedArray }) => { painted = img.data.slice(); },
drawImage: () => {},
getImageData: (_x: number, _y: number, w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4) }),
};
const canvas = () => ({ width: 128, height: 64, getContext: () => ctx, toDataURL: () => "data:," });
const sandbox: Record<string, unknown> = {
document: { getElementById: canvas, createElement: canvas },
parent: { postMessage: (m: Posted) => posted.push(m) },
performance: { now: () => clock },
requestAnimationFrame: (fn: (now: number) => void) => { pending = fn; return 1; },
cancelAnimationFrame: () => { pending = null; },
window: {
addEventListener: (type: string, fn: (event: { data: unknown }) => void) => {
if (type === "message") handler = fn;
},
},
console,
};
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(script as string, sandbox);
return {
live: () => sandbox.__pfLive as Harness extends never ? never : { runtime: { params: Record<string, number> } },
send: (data) => handler?.({ data }),
frame: (ms) => { clock += ms; const fn = pending; pending = null; fn?.(clock); },
asleep: () => pending === null,
simSeconds: () => {
if (!painted) return null;
let lit = 0;
for (let i = 0; i < painted.length; i += 4) if (painted[i] > 128) lit += 1;
return Number((lit / 100).toFixed(2));
},
posted,
};
}
/** Reports the dt it was handed, as one lit pixel per 10 ms. */
const PROBE = `
// @matrix 128x64
export function setup(p){ p.t = 0; p.turns = 0; }
export function update(dt, input, p){
p.t += dt;
if (input.knobDeltas && input.knobDeltas[0]) p.turns += 1;
}
export function draw(display, p, time){
const lit = Math.round(p.t * 100);
for (let i = 0; i < lit && i < 128*64; i++) display.setPixel(i % 128, (i/128)|0, 255,255,255);
}`;
const load = (s: Harness, running: boolean) =>
s.send({
type: "pf-load", code: PROBE, running,
knobValues: [0.5, 0.5, 0.5, 0.5], knobRanges: [[0, 1], [0, 1], [0, 1], [0, 1]],
knobWrap: [false, false, false, false], knobUnitsPerTurn: [1, 1, 1, 1],
});
// ── A paused preview schedules nothing ──────────────────────────────────────
// The wall mounts one sandbox per nearby card. They used to re-arm rAF forever
// and bail out inside the callback — cheap per call, and thirteen documents
// holding a live animation callback sharing the budget with the one card the
// cursor is on.
{
const s = boot();
load(s, false);
check("a paused load wakes once", s.asleep(), false);
s.frame(16);
check("…paints that frame", s.simSeconds() !== null, true);
check("…then stops scheduling entirely", s.asleep(), true);
s.frame(16);
check("…and stays asleep", s.asleep(), true);
s.send({ type: "pf-run", running: true });
check("running wakes it", s.asleep(), false);
s.frame(16);
check("…and it keeps itself going", s.asleep(), false);
s.send({ type: "pf-run", running: false });
s.frame(16);
check("pausing settles, then sleeps", s.asleep(), true);
s.send({ type: "pf-knobs", values: [0.7, 0.5, 0.5, 0.5], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] });
check("a knob turn wakes a paused preview", s.asleep(), false);
s.frame(16);
check("…for exactly one repaint", s.asleep(), true);
}
// ── Wall-clock time reaches the pattern at any frame rate ───────────────────
// Before the catch-up stepping these read 3.00 / 3.00 / 3.00 / 2.25 / 1.50 /
// 0.90 — i.e. below 20fps the pattern ran in slow motion, proportionally.
for (const [label, frameMs, frames] of [
["60fps", 16.667, 180],
["30fps", 33.333, 90],
["20fps", 50, 60],
["15fps", 66.667, 45],
["10fps", 100, 30],
["6fps", 166.667, 18],
] as const) {
const s = boot();
load(s, true);
s.frame(0); // the wake frame carries no elapsed time
for (let i = 0; i < frames; i += 1) s.frame(frameMs);
check(`${label}: 3s of wall clock is 3s of simulation`, s.simSeconds(), 3);
}
// ── …but a genuine stall is dropped, never fast-forwarded ───────────────────
{
const s = boot();
load(s, true);
s.frame(0);
s.frame(60_000); // a minute in a background tab
check("a 60s stall advances at most MAX_CATCHUP", s.simSeconds(), 0.25);
}
// ── A knob turn is one event, not one per catch-up step ─────────────────────
{
const s = boot();
load(s, true);
s.frame(0);
s.send({ type: "pf-knobs", values: [0.9, 0.5, 0.5, 0.5], ranges: [[0, 1], [0, 1], [0, 1], [0, 1]] });
s.frame(250); // 250ms behind — five catch-up steps in the one frame
check("a knob turn is seen once, not once per substep", s.live().runtime.params.turns, 1);
}
// ── The still path is untouched, and never runs the live clock ──────────────
// Thumbnails step at a fixed 1/fps with no clamp. A still must also not wake
// the animation loop: the wall renders one per card through a single hidden
// sandbox, and a still that left a frame scheduled would start them all.
{
const s = boot();
load(s, false);
s.frame(16); // settle the paused load, then sleep
check("paused before the still", s.asleep(), true);
s.send({
type: "pf-still", id: "x", code: PROBE,
knobValues: [0.5, 0.5, 0.5, 0.5], knobRanges: [[0, 1], [0, 1], [0, 1], [0, 1]],
seconds: 0.9, fps: 15,
});
const result = s.posted.find((m) => m.type === "pf-still-result");
check("a still comes back", result?.ok, true);
check("…carrying an image", typeof result?.dataUrl === "string", true);
check("…and schedules no frame of its own", s.asleep(), true);
}
console.log(failures === 0 ? "\nAll sandbox clock checks passed." : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);