diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index f662a4d..405cd50 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -298,6 +298,22 @@ func (t *Tree) FindAllNodes(selector string) []*Node { return searchSubtree(t.Root, kind, value) } +// FindBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindBySelectorPath(path []Selector) *Node { + if t == nil || t.Root == nil { + return nil + } + return t.Root.FindBySelectorPath(path) +} + +// FindAllBySelectorPath walks the selector chain starting from the tree root. +func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node { + if t == nil || t.Root == nil { + return nil + } + 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. func (n *Node) Find(selector string) *Node { @@ -346,6 +362,45 @@ func (n *Node) FindAllBySelector(sel Selector) []*Node { return result } +// 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. +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 + } + } + } + return nil +} + +// FindAllBySelectorPath returns every deepest match for the selector chain +// scoped under the receiver. +func (n *Node) FindAllBySelectorPath(path []Selector) []*Node { + if len(path) == 0 { + return nil + } + 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:])...) + } + } + return result +} + func findPathNode(root *Node, segments []string) *Node { if root == nil || len(segments) == 0 { return nil diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 4f6daf4..e016a30 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -528,6 +528,93 @@ func TestElementResourceIDPopulatesFromAccessibilityIdentifier(t *testing.T) { } } +const selectorPathDump = `{ + "attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []} + ] + }, + { + "attributes": {"testTag": "AccountCard", "bounds": "[0,200,540,400]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Savings", "bounds": "[10,210,200,240]"}, "children": []} + ] + } + ] + }, + { + "attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"}, + "children": [ + {"attributes": {"testTag": "AccountName", "text": "Travel", "bounds": "[600,10,800,40]"}, "children": []} + ] + } + ] +}` + +func TestFindBySelectorPathSingleSegment(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}} + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen") + } + if got := node.Element.Attributes["testTag"]; got != "HomeScreen" { + t.Fatalf("testTag = %q, want HomeScreen", got) + } +} + +func TestFindBySelectorPathScopedDescent(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + node := tree.FindBySelectorPath(path) + if node == nil { + t.Fatal("expected match for HomeScreen > AccountCard > AccountName") + } + if node.Element.Text != "Checking" { + t.Fatalf("text = %q, want Checking", node.Element.Text) + } +} + +func TestFindBySelectorPathRespectsScope(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "LedgerScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}, + } + if node := tree.FindBySelectorPath(path); node != nil { + t.Fatalf("AccountCard is under HomeScreen only, expected nil, got %+v", node.Element) + } +} + +func TestFindAllBySelectorPathReturnsAllDeepestMatches(t *testing.T) { + tree, _ := Parse(selectorPathDump) + path := []Selector{ + {Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}, + {Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}}, + } + matches := tree.FindAllBySelectorPath(path) + if len(matches) != 2 { + t.Fatalf("want 2 matches (Checking, Savings), got %d", len(matches)) + } +} + +func TestFindBySelectorPathEmptyPathReturnsNil(t *testing.T) { + tree, _ := Parse(selectorPathDump) + if tree.FindBySelectorPath(nil) != nil { + t.Fatal("empty path should return nil") + } +} + func TestNodeFindDoesNotReturnSiblings(t *testing.T) { tree, _ := Parse(pathDump) a2Node := tree.FindNode("id:A2")