mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(hierarchy): bounds-containment fallback for scoped and path queries
Compose on iOS surfaces a testTag node as an empty leaf sibling of the content it labels instead of as an ancestor, so descendant search under the tagged node finds nothing and every path or scoped query returns null. When structural search yields no match, fall back to nodes whose bounds lie inside the scope node's bounds.
This commit is contained in:
1 parent
ce65cc32a4
commit
9944b97e0f
2 files changed
+251
-72
No files matched your search
+109
-72
@@ -75,6 +75,7 @@ type Element struct {
|
||||
type Node struct {
|
||||
Element
|
||||
Children []*Node `json:"-"`
|
||||
tree *Tree
|
||||
}
|
||||
|
||||
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
|
||||
@@ -180,7 +181,7 @@ func Parse(text string) (*Tree, error) {
|
||||
}
|
||||
|
||||
func walkNode(node *treeNodeJSON, tree *Tree) *Node {
|
||||
n := &Node{Element: *elementFromNode(node)}
|
||||
n := &Node{Element: *elementFromNode(node), tree: tree}
|
||||
tree.Elements = append(tree.Elements, &n.Element)
|
||||
for i := range node.Children {
|
||||
n.Children = append(n.Children, walkNode(&node.Children[i], tree))
|
||||
@@ -333,69 +334,50 @@ func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node {
|
||||
return t.Root.FindAllBySelectorPath(path)
|
||||
}
|
||||
|
||||
// Find returns the first Node in this node's subtree (descendants only) matching
|
||||
// the string selector. Path queries within the selector are not supported here.
|
||||
// Find returns the first Node scoped to this node (descendants, with spatial
|
||||
// fallback) matching the string selector. Path queries within the selector are
|
||||
// not supported here.
|
||||
func (n *Node) Find(selector string) *Node {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
if nodes := searchSubtree(child, kind, value); len(nodes) > 0 {
|
||||
return nodes[0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return firstNode(n.FindAll(selector))
|
||||
}
|
||||
|
||||
// FindAll returns all Nodes in this node's subtree (descendants only) matching
|
||||
// the string selector.
|
||||
// FindAll returns all Nodes scoped to this node (descendants, with spatial
|
||||
// fallback) matching the string selector.
|
||||
func (n *Node) FindAll(selector string) []*Node {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
result = append(result, searchSubtree(child, kind, value)...)
|
||||
}
|
||||
return result
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
return match(element, kind, value)
|
||||
})
|
||||
}
|
||||
|
||||
// FindBySelector returns the first Node in this node's subtree matching sel (AND semantics).
|
||||
// FindBySelector returns the first Node scoped to this node matching sel (AND semantics).
|
||||
func (n *Node) FindBySelector(sel Selector) *Node {
|
||||
for _, child := range n.Children {
|
||||
if nodes := searchSubtreeBySelector(child, sel); len(nodes) > 0 {
|
||||
return nodes[0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return firstNode(n.FindAllBySelector(sel))
|
||||
}
|
||||
|
||||
// FindAllBySelector returns all Nodes in this node's subtree matching sel (AND semantics).
|
||||
// FindAllBySelector returns all Nodes scoped to this node matching sel (AND semantics).
|
||||
func (n *Node) FindAllBySelector(sel Selector) []*Node {
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
result = append(result, searchSubtreeBySelector(child, sel)...)
|
||||
}
|
||||
return result
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
return matchSelector(element, sel)
|
||||
})
|
||||
}
|
||||
|
||||
// FindBySelectorPath walks a chain of selectors. The first selector is matched
|
||||
// against descendants of the receiver; each subsequent selector is matched
|
||||
// against descendants of the previous match. Returns the deepest match or nil.
|
||||
// in the receiver's scope; each subsequent selector is matched in the scope of
|
||||
// the previous match. Returns the deepest match or nil.
|
||||
func (n *Node) FindBySelectorPath(path []Selector) *Node {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
|
||||
if len(path) == 1 {
|
||||
return candidate
|
||||
}
|
||||
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
|
||||
return deeper
|
||||
}
|
||||
for _, candidate := range n.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
return candidate
|
||||
}
|
||||
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
|
||||
return deeper
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -407,19 +389,86 @@ func (n *Node) FindAllBySelectorPath(path []Selector) []*Node {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, candidate := range n.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
result = append(result, candidate)
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNode(nodes []*Node) *Node {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// scopedNodes returns this node's descendants matching accept, in pre-order.
|
||||
// When no descendant matches, nodes spatially contained in this node's bounds
|
||||
// are matched instead. Compose on iOS emits a testTag node as an empty leaf
|
||||
// sibling of the content it labels rather than as an ancestor, so descendant
|
||||
// search under the tagged node finds nothing; bounds containment recovers the
|
||||
// intended scope.
|
||||
func (n *Node) scopedNodes(accept func(*Element) bool) []*Node {
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
|
||||
if len(path) == 1 {
|
||||
result = append(result, candidate)
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
|
||||
collectMatches(child, accept, &result)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
for _, candidate := range n.spatialScope() {
|
||||
if accept(&candidate.Element) {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectMatches(node *Node, accept func(*Element) bool, result *[]*Node) {
|
||||
if accept(&node.Element) {
|
||||
*result = append(*result, node)
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
collectMatches(child, accept, result)
|
||||
}
|
||||
}
|
||||
|
||||
// spatialScope returns every node in the tree, in pre-order, whose positive
|
||||
// bounds lie fully inside this node's bounds, excluding the node itself.
|
||||
func (n *Node) spatialScope() []*Node {
|
||||
if n.tree == nil || n.tree.Root == nil {
|
||||
return nil
|
||||
}
|
||||
if n.Bounds.Width() <= 0 || n.Bounds.Height() <= 0 {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
var walk func(*Node)
|
||||
walk = func(candidate *Node) {
|
||||
if candidate != n &&
|
||||
candidate.Bounds.Width() > 0 && candidate.Bounds.Height() > 0 &&
|
||||
containsBounds(n.Bounds, candidate.Bounds) {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
for _, child := range candidate.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n.tree.Root)
|
||||
return result
|
||||
}
|
||||
|
||||
// containsBounds reports whether outer fully contains inner (inclusive).
|
||||
func containsBounds(outer, inner Bounds) bool {
|
||||
return inner.Left >= outer.Left && inner.Top >= outer.Top &&
|
||||
inner.Right <= outer.Right && inner.Bottom <= outer.Bottom
|
||||
}
|
||||
|
||||
func findPathNode(root *Node, segments []string) *Node {
|
||||
if root == nil || len(segments) == 0 {
|
||||
return nil
|
||||
@@ -440,18 +489,12 @@ func findPathNode(root *Node, segments []string) *Node {
|
||||
}
|
||||
|
||||
func findPathDescendantsNode(root *Node, segments []string) *Node {
|
||||
kind, value, ok := parseSelector(segments[0])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
for _, node := range searchSubtree(child, kind, value) {
|
||||
if len(segments) == 1 {
|
||||
return node
|
||||
}
|
||||
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
|
||||
return result
|
||||
}
|
||||
for _, node := range root.FindAll(segments[0]) {
|
||||
if len(segments) == 1 {
|
||||
return node
|
||||
}
|
||||
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -477,19 +520,13 @@ func findPathAllNodes(root *Node, segments []string) []*Node {
|
||||
}
|
||||
|
||||
func findPathAllDescendantsNodes(root *Node, segments []string) []*Node {
|
||||
kind, value, ok := parseSelector(segments[0])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, child := range root.Children {
|
||||
for _, node := range searchSubtree(child, kind, value) {
|
||||
if len(segments) == 1 {
|
||||
result = append(result, node)
|
||||
continue
|
||||
}
|
||||
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||
for _, node := range root.FindAll(segments[0]) {
|
||||
if len(segments) == 1 {
|
||||
result = append(result, node)
|
||||
continue
|
||||
}
|
||||
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -730,3 +730,145 @@ func TestExplicitPackageAttributeWins(t *testing.T) {
|
||||
t.Errorf("package = %q, want app.folio (explicit attr should win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// iosFlatDump mirrors the Compose-on-iOS accessibility shape: the testTag node
|
||||
// surfaces as an empty leaf SIBLING of the container holding the content it
|
||||
// labels, with equal bounds, instead of as an ancestor.
|
||||
const iosFlatDump = `{
|
||||
"attributes": {"bounds": "[0,0][402,874]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"accessibilityText": "Folio", "bounds": "[0,0][402,874]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "LoginScreen", "bounds": "[0,62][402,840]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"bounds": "[0,62][402,840]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"accessibilityText": "EMAIL", "bounds": "[20,106][60,120]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginEmail", "accessibilityText": "Email", "bounds": "[34,125][368,173]"},
|
||||
"children": [
|
||||
{"attributes": {"bounds": "[34,125][368,173]"}, "children": []}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginPassword", "accessibilityText": "Password", "bounds": "[34,205][368,253]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"text": "Sign in", "bounds": "[20,297][382,345]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginSubmit", "accessibilityText": "Sign in", "bounds": "[20,297][382,345]"},
|
||||
"children": [
|
||||
{"attributes": {"text": "Sign in", "resource-id": "SubmitLabel", "bounds": "[168,312][233,330]"}, "children": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"bounds": "[0,0][402,54]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "StatusClock", "accessibilityText": "3:24 PM", "bounds": "[55,22][92,42]"},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestIOSFlatSelectorPathFallsBackToBounds(t *testing.T) {
|
||||
tree, err := Parse(iosFlatDump)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
path := []Selector{
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginEmail"}}},
|
||||
}
|
||||
node := tree.FindBySelectorPath(path)
|
||||
if node == nil {
|
||||
t.Fatal("expected LoginEmail via bounds containment under leaf LoginScreen")
|
||||
}
|
||||
if node.ResourceID != "LoginEmail" {
|
||||
t.Fatalf("got %q, want LoginEmail", node.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatStringPathFallsBackToBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
element := tree.Find("id:LoginScreen > id:LoginSubmit")
|
||||
if element == nil {
|
||||
t.Fatal("expected LoginSubmit via bounds containment under leaf LoginScreen")
|
||||
}
|
||||
if element.ResourceID != "LoginSubmit" {
|
||||
t.Fatalf("got %q, want LoginSubmit", element.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatScopedNodeFindFallsBackToBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
screen := tree.FindNode("id:LoginScreen")
|
||||
if screen == nil {
|
||||
t.Fatal("expected LoginScreen node")
|
||||
}
|
||||
node := screen.Find("id:LoginPassword")
|
||||
if node == nil {
|
||||
t.Fatal("expected LoginPassword via bounds containment")
|
||||
}
|
||||
if node.ResourceID != "LoginPassword" {
|
||||
t.Fatalf("got %q, want LoginPassword", node.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatFindAllBySelectorPathReturnsEachField(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
path := []Selector{
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
|
||||
{Filters: []AttrFilter{{Attr: "label", Value: "word"}}},
|
||||
}
|
||||
// label "word" substring-matches "Password" only.
|
||||
nodes := tree.FindAllBySelectorPath(path)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].ResourceID != "LoginPassword" {
|
||||
t.Fatalf("got %q, want LoginPassword", nodes[0].ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatSpatialScopeExcludesOutsideBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
// StatusClock sits in the status bar above LoginScreen's bounds; the
|
||||
// spatial fallback must not leak it into the screen's scope.
|
||||
if tree.Find("id:LoginScreen > id:StatusClock") != nil {
|
||||
t.Fatal("StatusClock is outside LoginScreen bounds, expected nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatStructuralChildStillPreferred(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
submit := tree.FindNode("id:LoginSubmit")
|
||||
if submit == nil {
|
||||
t.Fatal("expected LoginSubmit node")
|
||||
}
|
||||
// "Sign in" exists as the structural child of LoginSubmit and as an
|
||||
// equal-bounds sibling decoration; the structural child must win.
|
||||
node := submit.Find("text:Sign in")
|
||||
if node == nil {
|
||||
t.Fatal("expected structural child match")
|
||||
}
|
||||
if node.ResourceID != "SubmitLabel" {
|
||||
t.Fatalf("expected the structural child SubmitLabel, got id=%q", node.ResourceID)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user