From c35634056349c72c19ddaa9f9ad91297971698a0 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 00:36:32 +0530 Subject: [PATCH] fix(hierarchy): rank spatial-fallback matches by specificity The bounds-containment fallback returned the first pre-order match, so a screen-sized container could win over the intended small element. Matches are now ordered smallest-area first; equal-area matches keep pre-order, preserving the iOS-flat equal-bounds sibling pattern. --- internal/hierarchy/hierarchy.go | 14 ++++++ internal/hierarchy/hierarchy_test.go | 68 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 9135649..9972cb4 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -28,6 +28,7 @@ import ( "fmt" "maps" "regexp" + "sort" "strconv" "strings" ) @@ -426,9 +427,22 @@ func (n *Node) scopedNodes(accept func(*Element) bool) []*Node { result = append(result, candidate) } } + // Spatial containment alone lets a large container outrank the intended + // small element; the most specific (smallest) match wins instead. + sortBySpecificity(result) return result } +// sortBySpecificity orders nodes ascending by bounds area, so the smallest +// (most specific) containing match comes first. Equal-area nodes keep their +// pre-order position. +func sortBySpecificity(nodes []*Node) { + sort.SliceStable(nodes, func(i, j int) bool { + return nodes[i].Bounds.Width()*nodes[i].Bounds.Height() < + nodes[j].Bounds.Width()*nodes[j].Bounds.Height() + }) +} + func collectMatches(node *Node, accept func(*Element) bool, result *[]*Node) { if accept(&node.Element) { *result = append(*result, node) diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index ebcf8ea..6eff3b7 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -856,6 +856,74 @@ func TestIOSFlatSpatialScopeExcludesOutsideBounds(t *testing.T) { } } +// spatialSpecificityDump scopes a leaf testTag (iOS-flat pattern) over a +// screen where both a screen-sized container and the small element inside it +// match the same attribute, plus two equal-bounds siblings for tie ordering. +const spatialSpecificityDump = `{ + "attributes": {"bounds": "[0,0][402,874]"}, + "children": [ + { + "attributes": {"resource-id": "FormScreen", "bounds": "[0,62][402,840]"}, + "children": [] + }, + { + "attributes": {"resource-id": "FieldContainer", "accessibilityText": "Amount", "bounds": "[0,62][402,840]"}, + "children": [ + { + "attributes": {"resource-id": "AmountField", "accessibilityText": "Amount", "bounds": "[34,125][368,173]"}, + "children": [] + }, + { + "attributes": {"resource-id": "FirstTab", "accessibilityText": "Tab", "bounds": "[20,297][382,345]"}, + "children": [] + }, + { + "attributes": {"resource-id": "SecondTab", "accessibilityText": "Tab", "bounds": "[20,297][382,345]"}, + "children": [] + } + ] + } + ] +}` + +func TestSpatialFallbackPrefersSmallestContainingMatch(t *testing.T) { + tree, err := Parse(spatialSpecificityDump) + if err != nil { + t.Fatalf("Parse: %v", err) + } + screen := tree.FindNode("id:FormScreen") + if screen == nil { + t.Fatal("expected FormScreen node") + } + // Both FieldContainer (screen-sized, earlier in pre-order) and + // AmountField (small) match; the most specific match must win. + node := screen.Find("desc:Amount") + if node == nil { + t.Fatal("expected a spatial-fallback match") + } + if node.ResourceID != "AmountField" { + t.Fatalf("expected the smallest containing match AmountField, got id=%q", node.ResourceID) + } +} + +func TestSpatialFallbackEqualAreaKeepsPreOrder(t *testing.T) { + tree, err := Parse(spatialSpecificityDump) + if err != nil { + t.Fatalf("Parse: %v", err) + } + screen := tree.FindNode("id:FormScreen") + if screen == nil { + t.Fatal("expected FormScreen node") + } + node := screen.Find("desc:Tab") + if node == nil { + t.Fatal("expected a spatial-fallback match") + } + if node.ResourceID != "FirstTab" { + t.Fatalf("equal-area matches must keep pre-order, got id=%q", node.ResourceID) + } +} + func TestIOSFlatStructuralChildStillPreferred(t *testing.T) { tree, _ := Parse(iosFlatDump) submit := tree.FindNode("id:LoginSubmit")