Files
sanderling/pkg/spec/test/host-parity.test.ts
T
pj b5f64bf665 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
2026-08-12 16:48:27 +05:30

74 lines
2.9 KiB
TypeScript

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}`);
}
});
});