mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor(spec): one candidate producer over one target-eligibility rule
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
This commit is contained in:
1 parent
ef04a6de9d
commit
b5f64bf665
23 files changed
+1494
-594
No files matched your search
@@ -15,10 +15,14 @@ import type {
|
||||
function installRuntime(initialState: State): void {
|
||||
const state = { current: initialState };
|
||||
const runtime: SanderlingRuntime = {
|
||||
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
|
||||
current: getter(state.current),
|
||||
previous: undefined,
|
||||
}),
|
||||
extract: <T>(getter: (s: State) => T): Extracted<T> => {
|
||||
const handle: Extracted<T> = {
|
||||
current: getter(state.current),
|
||||
previous: undefined,
|
||||
named: () => handle,
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
always: () => ({ __sanderlingFormula: true } as Formula),
|
||||
now: () => ({ __sanderlingFormula: true } as Formula),
|
||||
next: () => ({ __sanderlingFormula: true } as Formula),
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"taps": [
|
||||
"save"
|
||||
],
|
||||
"doubleTaps": [
|
||||
"save"
|
||||
],
|
||||
"longPresses": [
|
||||
"save"
|
||||
],
|
||||
"typing": [
|
||||
"amount"
|
||||
],
|
||||
"scrolls": [
|
||||
"root",
|
||||
"root",
|
||||
"list",
|
||||
"list"
|
||||
],
|
||||
"swipes": [
|
||||
"root",
|
||||
"root",
|
||||
"root",
|
||||
"root",
|
||||
"save",
|
||||
"save",
|
||||
"save",
|
||||
"save",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"amount",
|
||||
"amount",
|
||||
"amount",
|
||||
"amount",
|
||||
"list",
|
||||
"list",
|
||||
"list",
|
||||
"list",
|
||||
"row",
|
||||
"row",
|
||||
"row",
|
||||
"row"
|
||||
],
|
||||
"pressKeys": [],
|
||||
"waitOnce": []
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { builtinCandidates } from "../src/pick.ts";
|
||||
import { resetWarnings } from "../src/verbs.ts";
|
||||
import type { BuiltinVerb } from "../src/action-tree.ts";
|
||||
import { fakeElement, withFakeDocument, type FakeElementSpec } from "./web-dom-harness.ts";
|
||||
import { __testing__ } from "../src/web-runtime.ts";
|
||||
|
||||
const { host } = __testing__;
|
||||
|
||||
const goldenPath = fileURLToPath(new URL("./fixtures/host-parity-golden.json", import.meta.url));
|
||||
const golden: Record<string, string[]> = JSON.parse(readFileSync(goldenPath, "utf8"));
|
||||
|
||||
const VERBS: BuiltinVerb[] = [
|
||||
"taps",
|
||||
"doubleTaps",
|
||||
"longPresses",
|
||||
"typing",
|
||||
"scrolls",
|
||||
"swipes",
|
||||
"pressKeys",
|
||||
"waitOnce",
|
||||
];
|
||||
|
||||
// SCREEN is the canonical screen both hosts are driven over: one row per fact
|
||||
// combination that any verb distinguishes. The native host builds the same rows,
|
||||
// in the same order, as a hierarchy tree in
|
||||
// internal/verifier/host_parity_test.go.
|
||||
const SCREEN: (FakeElementSpec & { name: string })[] = [
|
||||
{ name: "root", tag: "html", x: 0, y: 0, width: 400, height: 800, overflows: true },
|
||||
{ name: "save", tag: "button", x: 0, y: 0, width: 200, height: 60, clickable: true },
|
||||
{
|
||||
name: "cancel",
|
||||
tag: "button",
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 60,
|
||||
clickable: true,
|
||||
disabled: true,
|
||||
},
|
||||
{ name: "amount", tag: "input", x: 0, y: 100, width: 400, height: 60, editable: true },
|
||||
{ name: "list", tag: "div", x: 0, y: 200, width: 400, height: 400, overflows: true },
|
||||
{ name: "row", tag: "div", x: 0, y: 600, width: 400, height: 80 },
|
||||
{ name: "collapsed", tag: "button", x: 0, y: 0, width: 0, height: 0, clickable: true },
|
||||
];
|
||||
|
||||
// The web host and the native host used to route verbs themselves and had
|
||||
// drifted: web sent `swipes` to scrollable containers only, so a swipe on a list
|
||||
// row was reachable on Android and unreachable on web for the same spec.
|
||||
//
|
||||
// Per-verb eligibility now has ONE definition (src/targets.ts); a host reports
|
||||
// facts and never filters. This test is what notices if a second definition
|
||||
// grows back on either side. internal/verifier/host_parity_test.go asserts the
|
||||
// SAME golden from the native host, so a match on both sides proves the two
|
||||
// hosts agree without either invoking the other.
|
||||
test("web host targets match the cross-host golden, verb for verb", () => {
|
||||
const elements = SCREEN.map(fakeElement);
|
||||
withFakeDocument(elements, () => {
|
||||
for (const verb of VERBS) {
|
||||
resetWarnings();
|
||||
const candidates = builtinCandidates(verb, host);
|
||||
assert.notEqual(candidates.length, 0, `${verb} enumerated nothing at all`);
|
||||
const named = candidates
|
||||
.filter((candidate) => candidate.targetIndex >= 0)
|
||||
.map((candidate) => SCREEN[candidate.targetIndex]!.name);
|
||||
assert.deepEqual(named, golden[verb], `web host targets for ${verb}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list,
|
||||
// Shared cross-runtime parity scenario: the FIXED seed, FIXED target list,
|
||||
// and FIXED action root that both the node test (parity.test.ts) and the goja
|
||||
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
|
||||
// pick.ts over the SAME Pcg and asserts the SAME committed golden
|
||||
// (fixtures/parity-golden.json); matching one golden on both sides proves the
|
||||
// two engines agree without either invoking the other.
|
||||
//
|
||||
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
||||
// The target ORDER and the per-tick PCG draw order are the parity contract.
|
||||
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
|
||||
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
|
||||
// builtin, and the input corpus together; reordering candidates or adding or
|
||||
@@ -15,25 +15,29 @@ import { Pcg } from "../src/pcg.ts";
|
||||
import { nextAction } from "../src/pick.ts";
|
||||
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
|
||||
import { taps, typing, weighted } from "../src/actions.ts";
|
||||
import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts";
|
||||
import type { GeneratorNode, Host, TargetElement } from "../src/action-tree.ts";
|
||||
|
||||
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
|
||||
export const PARITY_STEPS = 20;
|
||||
|
||||
export const PARITY_CANDIDATES: Candidate[] = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
||||
// Every fact is set so the shared eligibility rule admits all three targets for
|
||||
// every verb, leaving the draw order as the only variable.
|
||||
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||
|
||||
export const PARITY_TARGETS: TargetElement[] = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...EVERY_FACT },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...EVERY_FACT },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...EVERY_FACT },
|
||||
];
|
||||
|
||||
// A 3:1 weighted split over taps and typing. The stub host returns the same
|
||||
// candidate list for every verb so the only variables are the draw order and
|
||||
// the JS engine's number/bigint behavior.
|
||||
// A 3:1 weighted split over taps and typing. The stub host offers the same
|
||||
// target list to every verb so the only variables are the draw order and the JS
|
||||
// engine's number/bigint behavior.
|
||||
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
|
||||
|
||||
const HOST: Host = {
|
||||
platform: () => "android",
|
||||
queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES,
|
||||
queryTargets: () => PARITY_TARGETS,
|
||||
reportUnsupported: () => {},
|
||||
seedHi: () => PARITY_SEED_HI,
|
||||
seedLo: () => 0n,
|
||||
|
||||
+241
-60
@@ -1,30 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Pcg } from "../src/pcg.ts";
|
||||
import { nextAction, walk } from "../src/pick.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,
|
||||
Candidate,
|
||||
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 candidate list for every verb and records each
|
||||
// reportUnsupported call so warn-once semantics are observable.
|
||||
// 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,
|
||||
candidates: Candidate[],
|
||||
targets: TargetElement[],
|
||||
): Host & { unsupported: BuiltinVerb[] } {
|
||||
const unsupported: BuiltinVerb[] = [];
|
||||
return {
|
||||
unsupported,
|
||||
platform: () => platform,
|
||||
queryCandidates: () => candidates,
|
||||
queryTargets: () => targets,
|
||||
reportUnsupported: (verb) => {
|
||||
unsupported.push(verb);
|
||||
},
|
||||
@@ -33,10 +34,12 @@ function stubHost(
|
||||
};
|
||||
}
|
||||
|
||||
const POINTS: Candidate[] = [
|
||||
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200 },
|
||||
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200 },
|
||||
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200 },
|
||||
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 {
|
||||
@@ -81,59 +84,132 @@ test("typing draws candidate index then corpus index, in that order", () => {
|
||||
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
||||
});
|
||||
|
||||
test("swipes draw candidate, magnitude (200+intN(401)), then direction", () => {
|
||||
// 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);
|
||||
const rng = new Pcg(7n, 0n);
|
||||
const oracle = new Pcg(7n, 0n);
|
||||
const candidateIndex = oracle.intN(POINTS.length);
|
||||
const magnitude = 200 + oracle.intN(401);
|
||||
const direction = oracle.intN(4);
|
||||
|
||||
const from = { x: POINTS[candidateIndex]!.x, y: POINTS[candidateIndex]!.y };
|
||||
const expectedTo = { x: from.x, y: from.y };
|
||||
switch (direction) {
|
||||
case 0:
|
||||
expectedTo.y = Math.max(0, from.y - magnitude);
|
||||
break;
|
||||
case 1:
|
||||
expectedTo.y = Math.max(0, from.y + magnitude);
|
||||
break;
|
||||
case 2:
|
||||
expectedTo.x = Math.max(0, from.x - magnitude);
|
||||
break;
|
||||
case 3:
|
||||
expectedTo.x = Math.max(0, from.x + magnitude);
|
||||
break;
|
||||
}
|
||||
|
||||
const action = walk(builtin("swipes"), rng, host) as ActionDescriptor & {
|
||||
kind: "Swipe";
|
||||
};
|
||||
assert.equal(action.kind, "Swipe");
|
||||
assert.deepEqual(action.from, from);
|
||||
assert.deepEqual(action.to, expectedTo);
|
||||
assert.equal(action.durationMillis, 250);
|
||||
assert.deepEqual(
|
||||
builtinCandidates("scrolls", host),
|
||||
POINTS.flatMap((target, targetIndex) =>
|
||||
SCROLL_DIRECTIONS.map((direction) => ({
|
||||
action: scrollCandidate(target, direction),
|
||||
targetIndex,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolls draw candidate index then direction", () => {
|
||||
test("swipes enumerate a free-form drag per target in all four directions", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const rng = new Pcg(99n, 0n);
|
||||
const oracle = new Pcg(99n, 0n);
|
||||
const candidateIndex = oracle.intN(POINTS.length);
|
||||
const directionIndex = oracle.intN(4);
|
||||
const directions = ["up", "down", "left", "right"] as const;
|
||||
// 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 },
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const action = walk(builtin("scrolls"), rng, host) as ActionDescriptor & {
|
||||
kind: "Scroll";
|
||||
};
|
||||
assert.equal(action.kind, "Scroll");
|
||||
assert.equal(action.direction, directions[directionIndex]);
|
||||
assert.deepEqual(action.in, {
|
||||
x: POINTS[candidateIndex]!.x,
|
||||
y: POINTS[candidateIndex]!.y,
|
||||
});
|
||||
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", () => {
|
||||
@@ -174,14 +250,28 @@ test("waitOnce emits a 500ms wait and draws nothing", () => {
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
|
||||
test("pressKeys on native draws from NATIVE_PRESS_KEYS", () => {
|
||||
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 oracle = new Pcg(42n, 0n);
|
||||
const index = oracle.intN(NATIVE_PRESS_KEYS.length);
|
||||
const action = walk(builtin("pressKeys"), rng, host);
|
||||
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[index] });
|
||||
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", () => {
|
||||
@@ -194,6 +284,97 @@ test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
||||
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", []);
|
||||
|
||||
@@ -19,7 +19,7 @@ function countingHost(platform: "android" | "ios" | "web"): Host & { calls: Buil
|
||||
return {
|
||||
calls,
|
||||
platform: () => platform,
|
||||
queryCandidates: () => [],
|
||||
queryTargets: () => [],
|
||||
reportUnsupported: (verb) => {
|
||||
calls.push(verb);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// A minimal stand-in for the DOM surface the web host reads, shared by the web
|
||||
// runtime's own tests and the cross-host eligibility test. The host asks the
|
||||
// document for three things -- every element, the tappable set, the editable set
|
||||
// -- and reads geometry, `disabled` and the scroll extents off each element, so
|
||||
// that is all a fake has to answer.
|
||||
|
||||
import { __testing__ } from "../src/web-runtime.ts";
|
||||
|
||||
const { TAPPABLE_SELECTOR, EDITABLE_SELECTOR } = __testing__;
|
||||
|
||||
export interface FakeElementSpec {
|
||||
tag: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// clickable/editable place the element in the selector sets the host queries;
|
||||
// the fake answers those queries directly rather than matching CSS.
|
||||
clickable?: boolean;
|
||||
editable?: boolean;
|
||||
disabled?: boolean;
|
||||
// overflows makes the element's content taller than its box, which is how the
|
||||
// host decides an element is scrollable.
|
||||
overflows?: boolean;
|
||||
}
|
||||
|
||||
export interface FakeElement extends FakeElementSpec {
|
||||
tagName: string;
|
||||
type: string;
|
||||
isContentEditable: boolean;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
getBoundingClientRect(): {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||
const editable = spec.editable ?? false;
|
||||
return {
|
||||
...spec,
|
||||
tagName: spec.tag.toUpperCase(),
|
||||
type: spec.tag === "input" ? "text" : "",
|
||||
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||
clientHeight: spec.height,
|
||||
scrollWidth: spec.width,
|
||||
clientWidth: spec.width,
|
||||
getBoundingClientRect: () => ({
|
||||
left: spec.x,
|
||||
top: spec.y,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
right: spec.x + spec.width,
|
||||
bottom: spec.y + spec.height,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// withFakeDocument installs a document answering the host's three queries over
|
||||
// `elements`, resets the host's per-tick cache, and restores the real document
|
||||
// afterwards.
|
||||
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
const original = global.document;
|
||||
const answers: Record<string, FakeElement[]> = {
|
||||
"*": elements,
|
||||
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
|
||||
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
};
|
||||
__testing__.resetTargetCache();
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
__testing__.resetTargetCache();
|
||||
global.document = original;
|
||||
}
|
||||
}
|
||||
@@ -83,78 +83,60 @@ test("installRuntime defined the host-invoked globals", () => {
|
||||
assert.equal(typeof g.__sanderling__, "object");
|
||||
});
|
||||
|
||||
// A button and a text input, each with a deterministic bounding box, exercise
|
||||
// the per-verb selector routing without a full DOM.
|
||||
function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
disabled: false,
|
||||
isContentEditable: false,
|
||||
type: tag === "input" ? "text" : "",
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
scrollWidth: 0,
|
||||
clientWidth: 0,
|
||||
getBoundingClientRect: () => ({
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
right: rect.x + rect.w,
|
||||
bottom: rect.y + rect.h,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
|
||||
|
||||
function withFakeDocument(map: Record<string, unknown[]>, run: () => void) {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const original = g.document;
|
||||
g.document = {
|
||||
querySelectorAll: (selector: string) => map[selector] ?? [],
|
||||
scrollingElement: null,
|
||||
documentElement: null,
|
||||
};
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
g.document = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("queryCandidates routes taps to the tappable selector set", () => {
|
||||
const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 });
|
||||
withFakeDocument(
|
||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [button] },
|
||||
() => {
|
||||
__testing__.resetCandidateCache();
|
||||
const candidates = host.queryCandidates("taps");
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("queryCandidates routes typing to editable inputs only", () => {
|
||||
const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 });
|
||||
withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => {
|
||||
__testing__.resetCandidateCache();
|
||||
const candidates = host.queryCandidates("typing");
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 });
|
||||
// The host reports facts and never routes verbs: which of these a verb may act
|
||||
// on is decided by the shared rule in src/targets.ts, exercised across both
|
||||
// engines by host-parity.test.ts.
|
||||
test("queryTargets reports the tappable selector set as clickable", () => {
|
||||
const button = fakeElement({ tag: "button", x: 10, y: 20, width: 40, height: 8, clickable: true });
|
||||
const plain = fakeElement({ tag: "div", x: 0, y: 0, width: 100, height: 100 });
|
||||
withFakeDocument([button, plain], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets.length, 2);
|
||||
assert.equal(targets[0]!.clickable, true);
|
||||
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 30, y: 24 });
|
||||
assert.equal(targets[1]!.clickable, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("queryCandidates caches within a tick until reset", () => {
|
||||
const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 });
|
||||
withFakeDocument(
|
||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [first] },
|
||||
() => {
|
||||
__testing__.resetCandidateCache();
|
||||
const a = host.queryCandidates("taps");
|
||||
const b = host.queryCandidates("taps");
|
||||
assert.equal(a, b);
|
||||
},
|
||||
);
|
||||
test("queryTargets reports only real text inputs as editable", () => {
|
||||
const input = fakeElement({ tag: "input", x: 0, y: 0, width: 100, height: 20, editable: true });
|
||||
const checkbox = fakeElement({ tag: "input", x: 0, y: 40, width: 20, height: 20, editable: true });
|
||||
checkbox.type = "checkbox";
|
||||
withFakeDocument([input, checkbox], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets[0]!.editable, true);
|
||||
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 50, y: 10 });
|
||||
assert.equal(targets[1]!.editable, false);
|
||||
});
|
||||
});
|
||||
|
||||
// A disabled control used to be dropped from every verb's candidates, because
|
||||
// the web host folded `disabled` into its visibility check. It is a fact of its
|
||||
// own now, so `taps` still skips it while `swipes` can still start on it, which
|
||||
// is what the native host has always done.
|
||||
test("queryTargets reports a disabled control rather than dropping it", () => {
|
||||
const disabled = fakeElement({
|
||||
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, disabled: true,
|
||||
});
|
||||
withFakeDocument([disabled], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0]!.clickable, true);
|
||||
assert.equal(targets[0]!.enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("queryTargets caches within a tick until reset", () => {
|
||||
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
||||
withFakeDocument([button], () => {
|
||||
const first = host.queryTargets();
|
||||
const second = host.queryTargets();
|
||||
assert.equal(first, second);
|
||||
__testing__.resetTargetCache();
|
||||
assert.notEqual(host.queryTargets(), first);
|
||||
});
|
||||
});
|
||||
|
||||
// evaluateExtractors builds State, which references document and window.
|
||||
|
||||
Reference in new issue
Block a user