Restructure core-v2 into layered modules
Rewrite the core-v2 internals without changing the public API or runtime behavior. The four large files (types, scene, motion, gesture) become layered modules with one responsibility each: - shared/: geometry and environment helpers - view/: originView recipes - choreography/: effect types, presets, and composeEffects compositing - actions/: forward/back/replace/above/under via one overload resolver - scene/: SceneGraph storage, transitions (history rules), compositor (styles and interactivity), spring (pure stepper + rAF driver), and createOriginScene, which only wires them and owns async timing - composables/: useOrigin and the node-scope injection key - gesture/: builder, definition->policy normalization, start admission, DirectionalTracker, and a GestureRecognizer session state machine replacing fifteen loose closure variables - components/: renderers and gesture hosts, plus their prop types Verification (packages/core-v2/verify): differential fuzzing drives main and this branch with identical random operation sequences and pointer streams and requires identical scene state, styles, contexts, callbacks, and promise results at every step. It caught every planted mutation that applied. Benchmarks are at parity. Also fix a pre-existing test that called the defineProps macro inside a runtime defineComponent, and correct API.md's description of the flick commit threshold to match the runtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 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<string, next.OriginView> = {};
|
||||
|
||||
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<string, number>,
|
||||
finalNodes: {} as Record<number, number>,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user