From 6c6bc97f4048fd450e6788839dbee688038efc5e Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 26 Apr 2026 23:59:06 +0700 Subject: [PATCH] feat(hierarchy): add FindBySelectorPath for chained object selectors Each selector in the chain is matched within the descendants of the previous match. Returns the deepest match (or nil) for FindBySelectorPath and every deepest match for FindAllBySelectorPath. --- internal/hierarchy/hierarchy.go | 55 ++++++++++++++++++ internal/hierarchy/hierarchy_test.go | 87 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) 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")