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
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user