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:
pj committed 2026-08-12 16:48:27 +05:30
1 parent ef04a6de9d
commit b5f64bf665
23 files changed
+1494 -594

No files matched your search

+33 -12
View File
@@ -1,24 +1,45 @@
package verifier
import (
"slices"
"testing"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// TestVerbAcceptsSwipeRequiresPositiveBounds locks the fix for the notification
// shade: a zero-bounds element centers at (0,0), and a downward swipe from the
// top-left corner is the system gesture that pulls the shade over the app. The
// swipe verb must reject zero-bounds nodes like every other verb does.
func TestVerbAcceptsSwipeRequiresPositiveBounds(t *testing.T) {
zeroBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{}}
if verbAccepts("swipes", zeroBounds) {
t.Error("swipes must reject a zero-bounds element (it centers at (0,0) and pulls the notification shade)")
// TestTargetsReportFactsWithoutFiltering pins the native host's half of the
// split introduced to stop the two hosts drifting: it reports what an element
// IS and never decides which verb may act on it. The verb decision is one shared
// rule (pkg/spec/src/targets.ts) both hosts consume, asserted across engines by
// TestHostsAgreeOnTargetEligibility.
func TestTargetsReportFactsWithoutFiltering(t *testing.T) {
tree, err := hierarchy.Parse(hostParityTreeJSON)
if err != nil {
t.Fatal(err)
}
realBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{Left: 100, Top: 400, Right: 980, Bottom: 600}}
if !verbAccepts("swipes", realBounds) {
t.Error("swipes must accept an element with positive bounds")
v := &Verifier{lastTree: tree}
targets := v.targets()
if len(targets) != len(hostParityScreen) {
t.Fatalf("targets() returned %d elements, want all %d in the tree",
len(targets), len(hostParityScreen))
}
for index, target := range targets {
want := hostParityScreen[index]
got := []bool{
target.clickable, target.enabled, target.editable, target.scrollable,
}
expected := []bool{
want.clickable, want.enabled, want.editable, want.scrollable,
}
if !slices.Equal(got, expected) {
t.Errorf("%s clickable/enabled/editable/scrollable = %v, want %v",
want.name, got, expected)
}
positiveBounds := target.width > 0 && target.height > 0
if positiveBounds != want.positiveBounds {
t.Errorf("%s positive bounds = %v, want %v",
want.name, positiveBounds, want.positiveBounds)
}
}
}