Files
Native-Router-Vue/packages/core-v2/tests/gesture.test.ts
T
Claude caf2ad692a Restore native scrolling and reliable recognition; add feel tests
The previous round regressed the demo against main: dropped frames while
a finger drags, and swipes ignored when they start imperfectly.

Frame drops: hosts used touch-action: manipulation plus a blocking
touchmove listener, so Chrome waited for JavaScript on every touch move
(~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis
touch-action like main (pan-y for horizontal gestures, and so on), and
attach no touch listener.

Flaky recognition: an irreversible decision after 6px dropped swipes
with a wobbly or backward start. Recognition again measures from the
pointer-down point until the pointer lifts, as on main.

Scrolling beside gestures, natively:
- .from.scrollEdge() uses direction-specific touch-action while its
  scroller is at the edge (pan-x pan-down for drag-down at the top), the
  pull-to-refresh pattern from the Pointer Events spec. A passive scroll
  listener keeps it current; no touch handling blocks the browser.
- Where directional values are unsupported (Safari), scroll-edge
  gestures alone fall back to claiming touches at the browser's first
  cancelable touchmove.
- Hosts publish touch-action as --nvo-touch-action and the stylesheet
  applies it to descendants, so nested scroll containers keep it and
  horizontal swipes over lists are not cancelled. Ignored subtrees get
  touch-action: auto back.
- Bindings gain listeners and ref (spread on the host); the components
  use them.

Feel tests (npm run test:feel): Chromium with emulated touch checks
recognition on ten human-like traces, frame pacing budgets while
dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge
dismiss, and the behaviour checks. Against the previous code they fail
on exactly the reported regressions; now 23/23 pass. On the demo, all
traces match main and drag frame pacing matches or beats it.

Commit thresholds are unchanged (0.25 distance, 0.2 flick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
2026-11-03 05:56:26 +00:00

264 lines
7.3 KiB
TypeScript

import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
back,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
OriginGestureSurface,
OriginScene,
originView,
replace,
useOriginGesture,
type OriginGestureBinding,
type OriginGestureCompletionContext,
} from "../src";
const mountedApps: Array<ReturnType<typeof createApp>> = [];
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<PointerEventInit, "clientX" | "clientY">,
) {
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(replace(target)).toEqual({
target,
placement: "above",
history: "replace",
});
expect(forward(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "push",
});
expect(back(testMotion)).toMatchObject({
choreography: testMotion,
history: "back",
});
expect(replace(target, testMotion)).toMatchObject({
target,
choreography: testMotion,
history: "replace",
});
});
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",
]);
});
});