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 @@
|
||||
.baseline/
|
||||
@@ -0,0 +1,27 @@
|
||||
# Differential verification
|
||||
|
||||
Checks a core-v2 implementation against an earlier one (by default `main`)
|
||||
by driving both with identical randomized inputs and requiring identical
|
||||
observable results after every step.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
packages/core-v2/verify/setup-baseline.sh main # extract into verify/.baseline/
|
||||
npx vitest run --config packages/core-v2/verify/vitest.config.ts
|
||||
SEEDS=50 npx vitest run --config packages/core-v2/verify/vitest.config.ts # quicker
|
||||
ONLY=rewrite npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
ONLY=baseline npx vitest bench --run --config packages/core-v2/verify/vitest.config.ts
|
||||
```
|
||||
|
||||
- `scene.diff.ts` runs random `begin`/`update`/`finish`/`cancel`/animation
|
||||
frame sequences against both scenes and compares nodes, operations, roots,
|
||||
every node's composited style, interactivity, contexts, and every promise
|
||||
result. It also compares action-helper output and the runtime export list.
|
||||
- `gesture.diff.ts` mounts both packages in Vue apps whose views install the
|
||||
same gestures (builder definitions, legacy options, `OriginGesture`, and
|
||||
`OriginGestureSurface`), dispatches identical random pointer streams, and
|
||||
compares scene state and every navigation, start, and completion callback.
|
||||
- `scene.bench.ts` measures compositing, push/back cycles, and rendered
|
||||
frames. Run each implementation in its own process with `ONLY=`; running
|
||||
both in one process biases whichever runs second.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineComponent, h, type Component } from "vue";
|
||||
import type { OriginScene } from "../src";
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export function component(name: string): Component {
|
||||
return defineComponent({ name, render: () => h("div", name) });
|
||||
}
|
||||
|
||||
/** Deterministic rAF + clock shared by both implementations. */
|
||||
export function installFrames() {
|
||||
let now = 1000;
|
||||
let queue: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
||||
queue.push(cb);
|
||||
return queue.length;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = window.requestAnimationFrame;
|
||||
const perf = performance;
|
||||
perf.now = () => now;
|
||||
return {
|
||||
advance(ms = 16) {
|
||||
now += ms;
|
||||
const run = queue;
|
||||
queue = [];
|
||||
for (const cb of run) cb(now);
|
||||
},
|
||||
pending: () => queue.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function flush(rounds = 6) {
|
||||
for (let i = 0; i < rounds; i += 1)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Implementation-agnostic, comparable snapshot of a scene. */
|
||||
export function snapshot(scene: OriginScene) {
|
||||
const nodes = scene.nodes.value;
|
||||
return {
|
||||
nodes: nodes.map((node) => ({
|
||||
key: node.key,
|
||||
sequence: node.sequence,
|
||||
view: node.view.key,
|
||||
previousNodeKey: node.previousNodeKey,
|
||||
state: node.state,
|
||||
incomingOperationId: node.incomingOperationId,
|
||||
history: node.history.map((view) => view.key),
|
||||
})),
|
||||
operations: scene.operations.value.map((operation) => ({
|
||||
id: operation.id,
|
||||
originKey: operation.originKey,
|
||||
targetKey: operation.targetKey,
|
||||
entryTargetKey: operation.entryTargetKey,
|
||||
choreography: operation.choreography.name,
|
||||
placement: operation.placement,
|
||||
history: operation.history,
|
||||
progress: operation.progress,
|
||||
velocity: operation.velocity,
|
||||
phase: operation.phase,
|
||||
intent: operation.intent,
|
||||
})),
|
||||
roots: [...scene.roots.value],
|
||||
styles: Object.fromEntries(
|
||||
nodes.map((node) => [node.key, scene.styleForNode(node.key)]),
|
||||
),
|
||||
interactive: Object.fromEntries(
|
||||
nodes.map((node) => [node.key, scene.isNodeInteractive(node.key)]),
|
||||
),
|
||||
contexts: Object.fromEntries(
|
||||
nodes.map((node) => {
|
||||
const context = scene.contextFor(node.key);
|
||||
return [
|
||||
node.key,
|
||||
{
|
||||
nodeKey: context.nodeKey,
|
||||
view: context.view.key,
|
||||
canGoBack: context.canGoBack,
|
||||
previous: context.previous?.key,
|
||||
history: context.history.map((view) => view.key),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
missingStyle: scene.styleForNode("missing::0"),
|
||||
missingInteractive: scene.isNodeInteractive("missing::0"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function settle<T>(promise: Promise<T>) {
|
||||
return promise.then(
|
||||
(value) => ({ ok: true as const, value }),
|
||||
(error: Error) => ({ ok: false as const, error: error.message }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { bench, describe } from "vitest";
|
||||
import * as next from "../src";
|
||||
import * as base from "./.baseline";
|
||||
import { component } from "./harness";
|
||||
|
||||
type Impl = typeof next;
|
||||
const all = { baseline: base as unknown as Impl, rewrite: next };
|
||||
const impls = process.env.ONLY
|
||||
? { [process.env.ONLY]: all[process.env.ONLY as keyof typeof all] }
|
||||
: all;
|
||||
|
||||
const motion = next.defineOriginChoreography({
|
||||
name: "bench",
|
||||
effects: ({ progress }) => ({
|
||||
frame: { transform: `translateY(${progress}px)` },
|
||||
source: {
|
||||
transform: `translateX(${-progress * 100}px)`,
|
||||
opacity: 1 - progress,
|
||||
},
|
||||
target: {
|
||||
transform: `translateX(${(1 - progress) * 100}px)`,
|
||||
style: { filter: "none" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
/** Long enough for both implementations to reach steady-state JIT. */
|
||||
const options = { time: 4000, warmupTime: 1500, warmupIterations: 2000 };
|
||||
|
||||
const views = Array.from({ length: 12 }, (_, i) =>
|
||||
next.originView(component(`V${i}`), undefined, { key: `v${i}` }),
|
||||
);
|
||||
|
||||
/** A scene with `depth` simultaneously live, chained operations. */
|
||||
async function liveChain(M: Impl, depth: number) {
|
||||
const scene = M.createOriginScene({ initial: views[0]! });
|
||||
const handles: next.OriginOperationHandle[] = [];
|
||||
let origin = scene.nodes.value[0]!.key;
|
||||
for (let i = 1; i <= depth; i += 1) {
|
||||
const handle = await scene.begin(origin, M.forward(views[i]!, motion));
|
||||
handle.update(0.5);
|
||||
handles.push(handle);
|
||||
origin = handle.targetKey;
|
||||
}
|
||||
return { scene, handles };
|
||||
}
|
||||
|
||||
for (const depth of [3, 10]) {
|
||||
describe(`styleForNode, every node, ${depth} live chained operations`, async () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const { scene, handles } = await liveChain(M, depth);
|
||||
let p = 0;
|
||||
bench(
|
||||
name,
|
||||
() => {
|
||||
p = (p + 0.013) % 1;
|
||||
handles[0]!.update(p);
|
||||
for (const node of scene.nodes.value) scene.styleForNode(node.key);
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("push → back cycle (begin + finish, no animation)", () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const scene = M.createOriginScene({ initial: views[0]! });
|
||||
const root = scene.nodes.value[0]!.key;
|
||||
bench(
|
||||
name,
|
||||
async () => {
|
||||
const push = await scene.begin(root, M.forward(views[1]!, motion));
|
||||
await push.finish({ commit: true, animate: false });
|
||||
const pop = await scene.begin(push.targetKey, M.back(motion));
|
||||
await pop.finish({ commit: true, animate: false });
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe("rendered frame: update progress + Vue re-render, 6 live operations", async () => {
|
||||
for (const [name, M] of Object.entries(impls)) {
|
||||
const { scene, handles } = await liveChain(M, 6);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
createApp({ render: () => h(M.OriginScene, { scene }) }).mount(host);
|
||||
await nextTick();
|
||||
let p = 0;
|
||||
bench(
|
||||
name,
|
||||
async () => {
|
||||
p = (p + 0.013) % 1;
|
||||
for (const handle of handles) handle.update(p);
|
||||
await nextTick();
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Differential test: drives the baseline (main) and rewritten scenes with the
|
||||
* same random operation sequences and requires identical observable state
|
||||
* after every step.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import * as next from "../src";
|
||||
import * as base from "./.baseline";
|
||||
import {
|
||||
component,
|
||||
flush,
|
||||
installFrames,
|
||||
rng,
|
||||
settle,
|
||||
snapshot,
|
||||
} from "./harness";
|
||||
|
||||
type Impl = typeof next;
|
||||
const impls = [base as unknown as Impl, next] as const;
|
||||
|
||||
const css = (style: Record<string, string>) => style as never;
|
||||
|
||||
const choreographies = [
|
||||
next.defineOriginChoreography({
|
||||
name: "layered",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "probe",
|
||||
commitThreshold: 0.5,
|
||||
effects: (c) => ({
|
||||
frame: { style: css({ filter: `blur(${c.progress}px)` }), layer: 1 },
|
||||
source: {
|
||||
opacity: 1 - c.progress,
|
||||
style: css({
|
||||
opacity: "0.9",
|
||||
transform: `rotate(${c.velocity}deg)`,
|
||||
"--state": `${c.phase}/${c.intent}`,
|
||||
}),
|
||||
},
|
||||
target: {
|
||||
transform: c.progress > 0.5 ? "none" : `scale(${c.progress})`,
|
||||
layer: 2,
|
||||
style: css({
|
||||
borderRadius: `${c.viewport.width}x${c.viewport.height}`,
|
||||
"--rects": JSON.stringify([
|
||||
c.originRect ?? null,
|
||||
c.targetRect ?? null,
|
||||
]),
|
||||
position: "fixed",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||
}),
|
||||
}),
|
||||
next.defineOriginChoreography({
|
||||
name: "drawer-close",
|
||||
commitThreshold: 0.2,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
||||
}),
|
||||
}),
|
||||
next.slideLeft,
|
||||
next.slideRight,
|
||||
next.fade,
|
||||
];
|
||||
|
||||
const views = ["A", "B", "C", "D", "E"].map((name) =>
|
||||
next.originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||
);
|
||||
|
||||
function randomAction(r: ReturnType<typeof rng>): next.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 next.forward(target, choreography, { placement });
|
||||
case 1:
|
||||
return next.replace(target, choreography, { placement });
|
||||
case 2:
|
||||
case 3:
|
||||
return next.back(choreography, { placement });
|
||||
case 4:
|
||||
return next.above(target, choreography, {
|
||||
history: r.pick(["push", "replace", "back", undefined] as const),
|
||||
});
|
||||
default:
|
||||
return next.under(target, choreography, {
|
||||
history: r.pick(["push", "back"] as const),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type Pair<T> = [T, T];
|
||||
export const stats = {
|
||||
undecidedFinishes: 0,
|
||||
boundary: 0,
|
||||
begins: 0,
|
||||
beginOk: 0,
|
||||
};
|
||||
interface Slot {
|
||||
done: [boolean, boolean];
|
||||
value: [unknown, unknown];
|
||||
}
|
||||
|
||||
async function runSequence(seed: number, steps: number) {
|
||||
const r = rng(seed);
|
||||
const frames = installFrames();
|
||||
const scenes = impls.map((impl) =>
|
||||
impl.createOriginScene({
|
||||
initial: seed % 3 === 0 ? [views[0]!, views[1]!] : views[0]!,
|
||||
}),
|
||||
) as Pair<next.OriginScene>;
|
||||
const handles: Pair<next.OriginOperationHandle>[] = [];
|
||||
const slots: Slot[] = [];
|
||||
const log: string[] = [];
|
||||
|
||||
const track = (promises: Pair<Promise<unknown>>) => {
|
||||
const slot: Slot = { done: [false, false], value: [undefined, undefined] };
|
||||
promises.forEach(
|
||||
(promise, index) =>
|
||||
void settle(promise).then((value) => {
|
||||
slot.done[index] = true;
|
||||
slot.value[index] = value;
|
||||
}),
|
||||
);
|
||||
slots.push(slot);
|
||||
return slot;
|
||||
};
|
||||
|
||||
const check = (label: string) => {
|
||||
expect(snapshot(scenes[1]), `${label}\n${log.join("\n")}`).toEqual(
|
||||
snapshot(scenes[0]),
|
||||
);
|
||||
for (const slot of slots) {
|
||||
expect(slot.done[1], label).toBe(slot.done[0]);
|
||||
expect(slot.value[1], label).toEqual(slot.value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const pickHandle = () => {
|
||||
const liveIds = new Set(scenes[0].operations.value.map((o) => o.id));
|
||||
const live = handles.filter(([handle]) => liveIds.has(handle.id));
|
||||
return live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||
};
|
||||
const boundaryUpdate = (pair: Pair<next.OriginOperationHandle>) => {
|
||||
// Bias toward the commit-decision boundaries.
|
||||
const progress = r.chance(0.4)
|
||||
? r.pick([0, 0.2, 0.35999, 0.36, 0.49999, 0.5, 1])
|
||||
: r.next() * 1.4 - 0.2;
|
||||
const velocity = r.chance(0.2)
|
||||
? undefined
|
||||
: r.chance(0.4)
|
||||
? r.pick([-0.3, 0, 0.29999, 0.3, 0.30001, 0.31])
|
||||
: r.next() * 4 - 2;
|
||||
log.push(`update ${pair[0].id} ${progress} ${velocity}`);
|
||||
pair.forEach((handle) => handle.update(progress, velocity));
|
||||
};
|
||||
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
const roll = r.next();
|
||||
const nodeKeys = scenes[0].nodes.value.map((node) => node.key);
|
||||
if (roll < 0.3 || handles.length === 0) {
|
||||
const originKey = r.chance(0.03) ? "missing::0" : r.pick(nodeKeys);
|
||||
const action = randomAction(r);
|
||||
log.push(
|
||||
`begin ${originKey} ${action.history} ${action.choreography.name}`,
|
||||
);
|
||||
const slot = track(
|
||||
scenes.map((scene) => scene.begin(originKey, action)) as Pair<
|
||||
Promise<next.OriginOperationHandle>
|
||||
>,
|
||||
);
|
||||
await flush();
|
||||
if (slot.done[0] && slot.done[1]) {
|
||||
const [a, b] = slot.value as Pair<
|
||||
{ ok: true; value: next.OriginOperationHandle } | { ok: false }
|
||||
>;
|
||||
stats.begins += 1;
|
||||
if (a.ok && b.ok) {
|
||||
stats.beginOk += 1;
|
||||
handles.push([a.value, b.value]);
|
||||
// Handles are compared by identity fields only.
|
||||
slot.value = [
|
||||
{ id: a.value.id, o: a.value.originKey, t: a.value.targetKey },
|
||||
{ id: b.value.id, o: b.value.originKey, t: b.value.targetKey },
|
||||
];
|
||||
}
|
||||
}
|
||||
} else if (roll < 0.55) {
|
||||
boundaryUpdate(pickHandle());
|
||||
} else if (roll < 0.75) {
|
||||
const pair = pickHandle();
|
||||
if (r.chance(0.5)) boundaryUpdate(pair);
|
||||
const options = {
|
||||
commit: r.pick([true, false, undefined]),
|
||||
animate: r.chance(0.5),
|
||||
};
|
||||
log.push(`finish ${pair[0].id} ${JSON.stringify(options)}`);
|
||||
const live = scenes[0].operations.value.find((o) => o.id === pair[0].id);
|
||||
if (live && options.commit === undefined) {
|
||||
stats.undecidedFinishes += 1;
|
||||
if (live.velocity >= 0.3 && live.velocity < 0.31) stats.boundary += 1;
|
||||
}
|
||||
track(
|
||||
pair.map((handle) => handle.finish(options)) as Pair<Promise<boolean>>,
|
||||
);
|
||||
} else if (roll < 0.82) {
|
||||
const pair = pickHandle();
|
||||
const animate = r.pick([true, false, undefined]);
|
||||
log.push(`cancel ${pair[0].id} ${animate}`);
|
||||
track(
|
||||
pair.map((handle) => handle.cancel({ animate })) as Pair<Promise<void>>,
|
||||
);
|
||||
} else {
|
||||
const count = 1 + r.int(12);
|
||||
log.push(`frames ${count}`);
|
||||
for (let i = 0; i < count; i += 1) frames.advance(4 + r.int(40));
|
||||
}
|
||||
if (!r.chance(0.15)) await flush();
|
||||
check(`seed ${seed} step ${step}`);
|
||||
}
|
||||
|
||||
// Drain every running spring and compare the resting state.
|
||||
for (let i = 0; i < 400 && frames.pending(); i += 1) frames.advance(16);
|
||||
await flush();
|
||||
check(`seed ${seed} drained`);
|
||||
}
|
||||
|
||||
describe("scene: baseline vs rewrite", () => {
|
||||
it("produces identical action and intent objects", () => {
|
||||
const target = views[1]!;
|
||||
const c = choreographies[0]!;
|
||||
const calls: Array<(m: Impl) => unknown> = [
|
||||
(m) => m.originAction(target, c),
|
||||
(m) => m.originAction(target, c, { placement: "under", history: "back" }),
|
||||
(m) => m.forward(target),
|
||||
(m) => m.forward(target, { placement: "under" }),
|
||||
(m) => m.forward(target, c),
|
||||
(m) => m.forward(target, c, { placement: "under" }),
|
||||
(m) => m.replace(target),
|
||||
(m) => m.replace(target, c, {}),
|
||||
(m) => m.back(),
|
||||
(m) => m.back({ placement: "above" }),
|
||||
(m) => m.back(c),
|
||||
(m) => m.back(c, { placement: "above" }),
|
||||
(m) => m.above(target),
|
||||
(m) => m.above(target, { history: "replace" }),
|
||||
(m) => m.above(target, c),
|
||||
(m) => m.above(target, c, { history: "back" }),
|
||||
(m) => m.under(target),
|
||||
(m) => m.under(target, c, { history: "back" }),
|
||||
(m) => m.normalizedEffect(undefined),
|
||||
(m) => m.normalizedEffect({ transform: "x", layer: 2 }, -1),
|
||||
];
|
||||
for (const call of calls) {
|
||||
const [a, b] = impls.map(call);
|
||||
expect(b).toStrictEqual(a);
|
||||
expect(Object.keys(b as object)).toEqual(Object.keys(a as object));
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => console.log("STATS", JSON.stringify(stats)));
|
||||
|
||||
it("exports the same runtime names", () => {
|
||||
expect(Object.keys(next).sort()).toEqual(Object.keys(base).sort());
|
||||
});
|
||||
|
||||
const seeds = Number(process.env.SEEDS ?? 300);
|
||||
for (let seed = 1; seed <= seeds; seed += 1) {
|
||||
it(`random sequence ${seed}`, () => runSequence(seed, 60));
|
||||
}
|
||||
});
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env sh
|
||||
# Extract core-v2 sources from a git ref into ./.baseline for comparison.
|
||||
set -eu
|
||||
ref="${1:-main}"
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(git -C "$here" rev-parse --show-toplevel)"
|
||||
tmp="$(mktemp -d)"
|
||||
git -C "$root" archive "$ref" packages/core-v2/src | tar -x -C "$tmp"
|
||||
rm -rf "$here/.baseline"
|
||||
mv "$tmp/packages/core-v2/src" "$here/.baseline"
|
||||
rm -f "$here/.baseline"/*.test.ts
|
||||
rm -rf "$tmp"
|
||||
echo "Baseline extracted from $ref into $here/.baseline"
|
||||
@@ -0,0 +1,17 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/*
|
||||
* Differential verification against a previous core-v2 implementation.
|
||||
* Run `setup-baseline.sh <git-ref>` first; see README.md.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [vue() as never],
|
||||
test: {
|
||||
root: __dirname,
|
||||
environment: "happy-dom",
|
||||
include: ["*.diff.ts"],
|
||||
testTimeout: 120_000,
|
||||
benchmark: { include: ["*.bench.ts"] },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user