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
286 lines
9.1 KiB
TypeScript
286 lines
9.1 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|
|
});
|