mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6a53e66a96
# 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.
191 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|