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.
This commit is contained in:
pj committed 2026-06-06 00:36:32 +05:30
1 parent 29ec13adcd
commit c356340563
2 files changed
+82

No files matched your search

+14
View File
@@ -28,6 +28,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
) )
@@ -426,9 +427,22 @@ func (n *Node) scopedNodes(accept func(*Element) bool) []*Node {
result = append(result, candidate) 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 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) { func collectMatches(node *Node, accept func(*Element) bool, result *[]*Node) {
if accept(&node.Element) { if accept(&node.Element) {
*result = append(*result, node) *result = append(*result, node)
+68
View File
@@ -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) { func TestIOSFlatStructuralChildStillPreferred(t *testing.T) {
tree, _ := Parse(iosFlatDump) tree, _ := Parse(iosFlatDump)
submit := tree.FindNode("id:LoginSubmit") submit := tree.FindNode("id:LoginSubmit")