Files
Native-Router-Vue/packages/core-v2/src/scene.test.ts
T
Claude 329271691c 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
2026-10-16 01:15:12 +00:00

551 lines
17 KiB
TypeScript

import {
createApp,
defineComponent,
h,
nextTick,
onMounted,
onUnmounted,
type Component,
} from "vue";
import { afterEach, describe, expect, it } from "vitest";
import {
above,
back,
createOriginScene,
defineOriginChoreography,
forward,
OriginScene,
originView,
replace,
under,
} from ".";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
function component(name: string): Component {
return defineComponent({
name,
render: () => h("div", { "data-test-view": name }, name),
});
}
const layeredMotion = defineOriginChoreography({
name: "test-layered-motion",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 100}px)` },
target: { transform: `translateX(${(1 - progress) * 100}px)` },
}),
});
const partialDrawerOpen = defineOriginChoreography({
name: "test-partial-drawer-open",
persistAtRest: true,
effects: ({ progress }) => ({
source: { transform: `translateX(${progress * 66.6667}%)` },
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
}),
});
const partialDrawerClose = defineOriginChoreography({
name: "test-partial-drawer-close",
effects: ({ progress }) => ({
source: { transform: `translateX(${-progress * 66.6667}%)` },
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
}),
});
describe("origin-relative scene graph", () => {
it("composes X→Y and Y→Z as independent transform layers", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const z = originView(component("Z"), undefined, { key: "z" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.5);
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
yz.update(0.25);
/*
* Y inherits the target half of X→Y, then adds its own source half of
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
*/
expect(scene.styleForNode(xy.targetKey).transform).toBe(
"translateX(50px) translateX(-25px)",
);
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
});
expect(scene.styleForNode(yz.targetKey).transform).toBe(
"translateX(50px) translateX(75px)",
);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
});
it("splices completed operations in either order without losing descendants", async () => {
const scene = createOriginScene({
initial: originView(component("X"), undefined, { key: "x" }),
});
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(
xKey,
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
);
xy.update(0.8);
const yz = await scene.begin(
xy.targetKey,
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
);
yz.update(0.6);
// Completing the newer edge first parks Y while Z replaces it as the
// visual target. Y remains mounted as Z's retained previous entry.
await yz.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
expect(
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
).toBe("parked");
expect(scene.operations.value).toHaveLength(1);
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
/^translateX\(19\.9.+px\)$/,
);
await xy.finish({ commit: true, animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "parked"],
["Z", "active"],
]);
expect(scene.operations.value).toHaveLength(0);
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
});
it("does not remount a target when its incoming operation is collapsed", async () => {
let yMounts = 0;
const X = component("X");
const Y = defineComponent({
name: "Y",
setup() {
onMounted(() => {
yMounts += 1;
});
return () => h("div", "Y");
},
});
const Z = component("Z");
const scene = createOriginScene({
initial: originView(X, undefined, { key: "x" }),
});
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
expect(
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
).toBe("relative");
const xy = await scene.begin(
scene.nodes.value[0]!.key,
above(originView(Y, undefined, { key: "y" }), layeredMotion),
);
const yz = await scene.begin(
xy.targetKey,
above(originView(Z, undefined, { key: "z" }), layeredMotion),
);
await nextTick();
expect(yMounts).toBe(1);
expect(
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
?.style.position,
).toBe("absolute");
// Y remains the same flat, keyed host while X→Y disappears around it.
await xy.finish({ commit: true, animate: false });
await nextTick();
expect(yMounts).toBe(1);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"X",
"Y",
"Z",
]);
await yz.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("reuses the retained previous node and pops only the current entry on back", async () => {
let xMounts = 0;
let xUnmounts = 0;
let yUnmounts = 0;
const X = defineComponent({
name: "X",
props: {
message: {
type: String,
required: true,
},
},
setup() {
onMounted(() => {
xMounts += 1;
});
onUnmounted(() => {
xUnmounts += 1;
});
return () =>
h(
"div",
{
"data-scroll": "feed",
style: { height: "100px", overflow: "auto" },
},
h("div", { style: { height: "2000px" } }, "Feed"),
);
},
});
const Y = defineComponent({
name: "Y",
setup() {
onUnmounted(() => {
yUnmounts += 1;
});
return () => h("div", "Y");
},
});
const x = originView(X, { message: "original" }, { key: "x" });
const y = originView(Y, undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const xKey = scene.nodes.value[0]!.key;
const originalScroller = host.querySelector(
'[data-scroll="feed"]',
) as HTMLElement;
originalScroller.scrollTop = 842;
const xy = await scene.begin(xKey, forward(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
await nextTick();
const yContext = scene.contextFor(xy.targetKey);
expect(yContext.previous).toBe(x);
expect(yContext.history).toEqual([x]);
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
"parked",
);
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
expect(yx.targetKey).toBe(xKey);
await nextTick();
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
expect(originalScroller.scrollTop).toBe(842);
await yx.finish({ commit: true, animate: false });
await nextTick();
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
expect(scene.contextFor(xKey).history).toEqual([]);
expect(scene.nodes.value[0]?.state).toBe("active");
expect(xMounts).toBe(1);
expect(xUnmounts).toBe(0);
expect(yUnmounts).toBe(1);
expect(originalScroller.scrollTop).toBe(842);
});
it("re-parks the retained target when a back operation is cancelled", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
await xy.finish({ commit: true, animate: false });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["X", "parked"],
["Y", "active"],
]);
});
it("replaces the current entry and makes back skip the removed instance", async () => {
let paymentUnmounts = 0;
const home = originView(component("Home"), undefined, { key: "home" });
const Payment = defineComponent({
name: "Payment",
setup() {
onUnmounted(() => {
paymentUnmounts += 1;
});
return () => h("div", "Payment");
},
});
const payment = originView(Payment, undefined, { key: "payment" });
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () => h(OriginScene, { scene }),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await nextTick();
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(paymentUnmounts).toBe(1);
const confirmationContext = scene.contextFor(confirmPayment.targetKey);
expect(confirmationContext.previous).toBe(home);
expect(confirmationContext.history).toEqual([home]);
const returnHome = await scene.begin(
confirmPayment.targetKey,
back(layeredMotion),
);
expect(returnHome.targetKey).toBe(homeKey);
await returnHome.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => node.view.name)).toEqual(["Home"]);
});
it("restores the current entry when an interactive replace is cancelled", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
await openPayment.finish({ commit: true, animate: false });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.cancel({ animate: false });
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Payment", "active"],
]);
expect(scene.contextFor(openPayment.targetKey).history).toEqual([home]);
});
it("can replace an initial root without manufacturing history", async () => {
const scene = createOriginScene({
initial: originView(component("Welcome"), undefined, { key: "welcome" }),
});
const welcomeKey = scene.nodes.value[0]!.key;
const replacement = await scene.begin(
welcomeKey,
replace(
originView(component("Signed in"), undefined, { key: "signed-in" }),
layeredMotion,
),
);
await replacement.finish({ commit: true, animate: false });
expect(scene.roots.value).toEqual([replacement.targetKey]);
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
"Signed in",
]);
expect(scene.contextFor(replacement.targetKey)).toMatchObject({
canGoBack: false,
history: [],
previous: undefined,
});
});
it("can replace a target while its incoming push is still settling", async () => {
const home = originView(component("Home"), undefined, { key: "home" });
const payment = originView(component("Payment"), undefined, {
key: "payment",
});
const confirmation = originView(component("Confirmation"), undefined, {
key: "confirmation",
});
const scene = createOriginScene({ initial: home });
const homeKey = scene.nodes.value[0]!.key;
const openPayment = await scene.begin(
homeKey,
forward(payment, layeredMotion),
);
openPayment.update(0.8, 1);
const paymentSettlement = openPayment.finish({ commit: true });
const confirmPayment = await scene.begin(
openPayment.targetKey,
replace(confirmation, layeredMotion),
);
await confirmPayment.finish({ commit: true, animate: false });
await paymentSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Home", "parked"],
["Confirmation", "active"],
]);
expect(scene.contextFor(confirmPayment.targetKey).history).toEqual([home]);
});
it("keeps a partial push connected at rest and restores it after cancelled back", async () => {
const page = originView(component("Page"), undefined, { key: "page" });
const drawer = originView(component("Drawer"), undefined, {
key: "drawer",
});
const scene = createOriginScene({ initial: page });
const pageKey = scene.nodes.value[0]!.key;
const openDrawer = await scene.begin(
pageKey,
forward(drawer, partialDrawerOpen),
);
await openDrawer.finish({ commit: true, animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
expect(scene.styleForNode(pageKey).pointerEvents).toBe("none");
expect(scene.styleForNode(openDrawer.targetKey).transform).toBe(
"translateX(0%)",
);
const closeDrawer = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
await closeDrawer.cancel({ animate: false });
expect(scene.operations.value).toHaveLength(0);
expect(scene.roots.value).toEqual([pageKey]);
expect(
scene.nodes.value.map((node) => [node.view.name, node.state]),
).toEqual([
["Page", "exposed"],
["Drawer", "active"],
]);
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
const committedClose = await scene.begin(
openDrawer.targetKey,
back(partialDrawerClose),
);
await committedClose.finish({ commit: true, animate: false });
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[pageKey, "active"],
]);
expect(scene.roots.value).toEqual([pageKey]);
expect(scene.styleForNode(pageKey).transform).toBe("none");
});
it("hands an immediate back gesture from a settling push to the same instances", async () => {
const x = originView(component("X"), undefined, { key: "x" });
const y = originView(component("Y"), undefined, { key: "y" });
const scene = createOriginScene({ initial: x });
const xKey = scene.nodes.value[0]!.key;
const xy = await scene.begin(xKey, above(y, layeredMotion));
xy.update(0.8, 1);
const forwardSettlement = xy.finish({ commit: true });
const yx = await scene.begin(
xy.targetKey,
under(x, layeredMotion, { history: "back" }),
);
expect(yx.targetKey).toBe(xKey);
await yx.finish({ commit: true, animate: false });
await forwardSettlement;
expect(scene.operations.value).toHaveLength(0);
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
[xKey, "active"],
]);
});
});