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

401 lines
14 KiB
TypeScript

import {
createApp,
defineComponent,
h,
nextTick,
shallowRef,
type Component,
} from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
allOf,
anyOf,
atScrollEdge,
createOriginScene,
defineOriginChoreography,
forward,
gesture,
nearEdge,
OriginGesture,
OriginGestureSurface,
OriginScene,
originView,
startedIn,
type OriginChoreographyContext,
type OriginGestureDefinition,
type OriginGestureStartContext,
} from "../src";
import { DirectionalTracker } from "../src/gesture/recognizer";
import { stepSpring } from "../src/scene/scene";
const apps: Array<ReturnType<typeof createApp>> = [];
afterEach(() => {
for (const app of apps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const contexts: OriginChoreographyContext[] = [];
const motion = defineOriginChoreography({
name: "v2-test",
effects: (context) => {
contexts.push(context);
return { target: { opacity: context.progress } };
},
});
const Target: Component = defineComponent({ render: () => h("div", "T") });
const toTarget = () => forward(originView(Target));
function pointer(
type: string,
clientX: number,
clientY: number,
timeStamp: number,
) {
const event = new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
isPrimary: true,
pointerId: 1,
clientX,
clientY,
});
Object.defineProperty(event, "timeStamp", { value: timeStamp });
return event;
}
function touchmove() {
return new TouchEvent("touchmove", { bubbles: true, cancelable: true });
}
async function flush() {
for (let i = 0; i < 4; i += 1) await new Promise((r) => setTimeout(r, 0));
}
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;
Object.defineProperties(host, {
clientWidth: { configurable: true, value: 200 },
clientHeight: { configurable: true, value: 400 },
});
host.getBoundingClientRect = () =>
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
return { scene, host };
}
const hostFor = (
definition: OriginGestureDefinition,
children?: () => unknown,
) =>
defineComponent({
setup: () => () =>
h(OriginGesture, { id: "host", gesture: definition }, () =>
children ? children() : "host",
),
});
describe("recognition and native scrolling", () => {
const leftward = gesture.to.left().navigate(toTarget).animate(motion);
it("leaves scrolling to the browser through touch-action alone", async () => {
const { scene, host } = await mount(hostFor(leftward));
// A horizontal gesture keeps native vertical panning.
expect(host.style.touchAction).toBe("pan-y");
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 140, 101, 16));
await flush();
const move = touchmove();
host.dispatchEvent(move);
// Ordinary gestures never block touchmove, so the browser never waits.
expect(move.defaultPrevented).toBe(false);
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a vertical wobble", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 149, 104, 16));
host.dispatchEvent(pointer("pointermove", 148, 107, 32));
host.dispatchEvent(pointer("pointermove", 120, 108, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("recognizes a swipe that starts with a wind-up the wrong way", async () => {
const { scene, host } = await mount(hostFor(leftward));
host.dispatchEvent(pointer("pointerdown", 150, 100, 0));
host.dispatchEvent(pointer("pointermove", 153, 100, 16));
host.dispatchEvent(pointer("pointermove", 156, 100, 32));
host.dispatchEvent(pointer("pointermove", 120, 100, 48));
await flush();
expect(scene.operations.value).toHaveLength(1);
});
it("claims touch-action per axis like native scrolling expects", async () => {
const down = gesture.to.down().navigate(toTarget).animate(motion);
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
const surface = (gestures: OriginGestureDefinition[]) =>
defineComponent({
setup: () => () =>
h(OriginGestureSurface, { id: "host", gestures }, () => "x"),
});
expect(
(await mount(surface([leftward, down]))).host.style.touchAction,
).toBe("none");
document.body.innerHTML = "";
// A scroll-edge gesture at its edge reserves only its own pan: with the
// horizontal gesture claiming pan-x, only scrolling down remains.
expect(
(await mount(surface([leftward, dismiss]))).host.style.touchAction,
).toBe("pan-down");
});
});
describe("scroll edges", () => {
const scroller = shallowRef<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(scroller, "top")
.to.down()
.navigate(toTarget)
.animate(motion);
async function mountWithScroller(scrollTop: number) {
const Root = hostFor(dismiss, () =>
h("div", {
id: "list",
ref: (element) => (scroller.value = element as HTMLElement | null),
}),
);
const mounted = await mount(Root);
let top = scrollTop;
Object.defineProperty(scroller.value!, "scrollTop", {
get: () => top,
configurable: true,
});
const scrollTo = async (value: number) => {
top = value;
scroller.value!.dispatchEvent(new Event("scroll"));
await nextTick();
};
await scrollTo(scrollTop);
return { ...mounted, scrollTo };
}
describe("with directional touch-action", () => {
it("reserves only the dismiss pan while the list is at the top", async () => {
const { host, scrollTo } = await mountWithScroller(0);
// Dragging up still scrolls natively; dragging down is the gesture's.
expect(host.style.touchAction).toBe("pan-x pan-down");
await scrollTo(120);
expect(host.style.touchAction).toBe("manipulation");
await scrollTo(0);
expect(host.style.touchAction).toBe("pan-x pan-down");
});
it("never blocks touchmove", async () => {
const { host } = await mountWithScroller(0);
host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
host.dispatchEvent(pointer("pointermove", 100, 118, 16));
const move = touchmove();
host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
it("starts a downward drag only when the scroller is at the top", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("leaves a scrolled list to scroll", async () => {
const scrolled = await mountWithScroller(120);
scrolled.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
scrolled.host.dispatchEvent(pointer("pointermove", 100, 140, 16));
await flush();
expect(scrolled.scene.operations.value).toHaveLength(0);
});
});
describe("without directional touch-action (Safari)", () => {
beforeEach(() => {
vi.stubGlobal("CSS", { supports: () => false });
});
it("keeps the axis pannable and claims touches from JavaScript", async () => {
const atTop = await mountWithScroller(0);
expect(atTop.host.style.touchAction).toBe("manipulation");
atTop.host.dispatchEvent(pointer("pointerdown", 100, 100, 0));
// The browser's first cancelable touchmove arrives after its slop.
atTop.host.dispatchEvent(pointer("pointermove", 101, 118, 16));
const first = touchmove();
atTop.host.dispatchEvent(first);
expect(first.defaultPrevented).toBe(true);
atTop.host.dispatchEvent(pointer("pointermove", 100, 140, 32));
await flush();
expect(atTop.scene.operations.value).toHaveLength(1);
});
it("lets an upward drag at the top scroll the list", async () => {
const atTop = await mountWithScroller(0);
atTop.host.dispatchEvent(pointer("pointerdown", 100, 200, 0));
atTop.host.dispatchEvent(pointer("pointermove", 100, 180, 16));
const move = touchmove();
atTop.host.dispatchEvent(move);
expect(move.defaultPrevented).toBe(false);
});
});
});
describe("start-rule helpers", () => {
const host = document.createElement("div");
host.innerHTML = `<div class="handle"><span id="inner"></span></div><p id="body"></p>`;
const context = (target: Element, localX = 100): OriginGestureStartContext =>
({
event: { target } as unknown as PointerEvent,
origin: {} as never,
host,
bounds: { top: 0, left: 0, width: 200, height: 400 },
point: { clientX: localX, clientY: 10, localX, localY: 10 },
}) as OriginGestureStartContext;
it("matches pointer-down inside a selector", () => {
expect(startedIn(".handle")(context(host.querySelector("#inner")!))).toBe(
true,
);
expect(startedIn(".handle")(context(host.querySelector("#body")!))).toBe(
false,
);
});
it("composes with anyOf and allOf", () => {
const body = host.querySelector("#body")!;
const nearLeft = nearEdge("left", 40);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 20))).toBe(true);
expect(anyOf(startedIn(".handle"), nearLeft)(context(body, 100))).toBe(
false,
);
expect(allOf(startedIn("p"), nearLeft)(context(body, 20))).toBe(true);
expect(allOf(startedIn("p"), nearLeft)(context(body, 100))).toBe(false);
});
it("checks scroll edges, treating a missing scroller as at the edge", () => {
const list = document.createElement("div");
Object.defineProperties(list, {
scrollTop: { value: 0 },
clientHeight: { value: 100 },
scrollHeight: { value: 300 },
});
const body = host.querySelector("#body")!;
expect(atScrollEdge(list, "top")(context(body))).toBe(true);
expect(atScrollEdge(list, "bottom")(context(body))).toBe(false);
expect(atScrollEdge(null, "top")(context(body))).toBe(true);
expect(atScrollEdge(".missing", "top")(context(body))).toBe(true);
});
});
describe("progress", () => {
it("measures against a custom distance and reports overshoot", async () => {
contexts.length = 0;
const drawer = gesture.to
.right({ distance: 100 })
.navigate(toTarget)
.animate(motion);
const { scene, host } = await mount(hostFor(drawer));
host.dispatchEvent(pointer("pointerdown", 0, 100, 0));
host.dispatchEvent(pointer("pointermove", 50, 100, 16));
await flush();
host.dispatchEvent(pointer("pointermove", 130, 100, 32));
await flush();
// Progress is 130 / 100 on a 200px host: clamped to 1, 0.3 overshoot.
expect(scene.operations.value[0]).toMatchObject({ progress: 1 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(0.3);
scene.styleForNode(scene.operations.value[0]!.targetKey);
expect(contexts.at(-1)!.overshoot).toBeCloseTo(0.3);
host.dispatchEvent(pointer("pointermove", -20, 100, 48));
await flush();
expect(scene.operations.value[0]).toMatchObject({ progress: 0 });
expect(scene.operations.value[0]!.overshoot).toBeCloseTo(-0.2);
});
it("averages velocity over recent samples instead of the last step", () => {
const tracker = new DirectionalTracker(
"right",
{ clientX: 0, clientY: 0, timeStamp: 0 },
() => 100,
);
// Steady 1px/ms with one jittery, late sample.
for (const [x, t] of [
[16, 16],
[32, 32],
[48, 48],
[52, 50],
[80, 80],
] as const)
tracker.sample({ clientX: x, clientY: 0, timeStamp: t });
expect(tracker.velocity).toBeCloseTo(10, 0); // 1px/ms over 100px.
// Held still for longer than the window: the flick is over.
tracker.sample({ clientX: 80, clientY: 0, timeStamp: 300 });
expect(tracker.velocity).toBe(0);
});
});
describe("springs", () => {
it("uses the choreography's stiffness and damping", () => {
const soft = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 80,
});
const stiff = stepSpring({ position: 0, velocity: 0 }, 1, 16, {
stiffness: 600,
});
expect(stiff.position).toBeGreaterThan(soft.position);
const unchanged = stepSpring({ position: 0, velocity: 0 }, 1, 16);
expect(unchanged.position).toBeGreaterThan(soft.position);
});
});
describe(".onArm() and .onDisarm()", () => {
it("fire when the drag crosses the commit threshold", async () => {
const events: string[] = [];
const definition = gesture.to
.left()
.navigate(toTarget)
.onArm(({ progress }) => events.push(`arm ${progress.toFixed(2)}`))
.onDisarm(({ progress }) => events.push(`disarm ${progress.toFixed(2)}`))
.animate(motion);
const { host } = await mount(hostFor(definition));
host.dispatchEvent(pointer("pointerdown", 190, 100, 0));
for (const [x, t] of [
[180, 16],
[160, 32],
[130, 48],
[100, 64],
[150, 80],
[90, 96],
] as const) {
host.dispatchEvent(pointer("pointermove", x, 100, t));
await flush();
}
// Default arming is distance-based: 25% of the 200px host is 50px.
expect(events).toEqual(["arm 0.30", "disarm 0.20", "arm 0.50"]);
});
});