mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
refactor(spec): picker emits native selector + scroll endpoints, setup precedence
This commit is contained in:
1 parent
6600994ef1
commit
7a5fc9aca5
5 files changed
+161
-12
No files matched your search
@@ -44,7 +44,10 @@ export type ActionDescriptor =
|
|||||||
| { kind: "Tap"; on: Target }
|
| { kind: "Tap"; on: Target }
|
||||||
| { kind: "DoubleTap"; on: Target }
|
| { kind: "DoubleTap"; on: Target }
|
||||||
| { kind: "LongPress"; on: Target }
|
| { kind: "LongPress"; on: Target }
|
||||||
| { kind: "Scroll"; direction: Direction; in?: Target }
|
// Scroll carries the author target (in) for spec-written scrolls; the builtin
|
||||||
|
// generator additionally pre-computes the swipe from/to so the wire contract
|
||||||
|
// carries a real gesture rather than a zero-length one.
|
||||||
|
| { kind: "Scroll"; direction: Direction; in?: Target; from?: Target; to?: Target }
|
||||||
| { kind: "InputText"; into: Target; text: string }
|
| { kind: "InputText"; into: Target; text: string }
|
||||||
| { kind: "Swipe"; from: Target; to: Target; durationMillis?: number }
|
| { kind: "Swipe"; from: Target; to: Target; durationMillis?: number }
|
||||||
| { kind: "PressKey"; key: string }
|
| { kind: "PressKey"; key: string }
|
||||||
|
|||||||
+57
-5
@@ -142,24 +142,76 @@ function walkBuiltin(
|
|||||||
|
|
||||||
switch (verb) {
|
switch (verb) {
|
||||||
case "taps":
|
case "taps":
|
||||||
return { kind: "Tap", on: point };
|
return tapDescriptor("Tap", point, picked.selector);
|
||||||
case "doubleTaps":
|
case "doubleTaps":
|
||||||
return { kind: "DoubleTap", on: point };
|
return tapDescriptor("DoubleTap", point, picked.selector);
|
||||||
case "longPresses":
|
case "longPresses":
|
||||||
return { kind: "LongPress", on: point };
|
return tapDescriptor("LongPress", point, picked.selector);
|
||||||
case "typing": {
|
case "typing": {
|
||||||
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
|
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
|
||||||
return { kind: "InputText", into: point, text };
|
return { kind: "InputText", into: withSelector(point, picked.selector), text };
|
||||||
}
|
}
|
||||||
case "swipes":
|
case "swipes":
|
||||||
return buildSwipe(point, rng);
|
return buildSwipe(point, rng);
|
||||||
case "scrolls": {
|
case "scrolls": {
|
||||||
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
|
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
|
||||||
return { kind: "Scroll", direction, in: point };
|
return buildScroll(point, direction, picked, rng);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withSelector attaches a native selector to a resolved Point so the runner can
|
||||||
|
// re-resolve the target by id/text. The web host omits it (point-only).
|
||||||
|
function withSelector(point: Point, selector?: string): Point {
|
||||||
|
if (selector === undefined) return point;
|
||||||
|
return { ...point, selector } as Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tapDescriptor(
|
||||||
|
kind: "Tap" | "DoubleTap" | "LongPress",
|
||||||
|
point: Point,
|
||||||
|
selector?: string,
|
||||||
|
): ActionDescriptor {
|
||||||
|
return { kind, on: withSelector(point, selector) } as ActionDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScroll lowers a scroll to a swipe over the container, matching
|
||||||
|
// worker.go's geometry: the gesture drags opposite the named content motion,
|
||||||
|
// magnitude 40% of the container extent. Missing width/height (web root) yields
|
||||||
|
// a zero-length endpoint, which the runner re-derives from container bounds.
|
||||||
|
function buildScroll(
|
||||||
|
from: Point,
|
||||||
|
direction: Direction,
|
||||||
|
candidate: { width?: number; height?: number },
|
||||||
|
_rng: Pcg,
|
||||||
|
): ActionDescriptor {
|
||||||
|
const width = candidate.width ?? 0;
|
||||||
|
const height = candidate.height ?? 0;
|
||||||
|
let toX = from.x;
|
||||||
|
let toY = from.y;
|
||||||
|
switch (direction) {
|
||||||
|
case "down":
|
||||||
|
toY = from.y - Math.trunc((4 * height) / 10);
|
||||||
|
break;
|
||||||
|
case "up":
|
||||||
|
toY = from.y + Math.trunc((4 * height) / 10);
|
||||||
|
break;
|
||||||
|
case "left":
|
||||||
|
toX = from.x + Math.trunc((4 * width) / 10);
|
||||||
|
break;
|
||||||
|
case "right":
|
||||||
|
toX = from.x - Math.trunc((4 * width) / 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction,
|
||||||
|
in: from,
|
||||||
|
from,
|
||||||
|
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
||||||
|
} as ActionDescriptor;
|
||||||
|
}
|
||||||
|
|
||||||
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
|
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
|
||||||
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
||||||
if (keys.length === 0) return null;
|
if (keys.length === 0) return null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// identical action stream by construction.
|
// identical action stream by construction.
|
||||||
|
|
||||||
import { Pcg } from "./pcg.ts";
|
import { Pcg } from "./pcg.ts";
|
||||||
import { nextAction } from "./pick.ts";
|
import { nextAction, walk } from "./pick.ts";
|
||||||
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
|
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
|
||||||
import type { Point } from "./types.ts";
|
import type { Point } from "./types.ts";
|
||||||
|
|
||||||
@@ -82,15 +82,18 @@ export function serializeAction(action: ActionDescriptor | null): SerializedActi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "Scroll": {
|
case "Scroll": {
|
||||||
const from = pointOf(action.in);
|
const from = pointOf(action.from) ?? pointOf(action.in);
|
||||||
if (!from) return null;
|
if (!from) return null;
|
||||||
|
// The builtin generator pre-computes `to`; an author Scroll without it
|
||||||
|
// collapses to a zero-length gesture the runner re-derives from bounds.
|
||||||
|
const to = pointOf(action.to) ?? from;
|
||||||
return {
|
return {
|
||||||
kind: "Scroll",
|
kind: "Scroll",
|
||||||
direction: action.direction,
|
direction: action.direction,
|
||||||
fromX: from.x,
|
fromX: from.x,
|
||||||
fromY: from.y,
|
fromY: from.y,
|
||||||
toX: from.x,
|
toX: to.x,
|
||||||
toY: from.y,
|
toY: to.y,
|
||||||
durationMillis: DEFAULT_SWIPE_DURATION,
|
durationMillis: DEFAULT_SWIPE_DURATION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,11 @@ export function serializeAction(action: ActionDescriptor | null): SerializedActi
|
|||||||
// AFTER this call (the web bundle imports the runtime before the spec, so the
|
// AFTER this call (the web bundle imports the runtime before the spec, so the
|
||||||
// root only exists on globalThis.actions once the spec has evaluated).
|
// root only exists on globalThis.actions once the spec has evaluated).
|
||||||
// evaluateExtractors is the engine's snapshot of the spec's extract() handles.
|
// evaluateExtractors is the engine's snapshot of the spec's extract() handles.
|
||||||
|
//
|
||||||
|
// Setup precedence: when the spec assigned globalThis.setup, it is walked ONCE
|
||||||
|
// per tick first; if it yields an action that wins, otherwise the call falls
|
||||||
|
// through to the action root's 16-attempt retry. This matches the native
|
||||||
|
// verifier's prior NextAction precedence and applies on both engines.
|
||||||
export function installRuntime(
|
export function installRuntime(
|
||||||
host: Host,
|
host: Host,
|
||||||
root: GeneratorNode | null | (() => GeneratorNode | null),
|
root: GeneratorNode | null | (() => GeneratorNode | null),
|
||||||
@@ -113,9 +121,18 @@ export function installRuntime(
|
|||||||
): void {
|
): void {
|
||||||
const rng = new Pcg(host.seedHi(), host.seedLo());
|
const rng = new Pcg(host.seedHi(), host.seedLo());
|
||||||
const resolveRoot = typeof root === "function" ? root : () => root;
|
const resolveRoot = typeof root === "function" ? root : () => root;
|
||||||
|
const resolveSetup = () =>
|
||||||
|
(globalThis as { setup?: GeneratorNode }).setup ?? null;
|
||||||
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
||||||
defineLockedGlobal("__sanderlingNextAction__", () => {
|
defineLockedGlobal("__sanderlingNextAction__", () => {
|
||||||
|
// resolveRoot runs first: on web it also resets the per-tick candidate
|
||||||
|
// cache, which setup's walk below must see fresh.
|
||||||
const current = resolveRoot();
|
const current = resolveRoot();
|
||||||
|
const setup = resolveSetup();
|
||||||
|
if (setup) {
|
||||||
|
const setupAction = walk(setup, rng, host);
|
||||||
|
if (setupAction) return serializeAction(setupAction);
|
||||||
|
}
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
return serializeAction(nextAction(current, rng, host));
|
return serializeAction(nextAction(current, rng, host));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ test("taps draws one candidate index and targets its point", () => {
|
|||||||
const action = walk(builtin("taps"), rng, host);
|
const action = walk(builtin("taps"), rng, host);
|
||||||
assert.deepEqual(action, {
|
assert.deepEqual(action, {
|
||||||
kind: "Tap",
|
kind: "Tap",
|
||||||
on: { x: POINTS[expectedIndex]!.x, y: POINTS[expectedIndex]!.y },
|
on: {
|
||||||
|
x: POINTS[expectedIndex]!.x,
|
||||||
|
y: POINTS[expectedIndex]!.y,
|
||||||
|
selector: POINTS[expectedIndex]!.selector,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ test("typing draws candidate index then corpus index, in that order", () => {
|
|||||||
assert.deepEqual(action.into, {
|
assert.deepEqual(action.into, {
|
||||||
x: POINTS[candidateIndex]!.x,
|
x: POINTS[candidateIndex]!.x,
|
||||||
y: POINTS[candidateIndex]!.y,
|
y: POINTS[candidateIndex]!.y,
|
||||||
|
selector: POINTS[candidateIndex]!.selector,
|
||||||
});
|
});
|
||||||
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
||||||
});
|
});
|
||||||
@@ -142,7 +147,11 @@ test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
|||||||
const kind = verb === "doubleTaps" ? "DoubleTap" : "LongPress";
|
const kind = verb === "doubleTaps" ? "DoubleTap" : "LongPress";
|
||||||
assert.deepEqual(action, {
|
assert.deepEqual(action, {
|
||||||
kind,
|
kind,
|
||||||
on: { x: POINTS[index]!.x, y: POINTS[index]!.y },
|
on: {
|
||||||
|
x: POINTS[index]!.x,
|
||||||
|
y: POINTS[index]!.y,
|
||||||
|
selector: POINTS[index]!.selector,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,7 +98,75 @@ test("PressKey and Wait pass through their fields", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("builtin Scroll carries the pre-computed to endpoint", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
serializeAction({
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
in: { x: 100, y: 200 },
|
||||||
|
from: { x: 100, y: 200 },
|
||||||
|
to: { x: 100, y: 120 },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
kind: "Scroll",
|
||||||
|
direction: "down",
|
||||||
|
fromX: 100,
|
||||||
|
fromY: 200,
|
||||||
|
toX: 100,
|
||||||
|
toY: 120,
|
||||||
|
durationMillis: 250,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("an unresolved (string) target drops the action", () => {
|
test("an unresolved (string) target drops the action", () => {
|
||||||
assert.equal(serializeAction({ kind: "Tap", on: "id:never-resolved" }), null);
|
assert.equal(serializeAction({ kind: "Tap", on: "id:never-resolved" }), null);
|
||||||
assert.equal(serializeAction(null), null);
|
assert.equal(serializeAction(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire round-trip: serialize -> JSON -> parse asserts every field the Go
|
||||||
|
// decodeAction reads survives by its exact camelCase name. A rename (fromX vs
|
||||||
|
// from_x) would silently turn web/native actions into no-ops; this catches it.
|
||||||
|
test("serialized actions JSON round-trip with the decoder's field names", () => {
|
||||||
|
const cases: ActionDescriptor[] = [
|
||||||
|
{ kind: "Tap", on: { x: 1, y: 2, selector: "id:a" } as never },
|
||||||
|
{ kind: "InputText", into: { x: 3, y: 4 } as never, text: "x" },
|
||||||
|
{ kind: "Swipe", from: { x: 5, y: 6 }, to: { x: 7, y: 8 }, durationMillis: 9 },
|
||||||
|
{ kind: "Scroll", direction: "up", in: { x: 1, y: 1 } as never },
|
||||||
|
{ kind: "PressKey", key: "enter" },
|
||||||
|
{ kind: "Wait", durationMillis: 10 },
|
||||||
|
];
|
||||||
|
for (const descriptor of cases) {
|
||||||
|
const wire = serializeAction(descriptor);
|
||||||
|
assert.ok(wire, `expected ${descriptor.kind} to serialize`);
|
||||||
|
const decoded = JSON.parse(JSON.stringify(wire)) as Record<string, unknown>;
|
||||||
|
assert.equal(decoded.kind, wire!.kind);
|
||||||
|
switch (wire!.kind) {
|
||||||
|
case "Tap":
|
||||||
|
assert.equal(typeof decoded.x, "number");
|
||||||
|
assert.equal(typeof decoded.y, "number");
|
||||||
|
assert.equal(decoded.selector, "id:a");
|
||||||
|
break;
|
||||||
|
case "InputText":
|
||||||
|
assert.equal(typeof decoded.x, "number");
|
||||||
|
assert.equal(decoded.text, "x");
|
||||||
|
break;
|
||||||
|
case "Swipe":
|
||||||
|
assert.equal(decoded.fromX, 5);
|
||||||
|
assert.equal(decoded.toY, 8);
|
||||||
|
assert.equal(decoded.durationMillis, 9);
|
||||||
|
break;
|
||||||
|
case "Scroll":
|
||||||
|
assert.equal(decoded.direction, "up");
|
||||||
|
assert.equal(typeof decoded.fromX, "number");
|
||||||
|
assert.equal(typeof decoded.toX, "number");
|
||||||
|
break;
|
||||||
|
case "PressKey":
|
||||||
|
assert.equal(decoded.key, "enter");
|
||||||
|
break;
|
||||||
|
case "Wait":
|
||||||
|
assert.equal(decoded.durationMillis, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user