/** * 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: (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((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(); 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(); 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): 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> = []; 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> = {}; 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)); });