/** * Differential test for gesture recognition: the baseline and rewritten * packages each render a scene whose views install equivalent gestures * (builder, legacy options, OriginGesture, OriginGestureSurface). Identical * random pointer streams must yield identical scenes and identical callbacks. */ import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { afterEach, describe, expect, it } from "vitest"; import * as next from "../src"; import * as base from "./.baseline"; import { flush, installFrames, rng, snapshot } from "./harness"; type Impl = typeof next; const impls = [base as unknown as Impl, next] as const; const apps: App[] = []; 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 as { stubbed?: boolean }).stubbed) return; (element as { stubbed?: boolean }).stubbed = true; Object.defineProperties(element, { clientWidth: { configurable: true, value: WIDTH }, clientHeight: { configurable: true, value: HEIGHT }, }); element.getBoundingClientRect = () => ({ top: 0, left: 0, right: WIDTH, bottom: HEIGHT, width: WIDTH, height: HEIGHT, x: 0, y: 0, toJSON: () => ({}), }) as DOMRect; } function pointOf(point: next.OriginGesturePoint) { return [point.clientX, point.clientY, point.localX, point.localY]; } /** Build one implementation's views, gestures, and callback recorder. */ function buildWorld(M: Impl, seed: number) { const record: unknown[] = []; const r = rng(seed); const tick = async (count: number) => { for (let i = 0; i < count; i += 1) await Promise.resolve(); }; const views: Record = {}; const slide = M.defineOriginChoreography({ name: "slide", effects: ({ progress }) => ({ source: { transform: `translateX(${-progress * 100}px)` }, target: { transform: `translateX(${(1 - progress) * 100}px)` }, }), }); const drawer = M.defineOriginChoreography({ name: "drawer", persistAtRest: true, effects: ({ progress }) => ({ source: { transform: `translateY(${progress * 50}%)` }, target: { opacity: progress }, }), }); // Asynchronous navigation delays are fixed per world so both match. const delays = Array.from({ length: 64 }, () => r.int(4)); let delayIndex = 0; const delay = () => tick(delays[delayIndex++ % delays.length]!); const surfaceGestures = [ M.gesture.to .left() .navigate(async (context) => { record.push(["left", context.nodeKey]); await delay(); return M.forward(views.B!); }) .animate(slide), M.gesture.from .left(40) .to.right() .navigate((context) => { record.push(["right", context.nodeKey, context.canGoBack]); return context.canGoBack ? M.back() : null; }) .animate(M.slideRight), M.gesture.from .when(({ point, bounds, origin }) => { record.push(["when", origin.nodeKey, pointOf(point), bounds.width]); return point.localY < HEIGHT / 2; }) .to.down({ threshold: 12, axisDominance: 1.5 }) .complete((context) => { record.push([ "complete", context.origin.nodeKey, context.direction, context.progress, context.velocity, context.distance, context.crossDistance, context.duration, pointOf(context.start), pointOf(context.current), context.bounds, ]); return context.progress > 0.2; }) .navigate(() => M.forward(views.C!, { placement: "under" })) .animate(drawer), M.gesture.from .bottom(120) .to.up() .navigate(async (context) => { await delay(); if (delays[delayIndex % delays.length] === 0) throw new Error("nav"); return context.canGoBack ? M.back() : M.replace(views.A!); }) .animate(M.fade), ]; const A = defineComponent({ name: "A", setup: () => () => h( M.OriginGestureSurface, { gestures: surfaceGestures, "data-gesture-host": "" }, () => [h("input", { "data-input": "" }), "A"], ), }); const bGestures = [ M.gesture.to .left({ threshold: 6 }) .navigate((context) => { record.push(["b-left", context.nodeKey, context.history.length]); return M.forward(views.C!); }) .animate(slide), M.gesture.to .right({ threshold: 6 }) .navigate((context) => (context.canGoBack ? M.back() : null)) .animate(M.slideRight), ]; const B = defineComponent({ name: "B", setup() { // Bind each recognizer directly instead of through a component. const bindings = bGestures.map((definition) => M.useOriginGesture(definition), ); const each = (name: "onPointerdown" | "onPointermove" | "onPointerup") => (event: PointerEvent) => bindings.forEach((binding) => binding[name](event)); return () => h( "div", { "data-gesture-host": "", style: bindings[0]!.style, onPointerdown: each("onPointerdown"), onPointermove: each("onPointermove"), onPointerup: each("onPointerup"), onPointercancel: () => bindings.forEach((binding) => binding.onPointercancel()), }, [h("input", { "data-input": "" }), "B"], ); }, }); const C = defineComponent({ name: "C", setup: () => () => h( M.OriginGesture, { "data-gesture-host": "", direction: "up", edge: 200, threshold: 4, action: (context: next.OriginContext) => { record.push(["legacy", context.nodeKey]); return context.canGoBack ? M.back(slide) : M.forward(views.B!, slide); }, }, () => [h("input", { "data-input": "" }), "C"], ), }); views.A = M.originView(A, undefined, { key: "a" }); views.B = M.originView(B, undefined, { key: "b" }); views.C = M.originView(C, undefined, { key: "c" }); const scene = M.createOriginScene({ initial: views.A }); const root = document.createElement("div"); document.body.append(root); const app = createApp({ render: () => h(M.OriginScene, { scene }) }); apps.push(app); app.mount(root); return { scene, root, record }; } function hostFor(root: HTMLElement, nodeKey: string) { const host = root.querySelector( `[data-origin-node="${CSS.escape(nodeKey)}"] [data-gesture-host]`, ) as HTMLElement | null; if (host) stubBounds(host); return host; } function pointerEvent( type: string, init: PointerEventInit, timeStamp: number, ): PointerEvent { const event = new PointerEvent(type, { bubbles: true, cancelable: true, ...init, }); Object.defineProperty(event, "timeStamp", { value: timeStamp }); return event; } const stats = { maxNodes: 0, maxOps: 0, opSteps: 0, records: {} as Record, finalNodes: {} as Record, }; async function runSequence(seed: number, steps: number) { const r = rng(seed * 7919); const frames = installFrames(); const worlds = impls.map((M) => buildWorld(M, seed)); await nextTick(); let time = 100; let x = 0; let y = 0; let pointerId = 1; let nodeKey = ""; let heading: next.OriginGestureDirection = "left"; let open = false; const log: string[] = []; const check = (label: string) => { const message = `${label}\n${log.join("\n")}`; expect(snapshot(worlds[1]!.scene), message).toEqual( snapshot(worlds[0]!.scene), ); expect(worlds[1]!.record, message).toEqual(worlds[0]!.record); }; const dispatch = (type: string, init: PointerEventInit, onInput = false) => { for (const world of worlds) { const host = hostFor(world.root, nodeKey); if (!host) continue; const target = onInput ? (host.querySelector("[data-input]") as HTMLElement) : host; target.dispatchEvent(pointerEvent(type, init, time)); } }; for (let step = 0; step < steps; step += 1) { const roll = r.next(); time += r.int(40); if (roll < 0.15) { if (open && r.chance(0.8)) { log.push(`up ${x},${y} t=${time}`); dispatch("pointerup", { clientX: x, clientY: y, pointerId }); await flush(3); } open = true; const nodes = worlds[0]!.scene.nodes.value; const scene = worlds[0]!.scene; const interactive = nodes.filter((node) => scene.isNodeInteractive(node.key), ); nodeKey = ( interactive.length && r.chance(0.85) ? r.pick(interactive) : r.pick(nodes) ).key; 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.9) ? 1 : 2; const init = { clientX: x, clientY: y, pointerId, button: r.chance(0.95) ? 0 : 1, isPrimary: r.chance(0.95), }; heading = r.pick(["left", "right", "up", "down"] as const); log.push(`down ${nodeKey} ${JSON.stringify(init)} t=${time}`); dispatch("pointerdown", init, r.chance(0.08)); } else if (roll < 0.65) { const jitter = r.int(9) - 4; if (r.chance(0.75)) { const amount = r.chance(0.1) ? 0 : 5 + r.int(55); if (heading === "left") x -= amount; if (heading === "right") x += amount; if (heading === "up") y -= amount; if (heading === "down") y += amount; if (heading === "left" || heading === "right") y += jitter; else x += jitter; } else { x += r.int(70) - 35; y += r.int(70) - 35; } log.push(`move ${x},${y} t=${time}`); dispatch("pointermove", { clientX: x, clientY: y, pointerId }); } else if (roll < 0.85) { open = false; log.push(`up ${x},${y} t=${time}`); dispatch("pointerup", { clientX: x, clientY: y, pointerId }); } else if (roll < 0.9) { open = false; log.push(`cancel`); dispatch("pointercancel", { pointerId }); } else { const count = 1 + r.int(20); log.push(`frames ${count}`); for (let i = 0; i < count; i += 1) frames.advance(16); } if (r.chance(0.7)) { frames.advance(16); frames.advance(16); } if (!r.chance(0.2)) await flush(3); check(`seed ${seed} step ${step}`); stats.maxNodes = Math.max( stats.maxNodes, worlds[0]!.scene.nodes.value.length, ); stats.maxOps = Math.max( stats.maxOps, worlds[0]!.scene.operations.value.length, ); stats.opSteps += worlds[0]!.scene.operations.value.length ? 1 : 0; } for (const entry of worlds[0]!.record as unknown[][]) stats.records[entry[0] as string] = (stats.records[entry[0] as string] ?? 0) + 1; stats.finalNodes[worlds[0]!.scene.nodes.value.length] = (stats.finalNodes[worlds[0]!.scene.nodes.value.length] ?? 0) + 1; for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16); await flush(); check(`seed ${seed} drained`); return worlds[0]!.scene.nodes.value.length; } describe("gesture: baseline vs rewrite", () => { const seeds = Number(process.env.SEEDS ?? 200); for (let seed = 1; seed <= seeds; seed += 1) { it(`random pointer stream ${seed}`, async () => { await runSequence(seed, 120); }); } it("reached multi-node scenes", () => { console.log("STATS", JSON.stringify(stats)); expect(stats.maxNodes).toBeGreaterThan(2); }); });