import { createApp, defineComponent, h, nextTick, shallowRef, type Component, } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import { back, createOriginScene, defineOriginChoreography, forward, gesture, OriginGesture, OriginGestureSurface, OriginScene, OriginSceneError, originView, type OriginGestureDefinition, } from "../src"; import { shouldCommit } from "../src/scene/graph"; const apps: Array> = []; afterEach(() => { for (const app of apps.splice(0)) app.unmount(); document.body.innerHTML = ""; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const motion = defineOriginChoreography({ name: "recognizer-test", effects: ({ progress }) => ({ target: { opacity: progress } }), }); const Target: Component = defineComponent({ render: () => h("div", "T") }); const toTarget = () => forward(originView(Target)); function pointer( type: string, clientX: number, timeStamp: number, pointerId = 1, ) { const event = new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, isPrimary: true, pointerId, clientX, clientY: 100, }); Object.defineProperty(event, "timeStamp", { value: timeStamp }); return event; } async function flush() { for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0)); } function stubBounds(host: HTMLElement) { Object.defineProperty(host, "clientWidth", { value: 200 }); host.getBoundingClientRect = () => ({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect; } /** Render `root` as the scene's initial view; return the scene and host. */ async function mount(root: Component) { vi.stubGlobal("matchMedia", () => ({ matches: true })); const scene = createOriginScene({ initial: originView(root) }); const element = document.createElement("div"); document.body.append(element); const app = createApp({ render: () => h(OriginScene, { scene }) }); apps.push(app); app.mount(element); await nextTick(); const host = element.querySelector("#host") as HTMLElement; stubBounds(host); return { scene, host, app }; } const gestureHost = (definition: () => OriginGestureDefinition) => defineComponent({ setup: () => () => h(OriginGesture, { id: "host", gesture: definition() }, () => "host"), }); /** Press, then drag left far enough to capture and begin an operation. */ async function dragLeft(host: HTMLElement, pointerId = 1, x = 180) { host.dispatchEvent(pointer("pointerdown", x, 0, pointerId)); host.dispatchEvent(pointer("pointermove", x - 60, 100, pointerId)); await flush(); } describe("stuck gestures", () => { const definition = gesture.to.left().navigate(toTarget).animate(motion); it("cancels a captured gesture when pointer capture is lost", async () => { const { scene, host } = await mount(gestureHost(() => definition)); await dragLeft(host); expect(scene.operations.value).toHaveLength(1); host.dispatchEvent(pointer("lostpointercapture", 120, 120)); await flush(); expect(scene.operations.value).toHaveLength(0); expect(scene.nodes.value).toHaveLength(1); }); it("ignores a child losing implicit touch capture to the host", async () => { const Root = defineComponent({ setup: () => () => h(OriginGesture, { id: "host", gesture: definition }, () => h("span", { id: "child" }, "x"), ), }); const { scene, host } = await mount(Root); await dragLeft(host); expect(scene.operations.value).toHaveLength(1); // Taking capture on the host makes the touched child lose its implicit // capture; that event bubbles to the host. host .querySelector("#child")! .dispatchEvent(pointer("lostpointercapture", 120, 110)); await flush(); expect(scene.operations.value).toHaveLength(1); }); it("ignores lostpointercapture after a normal release", async () => { const { scene, host } = await mount(gestureHost(() => definition)); await dragLeft(host); host.dispatchEvent(pointer("pointermove", 20, 200)); host.dispatchEvent(pointer("pointerup", 20, 200)); host.dispatchEvent(pointer("lostpointercapture", 20, 200)); await flush(); // Committed, not cancelled. expect(scene.nodes.value.map((node) => node.state)).toEqual([ "parked", "active", ]); }); it("cancels a stale gesture when a new primary pointer goes down", async () => { const { scene, host } = await mount(gestureHost(() => definition)); await dragLeft(host, 1); expect(scene.operations.value).toHaveLength(1); // pointer 1's pointer-up was lost; a new touch starts. host.dispatchEvent(pointer("pointerdown", 150, 300, 2)); await flush(); expect(scene.operations.value).toHaveLength(0); expect(scene.nodes.value).toHaveLength(1); }); it("cancels a captured gesture when its component unmounts", async () => { const show = shallowRef(true); const Root = defineComponent({ setup: () => () => h("div", [ show.value ? h(OriginGesture, { id: "host", gesture: definition }, () => "x") : null, ]), }); const { scene, host } = await mount(Root); await dragLeft(host); expect(scene.operations.value).toHaveLength(1); show.value = false; await flush(); expect(scene.operations.value).toHaveLength(0); }); }); describe("failure warnings", () => { it("warns when navigate() throws, without touching the scene", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const failing = gesture.to .left() .navigate(() => { throw new Error("no route"); }) .animate(motion); const { scene, host } = await mount(gestureHost(() => failing)); await dragLeft(host); expect(scene.operations.value).toHaveLength(0); expect(warn).toHaveBeenCalledWith( "[native-vue-router] A gesture's navigate() factory failed.", new Error("no route"), ); }); it("warns when the scene rejects the operation", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); // The initial view has no previous entry, so back() is rejected. const invalid = gesture.to .left() .navigate(() => back()) .animate(motion); const { host } = await mount(gestureHost(() => invalid)); await dragLeft(host); expect(warn).toHaveBeenCalledTimes(1); const error = warn.mock.calls[0]![1] as OriginSceneError; expect(error).toBeInstanceOf(OriginSceneError); expect(error.code).toBe("no-previous-entry"); }); it("stays quiet when two gestures race for the same origin", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const both = [ gesture.to.left({ threshold: 4 }).navigate(toTarget).animate(motion), gesture.to.left({ threshold: 6 }).navigate(toTarget).animate(motion), ]; const Root = defineComponent({ setup: () => () => h(OriginGestureSurface, { id: "host", gestures: both }, () => "x"), }); const { scene, host } = await mount(Root); await dragLeft(host); expect(scene.operations.value).toHaveLength(1); expect(warn).not.toHaveBeenCalled(); }); it("declining with null does not warn", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const declined = gesture.to .left() .navigate(() => null) .animate(motion); const { host } = await mount(gestureHost(() => declined)); await dragLeft(host); expect(warn).not.toHaveBeenCalled(); }); }); describe("reactive gesture props", () => { it("applies a new definition from the next pointer-down only", async () => { const leftward = gesture.to.left().navigate(toTarget).animate(motion); const rightward = gesture.to.right().navigate(toTarget).animate(motion); const current = shallowRef(leftward); const { scene, host } = await mount(gestureHost(() => current.value)); expect(host.style.touchAction).toBe("pan-y"); // Swap mid-gesture: the drag in progress keeps the leftward definition. host.dispatchEvent(pointer("pointerdown", 180, 0)); current.value = rightward; await nextTick(); host.dispatchEvent(pointer("pointermove", 140, 100)); await flush(); expect(scene.operations.value).toHaveLength(1); // A short, held release (20%) cancels, leaving the origin interactive. host.dispatchEvent(pointer("pointerup", 140, 1000)); await flush(); // The next touch uses the rightward definition. host.dispatchEvent(pointer("pointerdown", 20, 2000)); host.dispatchEvent(pointer("pointermove", 80, 2100)); await flush(); expect(scene.operations.value).toHaveLength(1); }); it("keeps a gesture in progress across a parent re-render", async () => { const tick = shallowRef(0); // A new but equivalent definition object on every render. const { scene, host } = await mount( gestureHost(() => { void tick.value; return gesture.to.left().navigate(toTarget).animate(motion); }), ); await dragLeft(host); tick.value += 1; await nextTick(); host.dispatchEvent(pointer("pointermove", 20, 200)); host.dispatchEvent(pointer("pointerup", 20, 200)); await flush(); expect(scene.nodes.value.map((node) => node.state)).toEqual([ "parked", "active", ]); }); it("tracks a surface's changing gesture list and touch-action", async () => { const list = shallowRef([ gesture.to.left().navigate(toTarget).animate(motion), ]); const Root = defineComponent({ setup: () => () => h( OriginGestureSurface, { id: "host", gestures: list.value }, () => "x", ), }); const { scene, host } = await mount(Root); expect(host.style.touchAction).toBe("pan-y"); list.value = [ ...list.value, gesture.to.down().navigate(toTarget).animate(motion), ]; await nextTick(); expect(host.style.touchAction).toBe("none"); host.dispatchEvent( Object.assign(pointer("pointerdown", 100, 0), { clientY: 0 }), ); const down = new PointerEvent("pointermove", { bubbles: true, isPrimary: true, pointerId: 1, clientX: 100, clientY: 80, }); host.dispatchEvent(down); await flush(); expect(scene.operations.value).toHaveLength(1); }); }); describe("commitVelocity", () => { const operation = (commitVelocity: number | undefined, velocity: number) => ({ progress: 0.1, velocity, choreography: { effects: () => ({}), commitVelocity }, }) as unknown as Parameters[0]; it("defaults to 0.2", () => { expect(shouldCommit(operation(undefined, 0.19), {})).toBe(false); expect(shouldCommit(operation(undefined, 0.2), {})).toBe(true); }); it("uses the choreography's value when supplied", () => { expect(shouldCommit(operation(0.6, 0.5), {})).toBe(false); expect(shouldCommit(operation(0.6, 0.6), {})).toBe(true); expect(shouldCommit(operation(0.1, 0.15), {})).toBe(true); }); it("lets a fast flick's direction veto the distance", () => { const at = (progress: number, velocity: number) => ({ progress, velocity, choreography: { effects: () => ({}) }, }) as unknown as Parameters[0]; // Past the 0.36 threshold, but flicked back toward the start: cancels. expect(shouldCommit(at(0.8, -0.5), {})).toBe(false); // Barely started, but flicked forward: commits. expect(shouldCommit(at(0.05, 0.5), {})).toBe(true); // Slow releases fall back to distance, in either direction. expect(shouldCommit(at(0.8, -0.1), {})).toBe(true); expect(shouldCommit(at(0.2, 0.1), {})).toBe(false); }); it("still yields to an explicit decision", () => { expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false); }); });