mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Both hosts routed verbs themselves and both policies enumerated their own actions, and all four drifted. Web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a list row was reachable on native and unreachable on web; the model policy folded gestures its own way and could not reach what the seeded picker drew. A host now reports facts about every element and never decides which verb may act on it: targets.ts acceptsTarget owns that for both. pick.ts builtinCandidates is the single enumeration, and the model policy reads it through __sanderlingEnumerateBuiltin__ instead of reimplementing it in Go. Gesture verbs change with it: scrolls stay vertical over scrollable containers, swipes go free-form in all four directions from any element with real bounds. Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Pcg } from "../src/pcg.ts";
|
|
import { builtinCandidates, nextAction, walk } from "../src/pick.ts";
|
|
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
|
|
import { resetWarnings } from "../src/verbs.ts";
|
|
import type {
|
|
ActionDescriptor,
|
|
BuiltinVerb,
|
|
GeneratorNode,
|
|
Host,
|
|
TargetElement,
|
|
} from "../src/action-tree.ts";
|
|
import type { Direction, Point } from "../src/types.ts";
|
|
|
|
type Platform = "android" | "ios" | "web";
|
|
|
|
// stubHost returns a fixed target list, eligible for every verb, and records
|
|
// each reportUnsupported call so warn-once semantics are observable.
|
|
function stubHost(
|
|
platform: Platform,
|
|
targets: TargetElement[],
|
|
): Host & { unsupported: BuiltinVerb[] } {
|
|
const unsupported: BuiltinVerb[] = [];
|
|
return {
|
|
unsupported,
|
|
platform: () => platform,
|
|
queryTargets: () => targets,
|
|
reportUnsupported: (verb) => {
|
|
unsupported.push(verb);
|
|
},
|
|
seedHi: () => 0n,
|
|
seedLo: () => 0n,
|
|
};
|
|
}
|
|
|
|
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
|
|
|
const POINTS: TargetElement[] = [
|
|
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200, ...EVERY_FACT },
|
|
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200, ...EVERY_FACT },
|
|
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200, ...EVERY_FACT },
|
|
];
|
|
|
|
function builtin(verb: BuiltinVerb): GeneratorNode {
|
|
return { kind: "builtin", verb };
|
|
}
|
|
|
|
test("taps draws one candidate index and targets its point", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const rng = new Pcg(42n, 0n);
|
|
const oracle = new Pcg(42n, 0n);
|
|
const expectedIndex = oracle.intN(POINTS.length);
|
|
|
|
const action = walk(builtin("taps"), rng, host);
|
|
assert.deepEqual(action, {
|
|
kind: "Tap",
|
|
on: {
|
|
x: POINTS[expectedIndex]!.x,
|
|
y: POINTS[expectedIndex]!.y,
|
|
selector: POINTS[expectedIndex]!.selector,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("typing draws candidate index then corpus index, in that order", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const rng = new Pcg(42n, 0n);
|
|
const oracle = new Pcg(42n, 0n);
|
|
const candidateIndex = oracle.intN(POINTS.length);
|
|
const corpusIndex = oracle.intN(INPUT_CORPUS.length);
|
|
|
|
const action = walk(builtin("typing"), rng, host) as ActionDescriptor & {
|
|
kind: "InputText";
|
|
};
|
|
assert.equal(action.kind, "InputText");
|
|
assert.deepEqual(action.into, {
|
|
x: POINTS[candidateIndex]!.x,
|
|
y: POINTS[candidateIndex]!.y,
|
|
selector: POINTS[candidateIndex]!.selector,
|
|
});
|
|
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
|
});
|
|
|
|
// scrollCandidate mirrors what the picker builds for one (container, direction)
|
|
// pair: a drag opposite the named content motion, 40% of the container extent.
|
|
function scrollCandidate(target: TargetElement, direction: "down" | "up") {
|
|
const from = { x: target.x, y: target.y };
|
|
const extent = Math.trunc((4 * (target.height ?? 0)) / 10);
|
|
const toY = direction === "down" ? from.y - extent : from.y + extent;
|
|
return {
|
|
kind: "Scroll",
|
|
direction,
|
|
in: from,
|
|
from,
|
|
to: { x: from.x, y: Math.max(0, toY) },
|
|
};
|
|
}
|
|
|
|
// swipeCandidate mirrors the free-form drag: a raw pixel distance, with the
|
|
// direction naming where the finger travels.
|
|
function swipeCandidate(
|
|
target: Point,
|
|
direction: Direction,
|
|
magnitude: number,
|
|
) {
|
|
const from = { x: target.x, y: target.y };
|
|
const horizontal = direction === "left" || direction === "right";
|
|
const forward = direction === "down" || direction === "right";
|
|
const travel = forward ? magnitude : -magnitude;
|
|
return {
|
|
kind: "Swipe",
|
|
from,
|
|
to: horizontal
|
|
? { x: Math.max(0, from.x + travel), y: from.y }
|
|
: { x: from.x, y: Math.max(0, from.y + travel) },
|
|
durationMillis: 250,
|
|
};
|
|
}
|
|
|
|
const NOMINAL_SWIPE_MAGNITUDE = 400;
|
|
|
|
const SCROLL_DIRECTIONS = ["down", "up"] as const;
|
|
const SWIPE_DIRECTIONS = ["down", "up", "left", "right"] as const;
|
|
|
|
// swipeDirection recovers which way a drawn swipe travelled from its endpoints.
|
|
function swipeDirection(from: Point, to: Point): Direction {
|
|
if (to.x !== from.x) return to.x > from.x ? "right" : "left";
|
|
return to.y > from.y ? "down" : "up";
|
|
}
|
|
|
|
test("scrolls enumerate every container up and down only", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
assert.deepEqual(
|
|
builtinCandidates("scrolls", host),
|
|
POINTS.flatMap((target, targetIndex) =>
|
|
SCROLL_DIRECTIONS.map((direction) => ({
|
|
action: scrollCandidate(target, direction),
|
|
targetIndex,
|
|
})),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("swipes enumerate a free-form drag per target in all four directions", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
// The swipe candidate is its own action shape, sized in raw pixels rather than
|
|
// off the target's extent, and it carries what the policy needs to redraw the
|
|
// distance. It is not the scroll gesture under a second name.
|
|
assert.deepEqual(
|
|
builtinCandidates("swipes", host),
|
|
POINTS.flatMap((target, targetIndex) =>
|
|
SWIPE_DIRECTIONS.map((direction) => ({
|
|
action: swipeCandidate(target, direction, NOMINAL_SWIPE_MAGNITUDE),
|
|
targetIndex,
|
|
swipe: { origin: { x: target.x, y: target.y }, direction },
|
|
})),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("the gesture verbs differ in target filter and in direction set", () => {
|
|
resetWarnings();
|
|
// Same host, same targets: what separates the two verbs here is the direction
|
|
// set alone. Scrolls stay vertical because every scrollable container gets a
|
|
// candidate; swipes reach sideways because swipe-to-dismiss does.
|
|
const host = stubHost("android", POINTS);
|
|
const scrolls = builtinCandidates("scrolls", host);
|
|
const swipes = builtinCandidates("swipes", host);
|
|
|
|
const scrollDirections = new Set(
|
|
scrolls.map(
|
|
(entry) => (entry.action as ActionDescriptor & { kind: "Scroll" }).direction,
|
|
),
|
|
);
|
|
const swipeDirections = new Set(swipes.map((entry) => entry.swipe!.direction));
|
|
|
|
assert.deepEqual([...scrollDirections].sort(), ["down", "up"]);
|
|
assert.deepEqual([...swipeDirections].sort(), ["down", "left", "right", "up"]);
|
|
assert.equal(scrolls.length, POINTS.length * 2);
|
|
assert.equal(swipes.length, POINTS.length * 4);
|
|
});
|
|
|
|
test("scrolls draw one index over the enumerated candidates", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const enumerated = builtinCandidates("scrolls", host);
|
|
const oracle = new Pcg(99n, 0n);
|
|
const index = oracle.intN(enumerated.length);
|
|
|
|
const action = walk(builtin("scrolls"), new Pcg(99n, 0n), host);
|
|
assert.deepEqual(action, enumerated[index]!.action);
|
|
});
|
|
|
|
test("swipes draw candidate index then magnitude, in that order", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const enumerated = builtinCandidates("swipes", host);
|
|
const oracle = new Pcg(7n, 0n);
|
|
const index = oracle.intN(enumerated.length);
|
|
const magnitude = 200 + oracle.intN(401);
|
|
const picked = enumerated[index]!.swipe!;
|
|
|
|
const action = walk(builtin("swipes"), new Pcg(7n, 0n), host);
|
|
assert.deepEqual(
|
|
action,
|
|
swipeCandidate(picked.origin, picked.direction, magnitude),
|
|
);
|
|
});
|
|
|
|
test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
|
resetWarnings();
|
|
for (const verb of ["doubleTaps", "longPresses"] as const) {
|
|
const host = stubHost("android", POINTS);
|
|
const rng = new Pcg(123n, 0n);
|
|
const oracle = new Pcg(123n, 0n);
|
|
const index = oracle.intN(POINTS.length);
|
|
const action = walk(builtin(verb), rng, host);
|
|
const kind = verb === "doubleTaps" ? "DoubleTap" : "LongPress";
|
|
assert.deepEqual(action, {
|
|
kind,
|
|
on: {
|
|
x: POINTS[index]!.x,
|
|
y: POINTS[index]!.y,
|
|
selector: POINTS[index]!.selector,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
test("waitOnce emits a 500ms wait and draws nothing", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const rng = new Pcg(42n, 0n);
|
|
const before = rng.float64();
|
|
const after = rng.float64();
|
|
|
|
const fresh = new Pcg(42n, 0n);
|
|
fresh.float64();
|
|
const action = walk(builtin("waitOnce"), fresh, host);
|
|
const afterWait = fresh.float64();
|
|
|
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
|
// The wait consumed no draw, so the stream is identical with or without it.
|
|
assert.equal(afterWait, after);
|
|
assert.notEqual(before, after);
|
|
});
|
|
|
|
test("pressKeys enumerates the platform's whole key pool", () => {
|
|
resetWarnings();
|
|
for (const [platform, keys] of [
|
|
["android", NATIVE_PRESS_KEYS],
|
|
["web", WEB_PRESS_KEYS],
|
|
] as const) {
|
|
const enumerated = builtinCandidates("pressKeys", stubHost(platform, POINTS));
|
|
assert.deepEqual(
|
|
enumerated,
|
|
keys.map((key) => ({ action: { kind: "PressKey", key }, targetIndex: -1 })),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("pressKeys on native emits the only key without drawing", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const rng = new Pcg(42n, 0n);
|
|
const action = walk(builtin("pressKeys"), rng, host);
|
|
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[0] });
|
|
// One key is no choice, so the pool consumed no draw.
|
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
|
});
|
|
|
|
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
|
resetWarnings();
|
|
const host = stubHost("web", POINTS);
|
|
const rng = new Pcg(2024n, 0n);
|
|
const oracle = new Pcg(2024n, 0n);
|
|
const index = oracle.intN(WEB_PRESS_KEYS.length);
|
|
const action = walk(builtin("pressKeys"), rng, host);
|
|
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
|
|
});
|
|
|
|
test("an unsupported verb enumerates nothing and reports once", () => {
|
|
resetWarnings();
|
|
// No platform in the matrix declines a verb today, so the branch is reached
|
|
// through a platform the matrix has never heard of.
|
|
const host = stubHost("desktop" as Platform, POINTS);
|
|
assert.deepEqual(builtinCandidates("taps", host), []);
|
|
assert.deepEqual(builtinCandidates("taps", host), []);
|
|
assert.deepEqual(host.unsupported, ["taps"]);
|
|
});
|
|
|
|
test("the seeded pick is an index into the shared enumeration", () => {
|
|
resetWarnings();
|
|
// Whatever the verb, the drawn action is one of the enumerated entries: the
|
|
// seeded policy adds a choice, never an action the model policy cannot see.
|
|
// `typing` and `swipes` are covered separately, being the two verbs whose
|
|
// enumeration leaves one value for the policy to fill in.
|
|
const host = stubHost("android", POINTS);
|
|
for (const verb of [
|
|
"taps",
|
|
"doubleTaps",
|
|
"longPresses",
|
|
"scrolls",
|
|
"pressKeys",
|
|
"waitOnce",
|
|
] as const) {
|
|
const enumerated = builtinCandidates(verb, host).map((entry) =>
|
|
JSON.stringify(entry.action),
|
|
);
|
|
for (let seed = 1; seed <= 50; seed++) {
|
|
const action = walk(builtin(verb), new Pcg(BigInt(seed), 0n), host);
|
|
assert.ok(
|
|
enumerated.includes(JSON.stringify(action)),
|
|
`${verb} drew ${JSON.stringify(action)}, which is not an enumerated candidate`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("typing enumerates the field and the policy supplies the text", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const enumerated = builtinCandidates("typing", host);
|
|
// The candidate set names fields only: an empty text is the slot the seeded
|
|
// corpus draw and the model's own value both fill.
|
|
for (const entry of enumerated) {
|
|
assert.equal((entry.action as ActionDescriptor & { kind: "InputText" }).text, "");
|
|
}
|
|
const oracle = new Pcg(2024n, 0n);
|
|
const index = oracle.intN(enumerated.length);
|
|
const text = INPUT_CORPUS[oracle.intN(INPUT_CORPUS.length)];
|
|
assert.deepEqual(walk(builtin("typing"), new Pcg(2024n, 0n), host), {
|
|
...enumerated[index]!.action,
|
|
text,
|
|
});
|
|
});
|
|
|
|
test("swipes enumerate origin and direction, the policy adds distance", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const origins = new Set(
|
|
builtinCandidates("swipes", host).map((entry) =>
|
|
JSON.stringify([entry.swipe!.origin, entry.swipe!.direction]),
|
|
),
|
|
);
|
|
const drawn = new Set<Direction>();
|
|
for (let seed = 1; seed <= 200; seed++) {
|
|
const action = walk(builtin("swipes"), new Pcg(BigInt(seed), 0n), host) as
|
|
ActionDescriptor & { kind: "Swipe"; from: Point; to: Point };
|
|
assert.equal(action.kind, "Swipe");
|
|
const direction = swipeDirection(action.from, action.to);
|
|
drawn.add(direction);
|
|
assert.ok(
|
|
origins.has(JSON.stringify([action.from, direction])),
|
|
`swipe from ${JSON.stringify(action.from)} going ${direction} is not enumerated`,
|
|
);
|
|
// The drawn distance stays inside 200..600, clamped at the screen edge.
|
|
const horizontal = direction === "left" || direction === "right";
|
|
const distance = horizontal
|
|
? Math.abs(action.to.x - action.from.x)
|
|
: Math.abs(action.to.y - action.from.y);
|
|
const clamped = horizontal ? action.to.x === 0 : action.to.y === 0;
|
|
assert.ok(distance <= 600, `drag of ${distance}px exceeds the drawn range`);
|
|
assert.ok(
|
|
distance >= 200 || clamped,
|
|
`drag of ${distance}px is under the drawn range and not clamped`,
|
|
);
|
|
}
|
|
// Every enumerated direction is reachable by a draw, sideways included.
|
|
assert.deepEqual([...drawn].sort(), ["down", "left", "right", "up"]);
|
|
});
|
|
|
|
test("empty candidate list yields null without drawing", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", []);
|
|
const rng = new Pcg(42n, 0n);
|
|
assert.equal(walk(builtin("taps"), rng, host), null);
|
|
// No draw consumed: next float64 matches a fresh stream's first draw.
|
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
|
});
|
|
|
|
test("weighted: one float64 draw, ascending cumulative scan", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const tap: GeneratorNode = builtin("taps");
|
|
const wait: GeneratorNode = builtin("waitOnce");
|
|
// total = 3; draw = float64()*3 decides the branch by ascending cumulative.
|
|
const node: GeneratorNode = {
|
|
kind: "weighted",
|
|
branches: [
|
|
[1, tap],
|
|
[2, wait],
|
|
],
|
|
};
|
|
|
|
const rng = new Pcg(42n, 0n);
|
|
const oracle = new Pcg(42n, 0n);
|
|
const draw = oracle.float64() * 3;
|
|
const expectFirst = draw < 1; // cumulative after branch 0 is 1.
|
|
|
|
const action = walk(node, rng, host);
|
|
if (expectFirst) {
|
|
assert.equal((action as ActionDescriptor).kind, "Tap");
|
|
} else {
|
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
|
}
|
|
});
|
|
|
|
test("weighted: a zero-weight branch is never selected", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const poison: GeneratorNode = {
|
|
kind: "actions",
|
|
generate: () => {
|
|
throw new Error("zero-weight branch must never be walked");
|
|
},
|
|
};
|
|
const node: GeneratorNode = {
|
|
kind: "weighted",
|
|
branches: [
|
|
[0, poison],
|
|
[1, builtin("waitOnce")],
|
|
],
|
|
};
|
|
// Drive many seeds: the zero-weight branch must never be entered.
|
|
for (let seed = 1; seed <= 200; seed++) {
|
|
const action = walk(node, new Pcg(BigInt(seed), 0n), host);
|
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
|
|
}
|
|
});
|
|
|
|
test("weighted: all-zero (or negative) weights return null", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const node: GeneratorNode = {
|
|
kind: "weighted",
|
|
branches: [
|
|
[0, builtin("waitOnce")],
|
|
[-5, builtin("taps")],
|
|
],
|
|
};
|
|
assert.equal(walk(node, new Pcg(42n, 0n), host), null);
|
|
});
|
|
|
|
test("actions: single-element list draws nothing", () => {
|
|
resetWarnings();
|
|
const only: ActionDescriptor = { kind: "Wait", durationMillis: 10 };
|
|
const node: GeneratorNode = { kind: "actions", generate: () => [only] };
|
|
const rng = new Pcg(42n, 0n);
|
|
const action = walk(node, rng, stubHost("android", POINTS));
|
|
assert.deepEqual(action, only);
|
|
// No draw consumed for a singleton list.
|
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
|
});
|
|
|
|
test("actions: multi-element list draws one intN(len) index", () => {
|
|
resetWarnings();
|
|
const list: ActionDescriptor[] = [
|
|
{ kind: "Wait", durationMillis: 1 },
|
|
{ kind: "Wait", durationMillis: 2 },
|
|
{ kind: "Wait", durationMillis: 3 },
|
|
];
|
|
const node: GeneratorNode = { kind: "actions", generate: () => [...list] };
|
|
const rng = new Pcg(42n, 0n);
|
|
const oracle = new Pcg(42n, 0n);
|
|
const index = oracle.intN(list.length);
|
|
assert.deepEqual(walk(node, rng, stubHost("android", POINTS)), list[index]);
|
|
});
|
|
|
|
test("actions: empty list yields null", () => {
|
|
resetWarnings();
|
|
const node: GeneratorNode = { kind: "actions", generate: () => [] };
|
|
assert.equal(walk(node, new Pcg(42n, 0n), stubHost("android", POINTS)), null);
|
|
});
|
|
|
|
test("corpus reproducibility: same seed yields the same typed text", () => {
|
|
resetWarnings();
|
|
const host = stubHost("android", POINTS);
|
|
const first = walk(builtin("typing"), new Pcg(2024n, 0n), host) as ActionDescriptor & {
|
|
kind: "InputText";
|
|
};
|
|
const second = walk(builtin("typing"), new Pcg(2024n, 0n), host) as ActionDescriptor & {
|
|
kind: "InputText";
|
|
};
|
|
assert.equal(first.text, second.text);
|
|
assert.ok(INPUT_CORPUS.includes(first.text));
|
|
});
|
|
|
|
test("nextAction retries up to 16 times then returns null", () => {
|
|
resetWarnings();
|
|
// An empty weighted tree always returns null; nextAction must give up, not loop.
|
|
const empty: GeneratorNode = { kind: "weighted", branches: [] };
|
|
assert.equal(nextAction(empty, new Pcg(42n, 0n), stubHost("android", POINTS)), null);
|
|
});
|
|
|
|
test("nextAction returns the first non-null walk result", () => {
|
|
resetWarnings();
|
|
let calls = 0;
|
|
// First two walks yield null (empty list), third yields an action. nextAction
|
|
// should return on the third attempt.
|
|
const node: GeneratorNode = {
|
|
kind: "actions",
|
|
generate: () => {
|
|
calls++;
|
|
return calls >= 3 ? [{ kind: "Wait", durationMillis: 5 }] : [];
|
|
},
|
|
};
|
|
const action = nextAction(node, new Pcg(42n, 0n), stubHost("android", POINTS));
|
|
assert.deepEqual(action, { kind: "Wait", durationMillis: 5 });
|
|
assert.equal(calls, 3);
|
|
});
|