Files
sanderling/internal/verifier/worker_test.go
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

96 lines
3.6 KiB
Go

package verifier
import (
"slices"
"testing"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// 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)
}
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)
}
}
}
// TestScopedElements is the core of keeping the fuzzer in the app. It checks
// the window-ownership rule against a realistic tree: the app window carries no
// package (Compose), even under android:id/content; the soft keyboard and system
// UI are separate windows with concrete packages, and their empty-package child
// wrappers (a keyboard "Settings" key) must inherit the foreign owner and drop
// out -- the exact node that used to leak in and navigate to system Settings.
func TestScopedElements(t *testing.T) {
const treeJSON = `{
"attributes": {"bounds": "[0,0,1080,2400]"},
"children": [
{"attributes": {"resource-id": "LoginEmail", "bounds": "[0,100,1080,200]"}, "children": []},
{"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,1080,2400]"}, "children": [
{"attributes": {"resource-id": "AccountNameField", "bounds": "[0,300,1080,400]"}, "children": []}
]},
{"attributes": {"resource-id": "com.oplus.securitykeyboard:id/keyboard", "bounds": "[0,1503,1080,2268]"}, "children": [
{"attributes": {"content-desc": "Settings", "bounds": "[461,1503,618,1635]"}, "children": []}
]},
{"attributes": {"resource-id": "com.android.systemui:id/nav", "bounds": "[0,2268,1080,2400]"}, "children": []}
]
}`
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatal(err)
}
v := &Verifier{appPackage: "app.folio", lastTree: tree}
scope := v.scopedElements()
inScope := func(selector string) bool {
element := tree.Find(selector)
if element == nil {
t.Fatalf("element %q not found in tree", selector)
}
return scope[element]
}
// App nodes carry no package and stay in scope, even under the android
// framework content wrapper.
for _, selector := range []string{"id:LoginEmail", "id:AccountNameField"} {
if !inScope(selector) {
t.Errorf("%s should be in scope (app window)", selector)
}
}
// The keyboard's empty-package "Settings" key inherits the IME window owner
// and drops out; the system UI node drops out by its own package.
if inScope("desc:Settings") {
t.Error("keyboard Settings key must be out of scope (owned by the IME window)")
}
if inScope("id:nav") {
t.Error("system UI node must be out of scope")
}
}