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> = []; 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"], ]); }); });