Move core-v2 tests into packages/core-v2/tests
Tests no longer sit next to the implementation. They import the package through ../src, the root tsconfig still type-checks them, and the package build and declarations cover only src/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Where an application can trigger haptic feedback with the current API.
|
||||
*/
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
watch,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
gesture,
|
||||
OriginScene,
|
||||
originView,
|
||||
useOriginGesture,
|
||||
type OriginGestureDefinition,
|
||||
} from "../src";
|
||||
|
||||
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: "haptics-test",
|
||||
effects: ({ progress }) => ({ target: { opacity: progress } }),
|
||||
});
|
||||
const Target: Component = defineComponent({ render: () => h("div", "T") });
|
||||
|
||||
function pointer(type: string, clientX: number, timeStamp: number) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
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));
|
||||
}
|
||||
|
||||
/** Mount a scene whose root installs `definition`, then return its host. */
|
||||
async function mount(definition: OriginGestureDefinition) {
|
||||
// Reduced motion settles instantly, so release outcomes apply at once.
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
||||
const Root = defineComponent({
|
||||
setup() {
|
||||
const binding = useOriginGesture(definition);
|
||||
return () =>
|
||||
h("div", {
|
||||
id: "host",
|
||||
style: binding.style,
|
||||
onPointerdown: binding.onPointerdown,
|
||||
onPointermove: binding.onPointermove,
|
||||
onPointerup: binding.onPointerup,
|
||||
});
|
||||
},
|
||||
});
|
||||
const scene = createOriginScene({ initial: originView(Root) });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
const host = root.querySelector("#host") as HTMLElement;
|
||||
Object.defineProperty(host, "clientWidth", { value: 200 });
|
||||
host.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 200, height: 400 }) as DOMRect;
|
||||
return { scene, host };
|
||||
}
|
||||
|
||||
/** Swipe left by `distance` px, slowly enough that velocity cannot commit. */
|
||||
async function swipe(host: HTMLElement, distance: number, log: string[]) {
|
||||
host.dispatchEvent(pointer("pointerdown", 190, 0));
|
||||
host.dispatchEvent(pointer("pointermove", 180, 500));
|
||||
await flush();
|
||||
log.push("recognized");
|
||||
host.dispatchEvent(pointer("pointermove", 190 - distance, 1000));
|
||||
await flush();
|
||||
host.dispatchEvent(pointer("pointerup", 190 - distance, 1000));
|
||||
await flush();
|
||||
log.push("released");
|
||||
}
|
||||
|
||||
describe("haptic feedback", () => {
|
||||
it("can vibrate when a gesture is recognized, from .navigate()", async () => {
|
||||
const log: string[] = [];
|
||||
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
navigator.vibrate?.(10);
|
||||
return forward(originView(Target));
|
||||
})
|
||||
.animate(motion),
|
||||
);
|
||||
// A short swipe that is cancelled on release still vibrated, because
|
||||
// .navigate() runs at recognition, before the outcome is known.
|
||||
await swipe(host, 20, log);
|
||||
expect(log).toEqual(["vibrate", "recognized", "released"]);
|
||||
});
|
||||
|
||||
it("can vibrate only when a release commits, by watching operation intent", async () => {
|
||||
const log: string[] = [];
|
||||
const vibrate = vi.fn(() => log.push("vibrate") > 0);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.animate(motion),
|
||||
);
|
||||
// Intent becomes "commit" synchronously at release, before settling.
|
||||
const seen = new Set<number>();
|
||||
watch(
|
||||
() => scene.operations.value.filter((o) => o.intent === "commit"),
|
||||
(committed) => {
|
||||
for (const operation of committed)
|
||||
if (!seen.has(operation.id)) {
|
||||
seen.add(operation.id);
|
||||
navigator.vibrate?.(10);
|
||||
}
|
||||
},
|
||||
{ flush: "sync" },
|
||||
);
|
||||
|
||||
await swipe(host, 20, log); // 10% progress: cancelled
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, log); // 75% progress: committed
|
||||
expect(log).toEqual([
|
||||
"recognized",
|
||||
"released",
|
||||
"recognized",
|
||||
"vibrate",
|
||||
"released",
|
||||
]);
|
||||
});
|
||||
|
||||
it("can vibrate on release from a custom .complete() decision", async () => {
|
||||
const vibrate = vi.fn(() => true);
|
||||
vi.stubGlobal("navigator", { ...navigator, vibrate });
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.complete(({ progress }) => {
|
||||
const commit = progress > 0.5;
|
||||
if (commit) navigator.vibrate?.(10);
|
||||
return commit;
|
||||
})
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.animate(motion),
|
||||
);
|
||||
await swipe(host, 20, []);
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, []);
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".onCommit()", () => {
|
||||
it("vibrates once when a release commits, and not when it cancels", async () => {
|
||||
const vibrate = vi.fn((_ms: number) => true);
|
||||
const { host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => vibrate(10))
|
||||
.animate(motion),
|
||||
);
|
||||
|
||||
await swipe(host, 20, []); // 10% progress: cancelled
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
await swipe(host, 150, []); // 75% progress: committed
|
||||
expect(vibrate).toHaveBeenCalledTimes(1);
|
||||
expect(vibrate).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it("is available at every stage after the direction, in order", async () => {
|
||||
const calls: string[] = [];
|
||||
const definition = gesture.to
|
||||
.left()
|
||||
.onCommit(() => calls.push("directed"))
|
||||
.complete(({ progress }) => progress > 0.5)
|
||||
.onCommit(() => calls.push("completed"))
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit((context) => calls.push(`navigation ${context.direction}`))
|
||||
.animate(motion);
|
||||
expect(definition.commitHandlers).toHaveLength(3);
|
||||
expect(Object.isFrozen(definition.commitHandlers)).toBe(true);
|
||||
|
||||
const { host } = await mount(definition);
|
||||
await swipe(host, 150, []);
|
||||
expect(calls).toEqual(["directed", "completed", "navigation left"]);
|
||||
});
|
||||
|
||||
it("keeps earlier builder stages unchanged", () => {
|
||||
const base = gesture.to.left().navigate(() => null);
|
||||
const withHandler = base.onCommit(() => {});
|
||||
expect(base.animate(motion).commitHandlers).toEqual([]);
|
||||
expect(withHandler.animate(motion).commitHandlers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a failing handler without stopping later handlers or the commit", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const onError = (error: unknown) => errors.push(error);
|
||||
process.on("uncaughtException", onError);
|
||||
try {
|
||||
const after = vi.fn();
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => {
|
||||
throw new Error("no vibration motor");
|
||||
})
|
||||
.onCommit(after)
|
||||
.animate(motion),
|
||||
);
|
||||
await swipe(host, 150, []);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
expect(scene.nodes.value.map((node) => node.state)).toEqual([
|
||||
"parked",
|
||||
"active",
|
||||
]);
|
||||
expect(errors).toEqual([new Error("no vibration motor")]);
|
||||
} finally {
|
||||
process.off("uncaughtException", onError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fires at release, before the settling animation finishes", async () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
|
||||
frames.push(cb),
|
||||
);
|
||||
const log: string[] = [];
|
||||
const { scene, host } = await mount(
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => forward(originView(Target)))
|
||||
.onCommit(() => log.push("commit"))
|
||||
.animate(motion),
|
||||
);
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
||||
|
||||
await swipe(host, 150, log);
|
||||
expect(log).toEqual(["recognized", "commit", "released"]);
|
||||
// The spring is still waiting for frames; the operation is settling.
|
||||
expect(scene.operations.value[0]?.phase).toBe("settling");
|
||||
expect(frames.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Property tests: random operation sequences and pointer streams must keep
|
||||
* the scene's structural invariants after every step, and must never leave
|
||||
* an operation stuck once all pointers are released.
|
||||
*/
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
fade,
|
||||
forward,
|
||||
gesture,
|
||||
OriginGestureSurface,
|
||||
OriginScene,
|
||||
OriginSceneError,
|
||||
originView,
|
||||
replace,
|
||||
slideLeft,
|
||||
slideRight,
|
||||
under,
|
||||
type OriginAction,
|
||||
type OriginOperationHandle,
|
||||
} from "../src";
|
||||
import type { SceneGraph } from "../src/scene/graph";
|
||||
import type { OriginScene as Scene } from "../src/scene/types";
|
||||
|
||||
const SEEDS = Number(process.env.PROPERTY_SEEDS ?? 60);
|
||||
|
||||
// Support -------------------------------------------------------------------
|
||||
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return {
|
||||
next,
|
||||
int: (n: number) => Math.floor(next() * n),
|
||||
pick: <T>(items: readonly T[]) => items[Math.floor(next() * items.length)]!,
|
||||
chance: (p: number) => next() < p,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic `requestAnimationFrame` and clock. */
|
||||
function installFrames() {
|
||||
let now = 1000;
|
||||
let queue: FrameRequestCallback[] = [];
|
||||
window.requestAnimationFrame = ((cb: FrameRequestCallback) =>
|
||||
queue.push(cb)) as typeof window.requestAnimationFrame;
|
||||
performance.now = () => now;
|
||||
return {
|
||||
advance(ms = 16) {
|
||||
now += ms;
|
||||
const run = queue;
|
||||
queue = [];
|
||||
for (const cb of run) cb(now);
|
||||
},
|
||||
drain() {
|
||||
for (let i = 0; i < 1000 && queue.length; i += 1) this.advance();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(rounds = 4) {
|
||||
for (let i = 0; i < rounds; i += 1)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
const component = (name: string): Component =>
|
||||
defineComponent({ name, render: () => h("div", name) });
|
||||
|
||||
/** One-line summary of a scene, for failure traces. */
|
||||
const describeScene = (scene: Scene) => {
|
||||
const nodes = scene.nodes.value
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.key}:${n.state}:in=${n.incomingOperationId ?? "-"}:prev=${n.previousNodeKey ?? "-"}`,
|
||||
)
|
||||
.join(" ");
|
||||
const operations = scene.operations.value
|
||||
.map(
|
||||
(o) =>
|
||||
`#${o.id} ${o.originKey}→${o.targetKey} ${o.history} ${o.phase}/${o.intent}`,
|
||||
)
|
||||
.join(", ");
|
||||
return ` roots=[${scene.roots.value}] nodes=[${nodes}] ops=[${operations}]`;
|
||||
};
|
||||
|
||||
const graphOf = (scene: Scene) =>
|
||||
(scene as unknown as { graph: SceneGraph }).graph;
|
||||
|
||||
/** Structural invariants that must hold after every step. */
|
||||
function checkInvariants(scene: Scene, label: string, rootCount: number) {
|
||||
const graph = graphOf(scene);
|
||||
const nodes = [...graph.nodes.values()];
|
||||
const roots = scene.roots.value;
|
||||
const fail = (message: string) => {
|
||||
throw new Error(`${label}\n${describeScene(scene)}\n${message}`);
|
||||
};
|
||||
|
||||
if (roots.length !== rootCount)
|
||||
fail(`expected ${rootCount} roots, found ${roots.length}`);
|
||||
if (new Set(roots).size !== roots.length) fail("duplicate roots");
|
||||
for (const key of roots) {
|
||||
const node = graph.node(key);
|
||||
if (!node) fail(`root ${key} does not exist`);
|
||||
else if (graph.incomingOf(node)) fail(`root ${key} has an incoming edge`);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// Incoming edges point back at their node.
|
||||
if (node.incomingOperationId !== undefined) {
|
||||
const incoming = graph.operation(node.incomingOperationId);
|
||||
if (!incoming) fail(`${node.key} has a dangling incoming edge`);
|
||||
else if (incoming.targetKey !== node.key)
|
||||
fail(`${node.key}'s incoming edge targets ${incoming.targetKey}`);
|
||||
}
|
||||
|
||||
// History links exist and are acyclic.
|
||||
const seen = new Set([node.key]);
|
||||
for (let key = node.previousNodeKey; key;) {
|
||||
const previous = graph.node(key);
|
||||
if (!previous) fail(`${node.key} has a missing previous entry ${key}`);
|
||||
if (seen.has(key)) fail(`${node.key} has cyclic history`);
|
||||
seen.add(key);
|
||||
key = previous?.previousNodeKey;
|
||||
}
|
||||
|
||||
if (node.state === "parked") {
|
||||
if (roots.includes(node.key)) fail(`parked ${node.key} is a root`);
|
||||
if (node.incomingOperationId !== undefined)
|
||||
fail(`parked ${node.key} is positioned by an edge`);
|
||||
if (scene.isNodeInteractive(node.key)) fail(`parked ${node.key} is live`);
|
||||
if (scene.styleForNode(node.key).visibility !== "hidden")
|
||||
fail(`parked ${node.key} is visible`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Every presented node reaches a root through acyclic incoming edges.
|
||||
const visited = new Set<string>();
|
||||
let current = node;
|
||||
for (;;) {
|
||||
if (visited.has(current.key)) fail(`${node.key} is in a visual cycle`);
|
||||
visited.add(current.key);
|
||||
const incoming = graph.incomingOf(current);
|
||||
if (!incoming) break;
|
||||
const origin = graph.node(incoming.originKey);
|
||||
if (!origin) fail(`${current.key}'s edge comes from a missing origin`);
|
||||
current = origin!;
|
||||
}
|
||||
if (!roots.includes(current.key))
|
||||
fail(`${node.key} is detached from every root`);
|
||||
if (node.state === "exposed" && scene.isNodeInteractive(node.key))
|
||||
fail(`exposed ${node.key} is live`);
|
||||
}
|
||||
|
||||
// Every live operation joins existing nodes; one per origin.
|
||||
const origins = new Set<string>();
|
||||
for (const operation of scene.operations.value) {
|
||||
if (!graph.node(operation.originKey))
|
||||
fail(`operation ${operation.id} has a missing origin`);
|
||||
if (!graph.node(operation.targetKey))
|
||||
fail(`operation ${operation.id} has a missing target`);
|
||||
if (origins.has(operation.originKey))
|
||||
fail(`${operation.originKey} has two live operations`);
|
||||
origins.add(operation.originKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Additional invariants once every operation has finished. */
|
||||
function checkResting(scene: Scene, rootCount: number, label: string) {
|
||||
checkInvariants(scene, label, rootCount);
|
||||
const states = scene.nodes.value.map((node) => node.state);
|
||||
expect(scene.operations.value, `${label}: live operations`).toEqual([]);
|
||||
expect(states, `${label}: states`).not.toContain("transitioning");
|
||||
expect(
|
||||
states.filter((state) => state === "active"),
|
||||
`${label}: one active entry per root`,
|
||||
).toHaveLength(rootCount);
|
||||
}
|
||||
|
||||
// Scene operations ----------------------------------------------------------
|
||||
|
||||
const drawerOpen = defineOriginChoreography({
|
||||
name: "drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const drawerClose = defineOriginChoreography({
|
||||
name: "drawer-close",
|
||||
commitThreshold: 0.2,
|
||||
commitVelocity: 0.5,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66}%)` },
|
||||
}),
|
||||
});
|
||||
const choreographies = [slideLeft, slideRight, fade, drawerOpen, drawerClose];
|
||||
const views = ["A", "B", "C", "D"].map((name) =>
|
||||
originView(component(name), undefined, { key: name.toLowerCase() }),
|
||||
);
|
||||
|
||||
function randomAction(r: ReturnType<typeof rng>): OriginAction {
|
||||
const choreography = r.pick(choreographies);
|
||||
const target = r.pick(views);
|
||||
const placement = r.pick(["above", "under", undefined] as const);
|
||||
switch (r.int(6)) {
|
||||
case 0:
|
||||
return forward(target, choreography, { placement });
|
||||
case 1:
|
||||
return replace(target, choreography, { placement });
|
||||
case 2:
|
||||
case 3:
|
||||
return back(choreography, { placement });
|
||||
case 4:
|
||||
return above(target, choreography, {
|
||||
history: r.pick(["push", "replace", "back"] as const),
|
||||
});
|
||||
default:
|
||||
return under(target, choreography);
|
||||
}
|
||||
}
|
||||
|
||||
async function runOperations(seed: number) {
|
||||
const r = rng(seed);
|
||||
const frames = installFrames();
|
||||
const initial = seed % 3 === 0 ? [views[0]!, views[1]!] : [views[0]!];
|
||||
const scene = createOriginScene({ initial });
|
||||
const handles: OriginOperationHandle[] = [];
|
||||
const unexpected: unknown[] = [];
|
||||
const expectSceneError = (error: unknown) => {
|
||||
if (!(error instanceof OriginSceneError)) unexpected.push(error);
|
||||
};
|
||||
|
||||
const trace: string[] = [];
|
||||
for (let step = 0; step < 80; step += 1) {
|
||||
const label = () =>
|
||||
`seed ${seed} step ${step}\n${trace.slice(-60).join("\n")}`;
|
||||
const roll = r.next();
|
||||
const liveIds = new Set(scene.operations.value.map(({ id }) => id));
|
||||
const live = handles.filter(({ id }) => liveIds.has(id));
|
||||
const pick = () =>
|
||||
live.length && r.chance(0.85) ? r.pick(live) : r.pick(handles);
|
||||
|
||||
if (roll < 0.3 || handles.length === 0) {
|
||||
const originKey = r.pick(scene.nodes.value).key;
|
||||
const action = randomAction(r);
|
||||
scene.begin(originKey, action).then(
|
||||
(handle) => {
|
||||
handles.push(handle);
|
||||
trace.push(
|
||||
` begin #${handle.id} ${originKey}→${handle.targetKey} ${action.history} ${action.choreography.name}`,
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
trace.push(
|
||||
` begin ${originKey} ${action.history} rejected: ${error.code}`,
|
||||
);
|
||||
expectSceneError(error);
|
||||
},
|
||||
);
|
||||
} else if (roll < 0.55) {
|
||||
const handle = pick();
|
||||
const p = r.next() * 1.4 - 0.2;
|
||||
const v = r.next() * 3 - 1;
|
||||
trace.push(`update #${handle.id} ${p.toFixed(2)} ${v.toFixed(2)}`);
|
||||
handle.update(p, v);
|
||||
} else if (roll < 0.75) {
|
||||
const handle = pick();
|
||||
const options = {
|
||||
commit: r.pick([true, false, undefined]),
|
||||
animate: r.chance(0.5),
|
||||
};
|
||||
trace.push(`finish #${handle.id} ${JSON.stringify(options)}`);
|
||||
void handle
|
||||
.finish(options)
|
||||
.then((v) => trace.push(` finished #${handle.id} → ${v}`));
|
||||
} else if (roll < 0.82) {
|
||||
const handle = pick();
|
||||
trace.push(`cancel #${handle.id}`);
|
||||
void handle
|
||||
.cancel({ animate: r.chance(0.5) })
|
||||
.then(() => trace.push(` cancelled #${handle.id}`));
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(12); i > 0; i -= 1) frames.advance(4 + r.int(40));
|
||||
}
|
||||
if (!r.chance(0.15)) await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(scene, label(), initial.length);
|
||||
}
|
||||
|
||||
// Release everything: every live operation finishes, then springs drain.
|
||||
await flush();
|
||||
for (const handle of handles) void handle.finish({ animate: r.chance(0.5) });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
checkResting(
|
||||
scene,
|
||||
initial.length,
|
||||
`seed ${seed} at rest\n${trace.slice(-50).join("\n")}`,
|
||||
);
|
||||
expect(unexpected, `seed ${seed}: non-scene errors`).toEqual([]);
|
||||
}
|
||||
|
||||
describe("scene properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random operations ${seed}`, () => runOperations(seed));
|
||||
});
|
||||
|
||||
// Gestures ------------------------------------------------------------------
|
||||
|
||||
const apps: Array<ReturnType<typeof createApp>> = [];
|
||||
afterEach(() => {
|
||||
for (const app of apps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 600;
|
||||
|
||||
function stubBounds(element: HTMLElement) {
|
||||
if (element.dataset.stubbed) return;
|
||||
element.dataset.stubbed = "1";
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: WIDTH },
|
||||
clientHeight: { configurable: true, value: HEIGHT },
|
||||
});
|
||||
element.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: WIDTH, height: HEIGHT }) as DOMRect;
|
||||
}
|
||||
|
||||
/** Views whose gestures can grow, collapse, and race chains of operations. */
|
||||
function gestureWorld() {
|
||||
const viewsByName: Record<string, ReturnType<typeof originView>> = {};
|
||||
const slide = defineOriginChoreography({
|
||||
name: "slide",
|
||||
commitVelocity: 0.6,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
const surface = (name: string, gestures: () => unknown[]) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup: () => () =>
|
||||
h(
|
||||
OriginGestureSurface,
|
||||
{ gestures: gestures() as never, "data-gesture-host": "" },
|
||||
() => [h("input", { "data-input": "" }), name],
|
||||
),
|
||||
});
|
||||
|
||||
const A = surface("A", () => [
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(async () => {
|
||||
await Promise.resolve();
|
||||
return forward(viewsByName.B!);
|
||||
})
|
||||
.animate(slide),
|
||||
gesture.from
|
||||
.left(40)
|
||||
.to.right()
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
gesture.from
|
||||
.when(({ point }) => point.localY < HEIGHT / 2)
|
||||
.to.down({ threshold: 12 })
|
||||
.complete(({ progress }) => progress > 0.2)
|
||||
.navigate(() => forward(viewsByName.C!, { placement: "under" }))
|
||||
.animate(drawerOpen),
|
||||
gesture.from
|
||||
.bottom("20%")
|
||||
.to.up()
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : replace(viewsByName.A!),
|
||||
)
|
||||
.animate(fade),
|
||||
]);
|
||||
const B = surface("B", () => [
|
||||
gesture.to
|
||||
.left({ threshold: 6 })
|
||||
.navigate(() => forward(viewsByName.C!))
|
||||
.animate(slide),
|
||||
gesture.to
|
||||
.right({ threshold: 6 })
|
||||
.navigate((context) => (context.canGoBack ? back() : null))
|
||||
.animate(slideRight),
|
||||
]);
|
||||
const C = surface("C", () => [
|
||||
gesture.to
|
||||
.up({ threshold: 4 })
|
||||
.navigate((context) =>
|
||||
context.canGoBack ? back() : forward(viewsByName.B!),
|
||||
)
|
||||
.animate(drawerClose),
|
||||
gesture.to
|
||||
.left()
|
||||
.navigate(() => {
|
||||
throw new Error("intentional navigate failure");
|
||||
})
|
||||
.animate(slide),
|
||||
]);
|
||||
viewsByName.A = originView(A, undefined, { key: "a" });
|
||||
viewsByName.B = originView(B, undefined, { key: "b" });
|
||||
viewsByName.C = originView(C, undefined, { key: "c" });
|
||||
|
||||
const scene = createOriginScene({ initial: viewsByName.A });
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const app = createApp({ render: () => h(OriginScene, { scene }) });
|
||||
apps.push(app);
|
||||
app.mount(root);
|
||||
return { scene, root };
|
||||
}
|
||||
|
||||
function pointerEvent(type: string, init: PointerEventInit, timeStamp: number) {
|
||||
const event = new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, "timeStamp", { value: timeStamp });
|
||||
return event;
|
||||
}
|
||||
|
||||
async function runPointers(seed: number) {
|
||||
const r = rng(seed * 7919);
|
||||
const frames = installFrames();
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { scene, root } = gestureWorld();
|
||||
await nextTick();
|
||||
let time = 100;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let pointerId = 1;
|
||||
let host: HTMLElement | null = null;
|
||||
let heading: "left" | "right" | "up" | "down" = "left";
|
||||
const trace: string[] = [];
|
||||
|
||||
const dispatch = (
|
||||
type: string,
|
||||
init: PointerEventInit,
|
||||
onInput = false,
|
||||
) => {
|
||||
trace.push(
|
||||
`${type} ${host?.closest("[data-origin-node]")?.getAttribute("data-origin-node")} ${JSON.stringify(init)} t=${time}${host?.isConnected ? "" : " (detached)"}`,
|
||||
);
|
||||
if (!host?.isConnected) return;
|
||||
const target = onInput
|
||||
? (host.querySelector("[data-input]") as HTMLElement)
|
||||
: host;
|
||||
target.dispatchEvent(pointerEvent(type, init, time));
|
||||
};
|
||||
|
||||
for (let step = 0; step < 120; step += 1) {
|
||||
time += r.int(40);
|
||||
const roll = r.next();
|
||||
if (roll < 0.15) {
|
||||
// A new primary pointer means the previous one ended: a browser
|
||||
// delivers its pointer-up to the captured host, or, if that was
|
||||
// lost, fires lostpointercapture there.
|
||||
dispatch(r.chance(0.8) ? "pointerup" : "lostpointercapture", {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
});
|
||||
if (r.chance(0.8)) await flush(3);
|
||||
const nodes = scene.nodes.value.filter((node) =>
|
||||
scene.isNodeInteractive(node.key),
|
||||
);
|
||||
const node = r.pick(nodes.length ? nodes : scene.nodes.value);
|
||||
host = root.querySelector(
|
||||
`[data-origin-node="${CSS.escape(node.key)}"] [data-gesture-host]`,
|
||||
);
|
||||
if (host) stubBounds(host);
|
||||
x = r.chance(0.3) ? r.int(50) : r.int(WIDTH);
|
||||
y = r.chance(0.3) ? HEIGHT - r.int(130) : r.int(HEIGHT);
|
||||
pointerId = r.chance(0.8) ? 1 : 2;
|
||||
heading = r.pick(["left", "right", "up", "down"] as const);
|
||||
dispatch(
|
||||
"pointerdown",
|
||||
{
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerId,
|
||||
button: r.chance(0.95) ? 0 : 1,
|
||||
isPrimary: r.chance(0.95),
|
||||
},
|
||||
r.chance(0.08),
|
||||
);
|
||||
} else if (roll < 0.65) {
|
||||
const amount = 5 + r.int(55);
|
||||
if (heading === "left") x -= amount;
|
||||
if (heading === "right") x += amount;
|
||||
if (heading === "up") y -= amount;
|
||||
if (heading === "down") y += amount;
|
||||
dispatch("pointermove", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.8) {
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
} else if (roll < 0.85) {
|
||||
dispatch(r.pick(["pointercancel", "lostpointercapture"]), {
|
||||
pointerId,
|
||||
});
|
||||
} else {
|
||||
trace.push("frames");
|
||||
for (let i = 1 + r.int(20); i > 0; i -= 1) frames.advance(16);
|
||||
}
|
||||
if (r.chance(0.7)) {
|
||||
frames.advance(16);
|
||||
frames.advance(16);
|
||||
}
|
||||
if (!r.chance(0.2)) await flush(3);
|
||||
trace.push(describeScene(scene));
|
||||
checkInvariants(
|
||||
scene,
|
||||
`seed ${seed} step ${step}\n${trace.slice(-40).join("\n")}`,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Lift the pointer; nothing may remain stuck mid-transition.
|
||||
dispatch("pointerup", { clientX: x, clientY: y, pointerId });
|
||||
await flush();
|
||||
frames.drain();
|
||||
await flush();
|
||||
trace.push(describeScene(scene));
|
||||
checkResting(
|
||||
scene,
|
||||
1,
|
||||
`seed ${seed} after release\n${trace.slice(-40).join("\n")}`,
|
||||
);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
}
|
||||
}
|
||||
|
||||
describe("gesture properties", () => {
|
||||
for (let seed = 1; seed <= SEEDS; seed += 1)
|
||||
it(`random pointer stream ${seed}`, () => runPointers(seed));
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
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 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", 120, 100));
|
||||
await flush();
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
host.dispatchEvent(pointer("pointerup", 120, 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.3", () => {
|
||||
expect(shouldCommit(operation(undefined, 0.29), {})).toBe(false);
|
||||
expect(shouldCommit(operation(undefined, 0.3), {})).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("still yields to an explicit decision", () => {
|
||||
expect(shouldCommit(operation(0.1, 5), { commit: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import {
|
||||
createApp,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
above,
|
||||
back,
|
||||
createOriginScene,
|
||||
defineOriginChoreography,
|
||||
forward,
|
||||
OriginScene,
|
||||
originView,
|
||||
replace,
|
||||
under,
|
||||
} from "../src";
|
||||
|
||||
const mountedApps: Array<ReturnType<typeof createApp>> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function component(name: string): Component {
|
||||
return defineComponent({
|
||||
name,
|
||||
render: () => h("div", { "data-test-view": name }, name),
|
||||
});
|
||||
}
|
||||
|
||||
const layeredMotion = defineOriginChoreography({
|
||||
name: "test-layered-motion",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 100}px)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 100}px)` },
|
||||
}),
|
||||
});
|
||||
|
||||
const partialDrawerOpen = defineOriginChoreography({
|
||||
name: "test-partial-drawer-open",
|
||||
persistAtRest: true,
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(progress - 1) * 66.6667}%)` },
|
||||
}),
|
||||
});
|
||||
|
||||
const partialDrawerClose = defineOriginChoreography({
|
||||
name: "test-partial-drawer-close",
|
||||
effects: ({ progress }) => ({
|
||||
source: { transform: `translateX(${-progress * 66.6667}%)` },
|
||||
target: { transform: `translateX(${(1 - progress) * 66.6667}%)` },
|
||||
}),
|
||||
});
|
||||
|
||||
describe("origin-relative scene graph", () => {
|
||||
it("composes X→Y and Y→Z as independent transform layers", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const z = originView(component("Z"), undefined, { key: "z" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.5);
|
||||
const yz = await scene.begin(xy.targetKey, above(z, layeredMotion));
|
||||
yz.update(0.25);
|
||||
|
||||
/*
|
||||
* Y inherits the target half of X→Y, then adds its own source half of
|
||||
* Y→Z. Z inherits X→Y as well, but receives Y→Z's target half.
|
||||
*/
|
||||
expect(scene.styleForNode(xy.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(-25px)",
|
||||
);
|
||||
expect(scene.styleForNode(xy.targetKey)).toMatchObject({
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
});
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe(
|
||||
"translateX(50px) translateX(75px)",
|
||||
);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splices completed operations in either order without losing descendants", async () => {
|
||||
const scene = createOriginScene({
|
||||
initial: originView(component("X"), undefined, { key: "x" }),
|
||||
});
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(
|
||||
xKey,
|
||||
above(originView(component("Y"), undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
xy.update(0.8);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(component("Z"), undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
yz.update(0.6);
|
||||
|
||||
// Completing the newer edge first parks Y while Z replaces it as the
|
||||
// visual target. Y remains mounted as Z's retained previous entry.
|
||||
await yz.finish({ commit: true, animate: false });
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
expect(
|
||||
scene.nodes.value.find((node) => node.view.name === "Y")?.state,
|
||||
).toBe("parked");
|
||||
expect(scene.operations.value).toHaveLength(1);
|
||||
expect(scene.operations.value[0]?.targetKey).toBe(yz.targetKey);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toMatch(
|
||||
/^translateX\(19\.9.+px\)$/,
|
||||
);
|
||||
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "parked"],
|
||||
["Z", "active"],
|
||||
]);
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.styleForNode(yz.targetKey).transform).toBe("none");
|
||||
expect(scene.styleForNode(xKey).visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("does not remount a target when its incoming operation is collapsed", async () => {
|
||||
let yMounts = 0;
|
||||
const X = component("X");
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
yMounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const Z = component("Z");
|
||||
const scene = createOriginScene({
|
||||
initial: originView(X, undefined, { key: "x" }),
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
expect(
|
||||
(host.querySelector(".nvo-scene") as HTMLElement | null)?.style.position,
|
||||
).toBe("relative");
|
||||
|
||||
const xy = await scene.begin(
|
||||
scene.nodes.value[0]!.key,
|
||||
above(originView(Y, undefined, { key: "y" }), layeredMotion),
|
||||
);
|
||||
const yz = await scene.begin(
|
||||
xy.targetKey,
|
||||
above(originView(Z, undefined, { key: "z" }), layeredMotion),
|
||||
);
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(
|
||||
(host.querySelector('[data-origin-view="Y"]') as HTMLElement | null)
|
||||
?.style.position,
|
||||
).toBe("absolute");
|
||||
|
||||
// Y remains the same flat, keyed host while X→Y disappears around it.
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(yMounts).toBe(1);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
]);
|
||||
|
||||
await yz.cancel({ animate: false });
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses the retained previous node and pops only the current entry on back", async () => {
|
||||
let xMounts = 0;
|
||||
let xUnmounts = 0;
|
||||
let yUnmounts = 0;
|
||||
|
||||
const X = defineComponent({
|
||||
name: "X",
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
onMounted(() => {
|
||||
xMounts += 1;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
xUnmounts += 1;
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
"data-scroll": "feed",
|
||||
style: { height: "100px", overflow: "auto" },
|
||||
},
|
||||
h("div", { style: { height: "2000px" } }, "Feed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const Y = defineComponent({
|
||||
name: "Y",
|
||||
setup() {
|
||||
onUnmounted(() => {
|
||||
yUnmounts += 1;
|
||||
});
|
||||
return () => h("div", "Y");
|
||||
},
|
||||
});
|
||||
const x = originView(X, { message: "original" }, { key: "x" });
|
||||
const y = originView(Y, undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const originalScroller = host.querySelector(
|
||||
'[data-scroll="feed"]',
|
||||
) as HTMLElement;
|
||||
originalScroller.scrollTop = 842;
|
||||
|
||||
const xy = await scene.begin(xKey, forward(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
|
||||
const yContext = scene.contextFor(xy.targetKey);
|
||||
expect(yContext.previous).toBe(x);
|
||||
expect(yContext.history).toEqual([x]);
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(scene.nodes.value.find((node) => node.key === xKey)?.state).toBe(
|
||||
"parked",
|
||||
);
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
const yx = await scene.begin(xy.targetKey, back(layeredMotion));
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await nextTick();
|
||||
expect(host.querySelector('[data-scroll="feed"]')).toBe(originalScroller);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
expect(scene.nodes.value.map((node) => node.key)).toEqual([xKey]);
|
||||
expect(scene.contextFor(xKey).history).toEqual([]);
|
||||
expect(scene.nodes.value[0]?.state).toBe("active");
|
||||
expect(xMounts).toBe(1);
|
||||
expect(xUnmounts).toBe(0);
|
||||
expect(yUnmounts).toBe(1);
|
||||
expect(originalScroller.scrollTop).toBe(842);
|
||||
});
|
||||
|
||||
it("re-parks the retained target when a back operation is cancelled", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
await xy.finish({ commit: true, animate: false });
|
||||
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.cancel({ animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["X", "parked"],
|
||||
["Y", "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the current entry and makes back skip the removed instance", async () => {
|
||||
let paymentUnmounts = 0;
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const Payment = defineComponent({
|
||||
name: "Payment",
|
||||
setup() {
|
||||
onUnmounted(() => {
|
||||
paymentUnmounts += 1;
|
||||
});
|
||||
return () => h("div", "Payment");
|
||||
},
|
||||
});
|
||||
const payment = originView(Payment, undefined, { key: "payment" });
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () => h(OriginScene, { scene }),
|
||||
});
|
||||
mountedApps.push(app);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
await openPayment.finish({ commit: true, animate: false });
|
||||
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.finish({ commit: true, animate: false });
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Confirmation", "active"],
|
||||
]);
|
||||
expect(paymentUnmounts).toBe(1);
|
||||
|
||||
const confirmationContext = scene.contextFor(confirmPayment.targetKey);
|
||||
expect(confirmationContext.previous).toBe(home);
|
||||
expect(confirmationContext.history).toEqual([home]);
|
||||
|
||||
const returnHome = await scene.begin(
|
||||
confirmPayment.targetKey,
|
||||
back(layeredMotion),
|
||||
);
|
||||
expect(returnHome.targetKey).toBe(homeKey);
|
||||
await returnHome.finish({ commit: true, animate: false });
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual(["Home"]);
|
||||
});
|
||||
|
||||
it("restores the current entry when an interactive replace is cancelled", async () => {
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const payment = originView(component("Payment"), undefined, {
|
||||
key: "payment",
|
||||
});
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
await openPayment.finish({ commit: true, animate: false });
|
||||
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.cancel({ animate: false });
|
||||
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Payment", "active"],
|
||||
]);
|
||||
expect(scene.contextFor(openPayment.targetKey).history).toEqual([home]);
|
||||
});
|
||||
|
||||
it("can replace an initial root without manufacturing history", async () => {
|
||||
const scene = createOriginScene({
|
||||
initial: originView(component("Welcome"), undefined, { key: "welcome" }),
|
||||
});
|
||||
const welcomeKey = scene.nodes.value[0]!.key;
|
||||
const replacement = await scene.begin(
|
||||
welcomeKey,
|
||||
replace(
|
||||
originView(component("Signed in"), undefined, { key: "signed-in" }),
|
||||
layeredMotion,
|
||||
),
|
||||
);
|
||||
await replacement.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.roots.value).toEqual([replacement.targetKey]);
|
||||
expect(scene.nodes.value.map((node) => node.view.name)).toEqual([
|
||||
"Signed in",
|
||||
]);
|
||||
expect(scene.contextFor(replacement.targetKey)).toMatchObject({
|
||||
canGoBack: false,
|
||||
history: [],
|
||||
previous: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("can replace a target while its incoming push is still settling", async () => {
|
||||
const home = originView(component("Home"), undefined, { key: "home" });
|
||||
const payment = originView(component("Payment"), undefined, {
|
||||
key: "payment",
|
||||
});
|
||||
const confirmation = originView(component("Confirmation"), undefined, {
|
||||
key: "confirmation",
|
||||
});
|
||||
const scene = createOriginScene({ initial: home });
|
||||
const homeKey = scene.nodes.value[0]!.key;
|
||||
const openPayment = await scene.begin(
|
||||
homeKey,
|
||||
forward(payment, layeredMotion),
|
||||
);
|
||||
openPayment.update(0.8, 1);
|
||||
|
||||
const paymentSettlement = openPayment.finish({ commit: true });
|
||||
const confirmPayment = await scene.begin(
|
||||
openPayment.targetKey,
|
||||
replace(confirmation, layeredMotion),
|
||||
);
|
||||
await confirmPayment.finish({ commit: true, animate: false });
|
||||
await paymentSettlement;
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Home", "parked"],
|
||||
["Confirmation", "active"],
|
||||
]);
|
||||
expect(scene.contextFor(confirmPayment.targetKey).history).toEqual([home]);
|
||||
});
|
||||
|
||||
it("keeps a partial push connected at rest and restores it after cancelled back", async () => {
|
||||
const page = originView(component("Page"), undefined, { key: "page" });
|
||||
const drawer = originView(component("Drawer"), undefined, {
|
||||
key: "drawer",
|
||||
});
|
||||
const scene = createOriginScene({ initial: page });
|
||||
const pageKey = scene.nodes.value[0]!.key;
|
||||
|
||||
const openDrawer = await scene.begin(
|
||||
pageKey,
|
||||
forward(drawer, partialDrawerOpen),
|
||||
);
|
||||
await openDrawer.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Page", "exposed"],
|
||||
["Drawer", "active"],
|
||||
]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
expect(scene.styleForNode(pageKey).pointerEvents).toBe("none");
|
||||
expect(scene.styleForNode(openDrawer.targetKey).transform).toBe(
|
||||
"translateX(0%)",
|
||||
);
|
||||
|
||||
const closeDrawer = await scene.begin(
|
||||
openDrawer.targetKey,
|
||||
back(partialDrawerClose),
|
||||
);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
await closeDrawer.cancel({ animate: false });
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(
|
||||
scene.nodes.value.map((node) => [node.view.name, node.state]),
|
||||
).toEqual([
|
||||
["Page", "exposed"],
|
||||
["Drawer", "active"],
|
||||
]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("translateX(66.6667%)");
|
||||
|
||||
const committedClose = await scene.begin(
|
||||
openDrawer.targetKey,
|
||||
back(partialDrawerClose),
|
||||
);
|
||||
await committedClose.finish({ commit: true, animate: false });
|
||||
|
||||
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||
[pageKey, "active"],
|
||||
]);
|
||||
expect(scene.roots.value).toEqual([pageKey]);
|
||||
expect(scene.styleForNode(pageKey).transform).toBe("none");
|
||||
});
|
||||
|
||||
it("hands an immediate back gesture from a settling push to the same instances", async () => {
|
||||
const x = originView(component("X"), undefined, { key: "x" });
|
||||
const y = originView(component("Y"), undefined, { key: "y" });
|
||||
const scene = createOriginScene({ initial: x });
|
||||
const xKey = scene.nodes.value[0]!.key;
|
||||
const xy = await scene.begin(xKey, above(y, layeredMotion));
|
||||
xy.update(0.8, 1);
|
||||
|
||||
const forwardSettlement = xy.finish({ commit: true });
|
||||
const yx = await scene.begin(
|
||||
xy.targetKey,
|
||||
under(x, layeredMotion, { history: "back" }),
|
||||
);
|
||||
expect(yx.targetKey).toBe(xKey);
|
||||
await yx.finish({ commit: true, animate: false });
|
||||
await forwardSettlement;
|
||||
|
||||
expect(scene.operations.value).toHaveLength(0);
|
||||
expect(scene.nodes.value.map((node) => [node.key, node.state])).toEqual([
|
||||
[xKey, "active"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user