Files
Native-Router-Vue/packages/core-v2/tests/recognizer.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

362 lines
12 KiB
TypeScript

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<ReturnType<typeof createApp>> = [];
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<OriginGestureDefinition[]>([
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<typeof shouldCommit>[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<typeof shouldCommit>[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);
});
});