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
551 lines
17 KiB
TypeScript
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"],
|
|
]);
|
|
});
|
|
});
|