import { createApp, defineComponent, h, nextTick, type Component } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import OriginGestureSurface from "./components/OriginGestureSurface.vue"; import OriginScene from "./components/OriginScene.vue"; import { gesture, useOriginGesture } from "./gesture"; import { back, defineOriginChoreography, forward } from "./motion"; import { createOriginScene, originView } from "./scene"; import type { OriginGestureBinding, OriginGestureCompletionContext, } from "./types"; const mountedApps: Array> = []; afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); const testMotion = defineOriginChoreography({ name: "gesture-test", effects: ({ progress }) => ({ source: { opacity: 1 - progress }, target: { opacity: progress }, }), }); function component(name: string): Component { return defineComponent({ name, render: () => h("div", name), }); } function pointer( type: string, init: Pick, ) { return new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, isPrimary: true, pointerId: 7, ...init, }); } async function flushAsyncHandlers() { await Promise.resolve(); await nextTick(); await Promise.resolve(); await nextTick(); } describe("gesture builder", () => { it("installs multiple page-owned definitions on a policy-neutral surface", async () => { const horizontal = gesture.to .left() .navigate(() => null) .animate(testMotion); const vertical = gesture.from .top("12%") .to.down() .navigate(() => null) .animate(testMotion); const Initial = defineComponent({ name: "SurfaceInitial", render: () => h( OriginGestureSurface, { as: "section", id: "multi-gesture-surface", gestures: [horizontal, vertical], }, () => "surface", ), }); const scene = createOriginScene({ initial: originView(Initial, undefined, { key: "surface-initial" }), }); const root = document.createElement("div"); document.body.append(root); const app = createApp({ render: () => h(OriginScene, { scene }) }); mountedApps.push(app); app.mount(root); await nextTick(); const surface = root.querySelector("#multi-gesture-surface") as HTMLElement; expect(surface.tagName).toBe("SECTION"); expect(surface.classList.contains("nvo-gesture")).toBe(true); expect(surface.style.touchAction).toBe("none"); expect(surface.style.width).toBe("100%"); expect(surface.style.height).toBe("100%"); }); it("keeps builder navigation intents separate from complete actions", () => { const target = originView(component("Target")); expect(forward(target)).toEqual({ target, placement: "above", history: "push", }); expect(back()).toEqual({ placement: "under", history: "back", }); expect(forward(target, testMotion)).toMatchObject({ target, choreography: testMotion, history: "push", }); expect(back(testMotion)).toMatchObject({ choreography: testMotion, history: "back", }); }); it("treats a chain beginning at .to as an immutable anywhere gesture", () => { const definition = gesture.to .right({ threshold: 12 }) .navigate(() => back()) .animate(testMotion); expect(definition).toMatchObject({ kind: "origin-gesture-definition", start: { kind: "anywhere" }, direction: "right", recognition: { threshold: 12 }, choreography: testMotion, }); expect(Object.isFrozen(definition)).toBe(true); expect(Object.isFrozen(definition.recognition)).toBe(true); }); it("keeps start predicates independent from movement direction", () => { const predicate = vi.fn(() => true); const complete = vi.fn(() => true); const definition = gesture.from .when(predicate) .to.down({ axisDominance: 1.4 }) .complete(complete) .navigate(() => forward(originView(component("Dialog")))) .animate(testMotion); expect(definition.start).toEqual({ kind: "when", predicate }); expect(definition.direction).toBe("down"); expect(definition.recognition.axisDominance).toBe(1.4); expect(definition.completion).toBe(complete); }); it("recognizes .to.right anywhere and lets .complete override release", async () => { vi.stubGlobal( "matchMedia", vi.fn(() => ({ matches: true }) as MediaQueryList), ); const Target = component("Target"); let binding: OriginGestureBinding | undefined; let completion: OriginGestureCompletionContext | undefined; const definition = gesture.to .right() .complete((context) => { completion = context; return false; }) .navigate(() => forward(originView(Target, undefined, { key: "target" }))) .animate(testMotion); const Initial = defineComponent({ name: "Initial", setup() { binding = useOriginGesture(definition); return () => h( "div", { id: "gesture-host", style: binding!.style, onPointerdown: binding!.onPointerdown, onPointermove: binding!.onPointermove, onPointerup: binding!.onPointerup, onPointercancel: binding!.onPointercancel, }, "Initial", ); }, }); const scene = createOriginScene({ initial: originView(Initial, undefined, { key: "initial" }), }); const root = document.createElement("div"); document.body.append(root); const app = createApp({ render: () => h(OriginScene, { scene }) }); mountedApps.push(app); app.mount(root); await nextTick(); const host = root.querySelector("#gesture-host") as HTMLElement; Object.defineProperties(host, { clientWidth: { configurable: true, value: 200 }, clientHeight: { configurable: true, value: 400 }, }); host.getBoundingClientRect = () => ({ top: 20, left: 100, right: 300, bottom: 420, width: 200, height: 400, x: 100, y: 20, toJSON: () => ({}), }) as DOMRect; // x=250 is nowhere near the left edge. With no `.from`, it is eligible. host.dispatchEvent(pointer("pointerdown", { clientX: 250, clientY: 100 })); host.dispatchEvent(pointer("pointermove", { clientX: 330, clientY: 102 })); await flushAsyncHandlers(); expect(scene.operations.value).toHaveLength(1); host.dispatchEvent(pointer("pointerup", { clientX: 350, clientY: 102 })); await flushAsyncHandlers(); expect(completion).toMatchObject({ direction: "right", progress: 0.5, distance: 100, crossDistance: 2, }); expect(completion?.start).toMatchObject({ clientX: 250, localX: 150, }); expect(completion?.current).toMatchObject({ clientX: 350, localX: 250, }); expect(scene.operations.value).toHaveLength(0); expect(scene.nodes.value.map((node) => node.view.name)).toEqual([ "Initial", ]); }); });