Tests no longer sit next to the implementation. They import the package through ../src, the root tsconfig still type-checks them, and the package build and declarations cover only src/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
556 lines
18 KiB
TypeScript
556 lines
18 KiB
TypeScript
/**
|
|
* Property tests: random operation sequences and pointer streams must keep
|
|
* the scene's structural invariants after every step, and must never leave
|
|
* an operation stuck once all pointers are released.
|
|
*/
|
|
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
above,
|
|
back,
|
|
createOriginScene,
|
|
defineOriginChoreography,
|
|
fade,
|
|
forward,
|
|
gesture,
|
|
OriginGestureSurface,
|
|
OriginScene,
|
|
OriginSceneError,
|
|
originView,
|
|
replace,
|
|
slideLeft,
|
|
slideRight,
|
|
under,
|
|
type OriginAction,
|
|
type OriginOperationHandle,
|
|
} from "../src";
|
|
import type { SceneGraph } from "../src/scene/graph";
|
|
import type { OriginScene as Scene } from "../src/scene/types";
|
|
|
|
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
|
|
|
|
// Support -------------------------------------------------------------------
|
|
|
|
function rng(seed: number) {
|
|
let a = seed >>> 0;
|
|
const next = () => {
|
|
a = (a + 0x6d2b79f5) >>> 0;
|
|
let t = a;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
return {
|
|
next,
|
|
int: (n: number) => Math.floor(next() * n),
|
|
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
|
chance: (p: number) => next() < p,
|
|
};
|
|
}
|
|
|
|
/** Deterministic `requestAnimationFrame` and clock. */
|
|
function installFrames() {
|
|
let now = 1000;
|
|
let queue: FrameRequestCallback[] = [];
|
|
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
|
queue.push(cb)) as typeof window.requestAnimationFrame;
|
|
performance.now = () => now;
|
|
return {
|
|
advance(ms = 16) {
|
|
now += ms;
|
|
const run = queue;
|
|
queue = [];
|
|
for (const cb of run) cb(now);
|
|
},
|
|
drain() {
|
|
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function flush(rounds = 4) {
|
|
for (let i = 0; i < rounds; i += 1)
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
const component = (name: string): Component =>
|
|
defineComponent({ name, render: () => h("div", name) });
|
|
|
|
/** One-line summary of a scene, for failure traces. */
|
|
const describeScene = (scene: Scene) => {
|
|
const nodes = scene.nodes.value
|
|
.map(
|
|
(n) =>
|
|
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
|
|
)
|
|
.join(" ");
|
|
const operations = scene.operations.value
|
|
.map(
|
|
(o) =>
|
|
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
|
|
)
|
|
.join(", ");
|
|
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
|
|
};
|
|
|
|
const graphOf = (scene: Scene) =>
|
|
(scene as unknown as { graph: SceneGraph }).graph;
|
|
|
|
/** Structural invariants that must hold after every step. */
|
|
function checkInvariants(scene: Scene, label: string, rootCount: number) {
|
|
const graph = graphOf(scene);
|
|
const nodes = [...graph.nodes.values()];
|
|
const roots = scene.roots.value;
|
|
const fail = (message: string) => {
|
|
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
|
|
};
|
|
|
|
if (roots.length !== rootCount)
|
|
fail(`expected ${rootCount} roots, found ${roots.length}`);
|
|
if (new Set(roots).size !== roots.length) fail("duplicate roots");
|
|
for (const key of roots) {
|
|
const node = graph.node(key);
|
|
if (!node) fail(`root ${key} does not exist`);
|
|
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
|
|
}
|
|
|
|
for (const node of nodes) {
|
|
// Incoming edges point back at their node.
|
|
if (node.incomingOperationId !== undefined) {
|
|
const incoming = graph.operation(node.incomingOperationId);
|
|
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
|
|
else if (incoming.targetKey !== node.key)
|
|
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
|
|
}
|
|
|
|
// History links exist and are acyclic.
|
|
const seen = new Set([node.key]);
|
|
for (let key = node.previousNodeKey; key;) {
|
|
const previous = graph.node(key);
|
|
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
|
|
if (seen.has(key)) fail(`${node.key} has cyclic history`);
|
|
seen.add(key);
|
|
key = previous?.previousNodeKey;
|
|
}
|
|
|
|
if (node.state === "parked") {
|
|
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
|
|
if (node.incomingOperationId !== undefined)
|
|
fail(`parked ${node.key} is positioned by an edge`);
|
|
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
|
|
if (scene.styleForNode(node.key).visibility !== "hidden")
|
|
fail(`parked ${node.key} is visible`);
|
|
continue;
|
|
}
|
|
|
|
// Every presented node reaches a root through acyclic incoming edges.
|
|
const visited = new Set<string>();
|
|
let current = node;
|
|
for (;;) {
|
|
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
|
|
visited.add(current.key);
|
|
const incoming = graph.incomingOf(current);
|
|
if (!incoming) break;
|
|
const origin = graph.node(incoming.originKey);
|
|
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
|
|
current = origin!;
|
|
}
|
|
if (!roots.includes(current.key))
|
|
fail(`${node.key} is detached from every root`);
|
|
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
|
|
fail(`exposed ${node.key} is live`);
|
|
}
|
|
|
|
// Every live operation joins existing nodes; one per origin.
|
|
const origins = new Set<string>();
|
|
for (const operation of scene.operations.value) {
|
|
if (!graph.node(operation.originKey))
|
|
fail(`operation ${operation.id} has a missing origin`);
|
|
if (!graph.node(operation.targetKey))
|
|
fail(`operation ${operation.id} has a missing target`);
|
|
if (origins.has(operation.originKey))
|
|
fail(`${operation.originKey} has two live operations`);
|
|
origins.add(operation.originKey);
|
|
}
|
|
}
|
|
|
|
/** Additional invariants once every operation has finished. */
|
|
function checkResting(scene: Scene, rootCount: number, label: string) {
|
|
checkInvariants(scene, label, rootCount);
|
|
const states = scene.nodes.value.map((node) => node.state);
|
|
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
|
|
expect(states, `${label}: states`).not.toContain("transitioning");
|
|
expect(
|
|
states.filter((state) => state === "active"),
|
|
`${label}: one active entry per root`,
|
|
).toHaveLength(rootCount);
|
|
}
|
|
|
|
// Scene operations ----------------------------------------------------------
|
|
|
|
const drawerOpen = defineOriginChoreography({
|
|
name: "drawer-open",
|
|
persistAtRest: true,
|
|
effects: ({ progress }) => ({
|
|
source: { transform: `translateX(${progress * 66}%)` },
|
|
target: { transform: `translateX(${(progress - 1) * 66}%)` },
|
|
}),
|
|
});
|
|
const drawerClose = defineOriginChoreography({
|
|
name: "drawer-close",
|
|
commitThreshold: 0.2,
|
|
commitVelocity: 0.5,
|
|
effects: ({ progress }) => ({
|
|
source: { transform: `translateX(${-progress * 66}%)` },
|
|
}),
|
|
});
|
|
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
|
|
const views = ["A", "B", "C", "D"].map((name) =>
|
|
originView(component(name), undefined, { key: name.toLowerCase() }),
|
|
);
|
|
|
|
function randomAction(r: ReturnType<typeof rng>): OriginAction {
|
|
const choreography = r.pick(choreographies);
|
|
const target = r.pick(views);
|
|
const placement = r.pick(["above", "under", undefined] as const);
|
|
switch (r.int(6)) {
|
|
case 0:
|
|
return forward(target, choreography, { placement });
|
|
case 1:
|
|
return replace(target, choreography, { placement });
|
|
case 2:
|
|
case 3:
|
|
return back(choreography, { placement });
|
|
case 4:
|
|
return above(target, choreography, {
|
|
history: r.pick(["push", "replace", "back"] as const),
|
|
});
|
|
default:
|
|
return under(target, choreography);
|
|
}
|
|
}
|
|
|
|
async function runOperations(seed: number) {
|
|
const r = rng(seed);
|
|
const frames = installFrames();
|
|
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
|
|
const scene = createOriginScene({ initial });
|
|
const handles: OriginOperationHandle[] = [];
|
|
const unexpected: unknown[] = [];
|
|
const expectSceneError = (error: unknown) => {
|
|
if (!(error instanceof OriginSceneError)) unexpected.push(error);
|
|
};
|
|
|
|
const trace: string[] = [];
|
|
for (let step = 0; step < 80; step += 1) {
|
|
const label = () =>
|
|
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
|
|
const roll = r.next();
|
|
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
|
|
const live = handles.filter(({ id }) => liveIds.has(id));
|
|
const pick = () =>
|
|
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
|
|
|
if (roll < 0.3 || handles.length === 0) {
|
|
const originKey = r.pick(scene.nodes.value).key;
|
|
const action = randomAction(r);
|
|
scene.begin(originKey, action).then(
|
|
(handle) => {
|
|
handles.push(handle);
|
|
trace.push(
|
|
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
|
|
);
|
|
},
|
|
(error) => {
|
|
trace.push(
|
|
` begin ${originKey} ${action.history} rejected: ${error.code}`,
|
|
);
|
|
expectSceneError(error);
|
|
},
|
|
);
|
|
} else if (roll < 0.55) {
|
|
const handle = pick();
|
|
const p = r.next() * 1.4 - 0.2;
|
|
const v = r.next() * 3 - 1;
|
|
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
|
|
handle.update(p, v);
|
|
} else if (roll < 0.75) {
|
|
const handle = pick();
|
|
const options = {
|
|
commit: r.pick([true, false, undefined]),
|
|
animate: r.chance(0.5),
|
|
};
|
|
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
|
|
void handle
|
|
.finish(options)
|
|
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
|
|
} else if (roll < 0.82) {
|
|
const handle = pick();
|
|
trace.push(`cancel #${handle.id}`);
|
|
void handle
|
|
.cancel({ animate: r.chance(0.5) })
|
|
.then(() => trace.push(` cancelled #${handle.id}`));
|
|
} else {
|
|
trace.push("frames");
|
|
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
|
|
}
|
|
if (!r.chance(0.15)) await flush();
|
|
trace.push(describeScene(scene));
|
|
checkInvariants(scene, label(), initial.length);
|
|
}
|
|
|
|
// Release everything: every live operation finishes, then springs drain.
|
|
await flush();
|
|
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
|
|
await flush();
|
|
frames.drain();
|
|
await flush();
|
|
checkResting(
|
|
scene,
|
|
initial.length,
|
|
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
|
|
);
|
|
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
|
|
}
|
|
|
|
describe("scene properties", () => {
|
|
for (let seed = 1; seed <= SEEDS; seed += 1)
|
|
it(`random operations ${seed}`, () => runOperations(seed));
|
|
});
|
|
|
|
// Gestures ------------------------------------------------------------------
|
|
|
|
const apps: Array<ReturnType<typeof createApp>> = [];
|
|
afterEach(() => {
|
|
for (const app of apps.splice(0)) app.unmount();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
const WIDTH = 300;
|
|
const HEIGHT = 600;
|
|
|
|
function stubBounds(element: HTMLElement) {
|
|
if (element.dataset.stubbed) return;
|
|
element.dataset.stubbed = "1";
|
|
Object.defineProperties(element, {
|
|
clientWidth: { configurable: true, value: WIDTH },
|
|
clientHeight: { configurable: true, value: HEIGHT },
|
|
});
|
|
element.getBoundingClientRect = () =>
|
|
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
|
|
}
|
|
|
|
/** Views whose gestures can grow, collapse, and race chains of operations. */
|
|
function gestureWorld() {
|
|
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
|
|
const slide = defineOriginChoreography({
|
|
name: "slide",
|
|
commitVelocity: 0.6,
|
|
effects: ({ progress }) => ({
|
|
source: { transform: `translateX(${-progress * 100}px)` },
|
|
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
|
}),
|
|
});
|
|
const surface = (name: string, gestures: () => unknown[]) =>
|
|
defineComponent({
|
|
name,
|
|
setup: () => () =>
|
|
h(
|
|
OriginGestureSurface,
|
|
{ gestures: gestures() as never, "data-gesture-host": "" },
|
|
() => [h("input", { "data-input": "" }), name],
|
|
),
|
|
});
|
|
|
|
const A = surface("A", () => [
|
|
gesture.to
|
|
.left()
|
|
.navigate(async () => {
|
|
await Promise.resolve();
|
|
return forward(viewsByName.B!);
|
|
})
|
|
.animate(slide),
|
|
gesture.from
|
|
.left(40)
|
|
.to.right()
|
|
.navigate((context) => (context.canGoBack ? back() : null))
|
|
.animate(slideRight),
|
|
gesture.from
|
|
.when(({ point }) => point.localY < HEIGHT / 2)
|
|
.to.down({ threshold: 12 })
|
|
.complete(({ progress }) => progress > 0.2)
|
|
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
|
|
.animate(drawerOpen),
|
|
gesture.from
|
|
.bottom("20%")
|
|
.to.up()
|
|
.navigate((context) =>
|
|
context.canGoBack ? back() : replace(viewsByName.A!),
|
|
)
|
|
.animate(fade),
|
|
]);
|
|
const B = surface("B", () => [
|
|
gesture.to
|
|
.left({ threshold: 6 })
|
|
.navigate(() => forward(viewsByName.C!))
|
|
.animate(slide),
|
|
gesture.to
|
|
.right({ threshold: 6 })
|
|
.navigate((context) => (context.canGoBack ? back() : null))
|
|
.animate(slideRight),
|
|
]);
|
|
const C = surface("C", () => [
|
|
gesture.to
|
|
.up({ threshold: 4 })
|
|
.navigate((context) =>
|
|
context.canGoBack ? back() : forward(viewsByName.B!),
|
|
)
|
|
.animate(drawerClose),
|
|
gesture.to
|
|
.left()
|
|
.navigate(() => {
|
|
throw new Error("intentional navigate failure");
|
|
})
|
|
.animate(slide),
|
|
]);
|
|
viewsByName.A = originView(A, undefined, { key: "a" });
|
|
viewsByName.B = originView(B, undefined, { key: "b" });
|
|
viewsByName.C = originView(C, undefined, { key: "c" });
|
|
|
|
const scene = createOriginScene({ initial: viewsByName.A });
|
|
const root = document.createElement("div");
|
|
document.body.append(root);
|
|
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
|
apps.push(app);
|
|
app.mount(root);
|
|
return { scene, root };
|
|
}
|
|
|
|
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
|
|
const event = new PointerEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...init,
|
|
});
|
|
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
|
return event;
|
|
}
|
|
|
|
async function runPointers(seed: number) {
|
|
const r = rng(seed * 7919);
|
|
const frames = installFrames();
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
try {
|
|
const { scene, root } = gestureWorld();
|
|
await nextTick();
|
|
let time = 100;
|
|
let x = 0;
|
|
let y = 0;
|
|
let pointerId = 1;
|
|
let host: HTMLElement | null = null;
|
|
let heading: "left" | "right" | "up" | "down" = "left";
|
|
const trace: string[] = [];
|
|
|
|
const dispatch = (
|
|
type: string,
|
|
init: PointerEventInit,
|
|
onInput = false,
|
|
) => {
|
|
trace.push(
|
|
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
|
|
);
|
|
if (!host?.isConnected) return;
|
|
const target = onInput
|
|
? (host.querySelector("[data-input]") as HTMLElement)
|
|
: host;
|
|
target.dispatchEvent(pointerEvent(type, init, time));
|
|
};
|
|
|
|
for (let step = 0; step < 120; step += 1) {
|
|
time += r.int(40);
|
|
const roll = r.next();
|
|
if (roll < 0.15) {
|
|
// A new primary pointer means the previous one ended: a browser
|
|
// delivers its pointer-up to the captured host, or, if that was
|
|
// lost, fires lostpointercapture there.
|
|
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
|
|
clientX: x,
|
|
clientY: y,
|
|
pointerId,
|
|
});
|
|
if (r.chance(0.8)) await flush(3);
|
|
const nodes = scene.nodes.value.filter((node) =>
|
|
scene.isNodeInteractive(node.key),
|
|
);
|
|
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
|
|
host = root.querySelector(
|
|
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
|
|
);
|
|
if (host) stubBounds(host);
|
|
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
|
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
|
pointerId = r.chance(0.8) ? 1 : 2;
|
|
heading = r.pick(["left", "right", "up", "down"] as const);
|
|
dispatch(
|
|
"pointerdown",
|
|
{
|
|
clientX: x,
|
|
clientY: y,
|
|
pointerId,
|
|
button: r.chance(0.95) ? 0 : 1,
|
|
isPrimary: r.chance(0.95),
|
|
},
|
|
r.chance(0.08),
|
|
);
|
|
} else if (roll < 0.65) {
|
|
const amount = 5 + r.int(55);
|
|
if (heading === "left") x -= amount;
|
|
if (heading === "right") x += amount;
|
|
if (heading === "up") y -= amount;
|
|
if (heading === "down") y += amount;
|
|
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
|
} else if (roll < 0.8) {
|
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
|
} else if (roll < 0.85) {
|
|
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
|
|
pointerId,
|
|
});
|
|
} else {
|
|
trace.push("frames");
|
|
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
|
|
}
|
|
if (r.chance(0.7)) {
|
|
frames.advance(16);
|
|
frames.advance(16);
|
|
}
|
|
if (!r.chance(0.2)) await flush(3);
|
|
trace.push(describeScene(scene));
|
|
checkInvariants(
|
|
scene,
|
|
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
|
|
1,
|
|
);
|
|
}
|
|
|
|
// Lift the pointer; nothing may remain stuck mid-transition.
|
|
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
|
await flush();
|
|
frames.drain();
|
|
await flush();
|
|
trace.push(describeScene(scene));
|
|
checkResting(
|
|
scene,
|
|
1,
|
|
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
|
|
);
|
|
} finally {
|
|
console.warn = warn;
|
|
}
|
|
}
|
|
|
|
describe("gesture properties", () => {
|
|
for (let seed = 1; seed <= SEEDS; seed += 1)
|
|
it(`random pointer stream ${seed}`, () => runPointers(seed));
|
|
});
|