Files
software-mansion__argent/packages/tool-server/test/flows/flow-pinch-geometry.test.ts
Jakub Piasecki f32659df31 feat(flow): pinch directive (#545)
Adds a selector-targeted pinch-zoom directive to flows: `pinch: { on?,
scale }`. `on` resolves like `tap` (settled tree + auto-wait); omitted,
the pinch centers on the screen. `scale > 1` zooms in, `< 1` zooms out.

## Design

**Options-map only, no bare form.** `tap: "Map"` is unambiguous; `pinch:
"Map"` isn't (in or out?), so the parser requires `{ on?, scale }` and
rejects a bare selector with authoring guidance.

**Validity checks, not policy caps.** `scale` must be finite, > 0, and ≠
1 (a no-op scale is almost certainly a mistake) — but there is
deliberately no magnitude cap. An extreme scale decomposes
logarithmically into equal-ratio sub-gestures of ≤4× each, chained with
a 250 ms recognizer-reset delay (`scale: 1e6` is just 10 gestures).

**Pure geometry module.** All placement math lives in
`flow-pinch-geometry.ts` as deterministic data-in/data-out functions (no
device access), so tests drive it with synthetic centers, spans, and
edge guards. Per sub-gesture, a horizontal and a vertical candidate are
built from the axis-matching target dimension (normalized X and Y are
different units, so never `min(width, height)`), and the better one is
dispatched.

**Edge safety scores, it never rejects.** Per-platform system-edge
guards (wider on Android's back-gesture zones) demote candidates in
ranking but never fail a step: viable travel beats edge safety (a
risky-but-perceptible gesture beats a safe no-op the recognizer
ignores), then fully-safe with the most travel, then motion parallel to
the violated edge (edge swipes need motion *away* from the edge). Guards
are resolved once per directive and passed as data — the seam for a
future per-device query. The only geometry failure is literally zero
on-screen finger travel; a tiny target is still attempted.

**Centroid drift near edges.** When expanding fingers would leave the
screen, the centroid drifts inward — only on the moving axis, and only
as far as needed. `gesture-pinch` grows optional
`endCenterX`/`endCenterY` to interpolate the centroid alongside
separation; omitted, behavior is unchanged.

**Open-loop by design.** There is no "current zoom" to read back, so the
directive doesn't try to verify the multiplier — flows assert on the
resulting UI instead.

**Platform rejections.** Vega is remote-driven (no touch input) and
Chromium has no uniform pinch-zoom mapping for a desktop app (ctrl+wheel
or app-specific controls) — both fail upfront with guidance to drive the
app's zoom UI directly.

## Tests

Unit tests cover the geometry module (decomposition, candidate
construction, edge-guard ranking), the directive end-to-end against a
fake device, and the `gesture-pinch` centroid-drift interpolation.
2026-07-23 07:44:26 +02:00

253 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildAxisCandidate,
decomposePinch,
selectPinchCandidate,
systemEdgeGuards,
PINCH_RATIO_PER_GESTURE,
SCREEN_EDGE_INSET,
type EdgeGuards,
type PinchCandidate,
} from "../../src/tools/flows/flow-pinch-geometry";
import { resolveDevice } from "../../src/utils/device-info";
const IOS_GUARDS = systemEdgeGuards(resolveDevice("00000000-0000-0000-0000-0000000000ab"));
const ANDROID_GUARDS = systemEdgeGuards(resolveDevice("emulator-5554"));
/** Build both axis candidates and select, like runPinch does. */
function pick(
center: { x: number; y: number },
per: number,
guards: EdgeGuards,
frame?: { width: number; height: number }
): PinchCandidate | undefined {
const candidates = [
buildAxisCandidate({ angle: 0, center, targetSpan: frame?.width, per, guards }),
buildAxisCandidate({ angle: 90, center, targetSpan: frame?.height, per, guards }),
].filter((c): c is PinchCandidate => c !== undefined);
return selectPinchCandidate(candidates);
}
describe("decomposePinch", () => {
it("splits by the per-gesture ratio cap only, with equal-ratio sub-gestures", () => {
expect(decomposePinch(2)).toEqual({ n: 1, per: 2 });
expect(decomposePinch(0.5)).toEqual({ n: 1, per: 0.5 });
const zoomIn = decomposePinch(20);
expect(zoomIn.n).toBe(3);
expect(zoomIn.per).toBeCloseTo(20 ** (1 / 3), 12);
const zoomOut = decomposePinch(0.05);
expect(zoomOut.n).toBe(3);
expect(zoomOut.per).toBeCloseTo(0.05 ** (1 / 3), 12);
});
it("grows logarithmically with no gesture-count cap (scale 1e6 → 10 gestures)", () => {
expect(decomposePinch(1e6).n).toBe(10);
});
it("decomposes extreme scales finitely — a subnormal scale must not overflow via 1/scale", () => {
// 5e-324 (Number.MIN_VALUE) is exactly 4^-537: the reciprocal is Infinity,
// but the log-space magnitude still decomposes it.
const tiny = decomposePinch(5e-324);
expect(tiny.n).toBe(537);
expect(Number.isFinite(tiny.per)).toBe(true);
expect(tiny.per).toBeGreaterThan(0);
expect(tiny.per).toBeLessThan(1);
expect(tiny.per ** tiny.n).toBe(5e-324);
const huge = decomposePinch(1e300);
expect(huge.n).toBe(499);
expect(huge.per).toBeCloseTo(1e300 ** (1 / 499), 12);
});
it("does not gain a spurious gesture on exact ratio powers", () => {
expect(decomposePinch(4).n).toBe(1);
expect(decomposePinch(16).n).toBe(2);
expect(decomposePinch(64).n).toBe(3);
expect(decomposePinch(0.25).n).toBe(1);
// The worst FP case: log(4^29)/log(4) lands epsilon-high.
expect(decomposePinch(PINCH_RATIO_PER_GESTURE ** 29).n).toBe(29);
// Mirror case: |log(4^-29)| can differ from log(4^29) by an ulp.
expect(decomposePinch(PINCH_RATIO_PER_GESTURE ** -29).n).toBe(29);
});
});
describe("buildAxisCandidate / selectPinchCandidate", () => {
it("dispatches one substantial gesture for a portrait-normalized 150×150 target at scale 4", () => {
// 150×150 px on a 1080×2400 portrait phone: much wider than tall in
// normalized units — the axis math must use the matching dimension.
const frame = { width: 150 / 1080, height: 150 / 2400 };
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, frame);
expect(selected).toBeDefined();
expect(selected!.angle).toBe(0); // width is the larger normalized span
expect(selected!.end / selected!.start).toBeCloseTo(4, 9); // full requested ratio
expect(selected!.travel).toBeGreaterThan(0.3); // substantial, not target-clipped
expect(selected!.fullyEdgeSafe).toBe(true);
});
it("chooses the vertical axis for a tall/narrow target", () => {
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, { width: 0.1, height: 0.6 });
expect(selected!.angle).toBe(90);
});
it("still attempts a tiny target (no minimum size)", () => {
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, { width: 0.01, height: 0.005 });
expect(selected).toBeDefined();
expect(selected!.travel).toBeGreaterThan(0);
expect(selected!.end / selected!.start).toBeCloseTo(4, 9);
});
it("runs parallel to the violated edge for a side-edge target", () => {
// Center inside the left guard: a horizontal pinch would start like an OS
// back swipe, so the vertical (parallel) candidate must win.
const selected = pick({ x: 0.05, y: 0.5 }, 4, IOS_GUARDS, { width: 0.1, height: 0.2 });
expect(selected!.angle).toBe(90);
expect(selected!.axisSafe).toBe(true);
expect(selected!.fullyEdgeSafe).toBe(false); // perpendicular coordinate stays in the guard
});
it("keeps the ratio and drifts the centroid inward for a corner target", () => {
// Bottom-left corner (frame x 0–0.3, y 0.86–1.0): center (0.15, 0.93).
const selected = pick({ x: 0.15, y: 0.93 }, 4, IOS_GUARDS, { width: 0.3, height: 0.14 });
expect(selected!.angle).toBe(0); // parallel to the violated bottom edge
expect(selected!.end / selected!.start).toBeCloseTo(4, 9);
expect(selected!.endCenter).toBeGreaterThan(0.15); // drifted inward
// Final pointers stay inside the screen inset.
expect(selected!.endCenter - selected!.end / 2).toBeGreaterThanOrEqual(
SCREEN_EDGE_INSET - 1e-9
);
expect(selected!.endCenter + selected!.end / 2).toBeLessThanOrEqual(
1 - SCREEN_EDGE_INSET + 1e-9
);
});
it("keeps Down points within the screen inset when the centroid sits inside a guard", () => {
// Center x 0.05 is inside the 0.08 left guard: the fallback start span
// must clamp to the 2% inset, not the full screen span.
const candidate = buildAxisCandidate({
angle: 0,
center: { x: 0.05, y: 0.5 },
targetSpan: 0.1,
per: 4,
guards: IOS_GUARDS,
});
expect(candidate).toBeDefined();
expect(candidate!.start).toBeGreaterThan(0);
expect(0.05 - candidate!.start / 2).toBeGreaterThanOrEqual(SCREEN_EDGE_INSET - 1e-9);
});
it("scores a candidate with its perpendicular coordinate in a guard band as not fully edge-safe", () => {
// Own axis clear, but both Down points ride the top guard band.
const candidate = buildAxisCandidate({
angle: 0,
center: { x: 0.5, y: 0.03 },
per: 4,
guards: IOS_GUARDS,
});
expect(candidate!.axisSafe).toBe(true);
expect(candidate!.fullyEdgeSafe).toBe(false);
});
it("guards the same near-side-edge target at 0.13 on android but 0.08 on ios", () => {
expect(IOS_GUARDS).toEqual({ left: 0.08, right: 0.08, top: 0.08, bottom: 0.08 });
expect(ANDROID_GUARDS).toEqual({ left: 0.13, right: 0.13, top: 0.08, bottom: 0.08 });
// Wide target near the left edge (center x 0.15): horizontal clears the
// iOS 0.08 side guard with room to spare, but not Android's 0.13.
const center = { x: 0.15, y: 0.5 };
const frame = { width: 0.3, height: 0.1 };
expect(pick(center, 4, IOS_GUARDS, frame)!.angle).toBe(0);
expect(pick(center, 4, ANDROID_GUARDS, frame)!.angle).toBe(90);
});
it("tracks injected synthetic guards — the selection flips with the guard data alone", () => {
const center = { x: 0.5, y: 0.5 };
const sideGuarded: EdgeGuards = { left: 0.4, right: 0.4, top: 0, bottom: 0 };
const capGuarded: EdgeGuards = { left: 0, right: 0, top: 0.4, bottom: 0.4 };
expect(pick(center, 4, sideGuarded)!.angle).toBe(90);
expect(pick(center, 4, capGuarded)!.angle).toBe(0);
});
it("fails only when there is literally no positive on-screen motion", () => {
expect(selectPinchCandidate([])).toBeUndefined();
// A centroid at the screen edge has no room on its axis in the fallback.
expect(
buildAxisCandidate({ angle: 0, center: { x: 0, y: 0.5 }, per: 4, guards: IOS_GUARDS })
).toBeUndefined();
});
it("prefers fully edge-safe candidates over axis-only safe candidates", () => {
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
angle: 0,
start: 0.1,
end: 0.13,
endCenter: 0.5,
travel: 0.03,
viable: true,
axisSafe: true,
fullyEdgeSafe: true,
clearance: -1,
...overrides,
});
const fullyEdgeSafe = mk({ angle: 90 });
const axisSafe = mk({
fullyEdgeSafe: false,
clearance: 1,
travel: 0.9,
});
// Deliberately make both later tie-breakers favor axisSafe: the safety
// boundary itself must decide this comparison.
expect(selectPinchCandidate([axisSafe, fullyEdgeSafe])).toBe(fullyEdgeSafe);
});
it("prefers axis-only safe candidates over candidates unsafe on their own axis", () => {
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
angle: 0,
start: 0.1,
end: 0.13,
endCenter: 0.5,
travel: 0.03,
viable: true,
axisSafe: true,
fullyEdgeSafe: false,
clearance: -1,
...overrides,
});
const axisSafe = mk({ angle: 90 });
const unsafe = mk({
axisSafe: false,
clearance: 1,
travel: 0.9,
});
// Deliberately make both later tie-breakers favor unsafe: the safety
// boundary itself must decide this comparison.
expect(selectPinchCandidate([unsafe, axisSafe])).toBe(axisSafe);
});
it("prefers greater starting clearance, then greater travel, among unsafe candidates", () => {
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
angle: 0,
start: 0.1,
end: 0.4,
endCenter: 0.5,
travel: 0.3,
viable: true,
axisSafe: false,
fullyEdgeSafe: false,
clearance: -0.05,
...overrides,
});
expect(
selectPinchCandidate([mk({ clearance: -0.06 }), mk({ angle: 90, clearance: -0.01 })])!.angle
).toBe(90);
expect(selectPinchCandidate([mk({ travel: 0.1 }), mk({ angle: 90, travel: 0.5 })])!.angle).toBe(
90
);
});
});