Files
Native-Router-Vue/packages/core-v2/tests/scene.test.ts
T
Claude a46c411ec4 Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package
through ../src, the root tsconfig still type-checks them, and the
package build and declarations cover only src/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-10-16 04:24:47 +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 "../src";
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"],
]);
});
});