Files
software-mansion__argent/packages/tool-server/test/gesture-rotate-radius.test.ts
Jakub Piasecki 6a53e66a96 feat(flow): rotate directive (#546)
# feat(flow): rotate directive

Adds a selector-targeted two-finger rotation directive to flows:
`rotate: { on?, by }`. `on` resolves like `tap` (settled tree +
auto-wait); omitted, the rotation centers on the screen. `by` is
degrees, positive = clockwise. Distinct from `tool: rotate`, which
changes device orientation — the docs and step labels call this out.

## Design

**One continuous gesture, fixed pace, no duration option.** Fingers
orbit at ~90°/300 ms; duration derives from the angle. Unlike pinch
there is no sub-gesture chaining — any angle dispatches as one sweep, so
the angular delta is exact with no settle delays. `by` is parse-bounded
to ±3000°: the largest sweep the 10 s single-gesture envelope delivers
at that pace, derived from the constants rather than picked as policy —
rejected at parse, never silently clamped. A consequence embraced here:
frame count always scales with the angle, so a speed-sensitive recording
stays a raw `tool:` step (the create-flow skill's polish list covers the
conversion).

**Fixed centroid, constant physical radius.** Drifting the centroid
would couple pan into the turn; growing the radius would couple pinch.
Both stay fixed — the trade is a smaller orbit near edges and small
targets, in exchange for a pure rotation.

**Physical circle, not a normalized-space ellipse.** A single normalized
radius on a non-square screen traces a physical ellipse, coupling an
unintended pinch into every turn. `gesture-rotate` grows optional
per-axis `radiusX`/`radiusY` (fractions of width/height with `radiusX·W
= radiusY·H`), and the flow tree adapters surface screen dimensions via
an optional `screen` field on `DescribeTreeData` so the directive can
compute the aspect. Missing dimensions fail soft to the legacy
single-radius call rather than erroring.

**Placement reuses pinch's edge model.** The initial finger axis
(horizontal or vertical) is scored against the same per-platform
system-edge guards, preferring a start parallel to the nearest violated
edge and staying inside the target's axis-matching span. Guards only
score placement — they never shrink the radius, and a violated guard is
attempted, never rejected. The only geometry failure is no positive
on-screen orbit radius.

**Abort correctness.** `gesture-rotate` now honors `ctx.signal`: each
frame checks it, lifts the synthetic fingers with a terminal Up (so a
cancelled run doesn't leave them held on the glass), then throws with
name `AbortError`; the directive maps a mid-gesture abort to the run's
aborted skip rather than a step failure.

**Explicit `inputSchema`.** The radius requirement is a zod `.refine()`
cross-field rule, which `z.toJSONSchema` drops — so the tool ships a
derived schema plus an explicit `anyOf` (`radius` or
`radiusX`+`radiusY`) to keep invalid calls rejected at the schema layer.

**Platform rejections.** Vega is remote-driven (no touch) and Chromium
has no rotate-gesture idiom at all — both fail upfront with guidance to
drive the app's rotate controls directly.

## Tests

Unit tests cover the geometry module (duration derivation, candidate
construction, aspect handling, edge-guard ranking), the directive
end-to-end against a fake device, and `gesture-rotate`'s per-axis radii
and abort path.
2026-07-24 13:18:23 +02:00

191 lines
6.7 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
// Keep the real module (blueprints import from it too) but neutralise the
// fire-and-forget WebSocket send so no real socket is opened during the test.
vi.mock("../src/utils/simulator-client", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/utils/simulator-client")>()),
sendCommand: vi.fn(),
}));
import { gestureRotateTool } from "../src/tools/gesture-rotate";
import { sendCommand } from "../src/utils/simulator-client";
const udid = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const services = { simulatorServer: {} } as never;
// The physical screen the normalized coordinates land on in these assertions.
const SCREEN_W = 1080;
const SCREEN_H = 2400;
interface TouchCmd {
cmd: string;
type: "Down" | "Move" | "Up";
x: number;
y: number;
second_x: number | null;
second_y: number | null;
}
function touches(): TouchCmd[] {
return vi.mocked(sendCommand).mock.calls.map(([, cmd]) => cmd as unknown as TouchCmd);
}
beforeEach(() => {
// Reset (not just clear) so the abort test's mockImplementation can't leak.
vi.mocked(sendCommand).mockReset();
});
describe("gesture-rotate radiusX/radiusY", () => {
it("orbits a physical circle with constant finger separation when both are set", async () => {
// One physical radius, two normalizations: 0.4·1080 = 0.18·2400 = 432 px.
await gestureRotateTool.execute(services, {
udid,
centerX: 0.5,
centerY: 0.5,
radiusX: 0.4,
radiusY: 0.18,
startAngle: 0,
endAngle: 90,
durationMs: 48, // 3 steps → 4 frames, keeps the test fast
});
const frames = touches();
expect(frames).toHaveLength(4);
expect(frames[0]!.type).toBe("Down");
expect(frames[3]!.type).toBe("Up");
for (const f of frames) {
const px1 = f.x * SCREEN_W;
const py1 = f.y * SCREEN_H;
const px2 = f.second_x! * SCREEN_W;
const py2 = f.second_y! * SCREEN_H;
// Each finger stays 432 physical px from the center; separation 864.
expect(Math.hypot(px1 - 0.5 * SCREEN_W, py1 - 0.5 * SCREEN_H)).toBeCloseTo(432, 9);
expect(Math.hypot(px1 - px2, py1 - py2)).toBeCloseTo(864, 9);
}
});
it("keeps the legacy radius-only behavior byte-identical", async () => {
await gestureRotateTool.execute(services, {
udid,
centerX: 0.4,
centerY: 0.6,
radius: 0.15,
startAngle: 0,
endAngle: 90,
durationMs: 48,
});
const frames = touches();
expect(frames).toHaveLength(4);
frames.forEach((f, i) => {
// The pre-radiusX formula, replicated operation-for-operation so the
// expected values are bit-identical, not merely close.
const t = i / 3;
const angleDeg = 0 + (90 - 0) * t;
const angleRad = (angleDeg * Math.PI) / 180;
expect(f.x).toBe(0.4 + 0.15 * Math.cos(angleRad));
expect(f.y).toBe(0.6 + 0.15 * Math.sin(angleRad));
expect(f.second_x!).toBe(0.4 - 0.15 * Math.cos(angleRad));
expect(f.second_y!).toBe(0.6 - 0.15 * Math.sin(angleRad));
});
});
});
// Minimal evaluator for the presence-only JSON Schema subset the anyOf uses
// (required / anyOf / not), so the advertised schema can be checked semantically
// without pulling in a full validator.
interface PresenceConstraint {
required?: string[];
anyOf?: PresenceConstraint[];
not?: PresenceConstraint;
}
function satisfies(params: Record<string, unknown>, c: PresenceConstraint): boolean {
if (c.required && !c.required.every((key) => key in params)) return false;
if (c.anyOf && !c.anyOf.some((branch) => satisfies(params, branch))) return false;
if (c.not && satisfies(params, c.not)) return false;
return true;
}
describe("gesture-rotate inputSchema", () => {
it("keeps the radius trio optional at the top level, constrained only by the anyOf", () => {
const schema = gestureRotateTool.inputSchema!;
expect(schema.type).toBe("object");
const required = schema.required as string[];
expect(required).toEqual(
expect.arrayContaining(["udid", "centerX", "centerY", "startAngle", "endAngle"])
);
expect(required).not.toContain("radius");
expect(required).not.toContain("radiusX");
expect(required).not.toContain("radiusY");
expect(schema).not.toHaveProperty("$schema");
});
it("advertises exactly the radius shapes the zod refinements accept at runtime", () => {
const anyOf = gestureRotateTool.inputSchema!.anyOf as PresenceConstraint[];
const base = { udid, centerX: 0.5, centerY: 0.5, startAngle: 0, endAngle: 90 };
const shapes: Array<[radii: Record<string, number>, valid: boolean]> = [
[{ radius: 0.15 }, true],
[{ radiusX: 0.4, radiusY: 0.18 }, true],
[{ radius: 0.15, radiusX: 0.4, radiusY: 0.18 }, true],
[{}, false],
[{ radius: 0.15, radiusX: 0.4 }, false],
[{ radius: 0.15, radiusY: 0.18 }, false],
[{ radiusX: 0.4 }, false],
[{ radiusY: 0.18 }, false],
];
for (const [radii, valid] of shapes) {
const params = { ...base, ...radii };
expect(satisfies(params, { anyOf }), `advertised schema on ${JSON.stringify(radii)}`).toBe(
valid
);
expect(
gestureRotateTool.zodSchema!.safeParse(params).success,
`zod schema on ${JSON.stringify(radii)}`
).toBe(valid);
}
});
});
// The ctx-less path (execute called with no third argument, as in direct unit
// invocations) is covered by every test in the describe above.
describe("gesture-rotate abort", () => {
it("lifts the fingers where they are and rejects when aborted mid-gesture", async () => {
const controller = new AbortController();
// Abort synchronously from inside the send mock after the 3rd dispatched
// event — deterministic, no real-time races.
let sent = 0;
vi.mocked(sendCommand).mockImplementation(() => {
if (++sent === 3) controller.abort();
});
await expect(
gestureRotateTool.execute(
services,
{
udid,
centerX: 0.5,
centerY: 0.5,
radius: 0.15,
startAngle: 0,
endAngle: 90,
durationMs: 480, // 30 steps → 31 frames if run to completion
},
{ signal: controller.signal } as never
)
).rejects.toThrow(/gesture-rotate aborted/);
const frames = touches();
// Down + 2 Moves before the abort lands, then only the terminal Up.
expect(frames.map((f) => f.type)).toEqual(["Down", "Move", "Move", "Up"]);
// The lift happens at the last dispatched finger positions, not the arc end.
const last = frames[3]!;
const prev = frames[2]!;
expect(last.x).toBe(prev.x);
expect(last.y).toBe(prev.y);
expect(last.second_x).toBe(prev.second_x);
expect(last.second_y).toBe(prev.second_y);
});
});