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
@@ -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