package hierarchy import "testing" // sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture. const sampleDump = `{ "attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"}, "children": [ { "attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"}, "children": [], "clickable": false, "enabled": true }, { "attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"}, "children": [], "clickable": true, "enabled": true }, { "attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"}, "children": [], "clickable": true, "enabled": true } ] }` func TestParseCountsNodes(t *testing.T) { tree, err := Parse(sampleDump) if err != nil { t.Fatalf("Parse: %v", err) } if len(tree.Elements) != 4 { t.Fatalf("want 4 elements, got %d", len(tree.Elements)) } } func TestFindByIDSuffix(t *testing.T) { tree, _ := Parse(sampleDump) element := tree.Find("id:title") if element == nil { t.Fatal("expected match for id:title") } if element.Text != "Hello" { t.Fatalf("unexpected text %q", element.Text) } } func TestFindByText(t *testing.T) { tree, _ := Parse(sampleDump) element := tree.Find("text:Alice") if element == nil { t.Fatal("expected match for text:Alice") } } func TestFindAllReturnsDuplicates(t *testing.T) { tree, _ := Parse(sampleDump) elements := tree.FindAll("id:row") if len(elements) != 2 { t.Fatalf("want 2, got %d", len(elements)) } } func TestBoundsCenter(t *testing.T) { tree, _ := Parse(sampleDump) element := tree.Find("text:Alice") x, y := element.Bounds.Center() if x != 540 || y != 150 { t.Fatalf("unexpected center %d,%d", x, y) } } func TestParseEmpty(t *testing.T) { tree, err := Parse("") if err != nil { t.Fatalf("Parse: %v", err) } if len(tree.Elements) != 0 { t.Fatalf("want empty, got %d", len(tree.Elements)) } } func TestUnknownSelector(t *testing.T) { tree, _ := Parse(sampleDump) if tree.Find("bogus:value") != nil { t.Fatal("unknown kind should not match") } } func TestDescPrefix(t *testing.T) { input := `{ "attributes": {}, "children": [ {"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []}, {"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []}, {"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []} ] }` tree, _ := Parse(input) rows := tree.FindAll("descPrefix:customer_row_") if len(rows) != 2 { t.Fatalf("want 2 customer rows, got %d", len(rows)) } } func TestBoolFieldsFromNode(t *testing.T) { input := `{ "attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"}, "children": [], "clickable": true, "enabled": false, "focused": true, "checked": true, "selected": false }` tree, err := Parse(input) if err != nil { t.Fatalf("Parse: %v", err) } if len(tree.Elements) != 1 { t.Fatalf("want 1 element, got %d", len(tree.Elements)) } el := tree.Elements[0] if !el.Clickable { t.Error("expected clickable=true") } if el.Enabled { t.Error("expected enabled=false") } if !el.Focused { t.Error("expected focused=true") } if !el.Checked { t.Error("expected checked=true") } if el.Selected { t.Error("expected selected=false") } } func TestIdentifierFallback(t *testing.T) { input := `{ "attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"}, "children": [] }` tree, _ := Parse(input) el := tree.Find("id:my-button") if el == nil { t.Fatal("expected match via identifier fallback") } } func TestAccessibilityTextFallback(t *testing.T) { input := `{ "attributes": {"accessibilityText": "Close dialog", "bounds": "[0,0,50,50]"}, "children": [] }` tree, _ := Parse(input) el := tree.Find("desc:Close dialog") if el == nil { t.Fatal("expected match via accessibilityText fallback") } } func TestIOSMergedLabel(t *testing.T) { // iOS merges contentDescription with child text: "add_account_button, + Add account" input := `{ "attributes": {"accessibilityText": "add_account_button, + Add account", "bounds": "[20,777][382,825]"}, "children": [] }` tree, _ := Parse(input) el := tree.Find("desc:add_account_button") if el == nil { t.Fatal("expected desc: to match iOS merged label") } if el.Bounds.Left != 20 || el.Bounds.Top != 777 || el.Bounds.Right != 382 || el.Bounds.Bottom != 825 { t.Errorf("unexpected bounds: %+v", el.Bounds) } } const pathDump = `{ "attributes": {"resource-id": "root", "bounds": "[0,0,1080,2340]"}, "children": [ { "attributes": {"resource-id": "A", "content-desc": "screen_a", "bounds": "[0,0,540,2340]"}, "children": [ { "attributes": {"resource-id": "B", "content-desc": "label_b", "bounds": "[0,0,100,100]"}, "children": [ { "attributes": {"resource-id": "C", "content-desc": "label_c", "bounds": "[0,0,50,50]"}, "children": [] } ] } ] }, { "attributes": {"resource-id": "A2", "content-desc": "screen_a2", "bounds": "[540,0,1080,2340]"}, "children": [ { "attributes": {"resource-id": "B2", "content-desc": "label_b", "bounds": "[540,0,640,100]"}, "children": [] } ] } ] }` func TestPathQuerySingleLevel(t *testing.T) { tree, _ := Parse(pathDump) el := tree.Find("id:A > id:B") if el == nil { t.Fatal("expected to find B under A") } if el.ResourceID != "B" { t.Fatalf("got %q, want B", el.ResourceID) } } func TestPathQueryNotBUnderOtherRoot(t *testing.T) { tree, _ := Parse(pathDump) // B2 is under A2, not A; path from A should not reach B2 el := tree.Find("id:A > id:B2") if el != nil { t.Fatalf("expected nil, got element with id %q", el.ResourceID) } } func TestPathQueryMultiLevel(t *testing.T) { tree, _ := Parse(pathDump) el := tree.Find("id:A > id:B > id:C") if el == nil { t.Fatal("expected to find C under A > B") } if el.ResourceID != "C" { t.Fatalf("got %q, want C", el.ResourceID) } } func TestPathQueryMixedTypes(t *testing.T) { tree, _ := Parse(pathDump) el := tree.Find("desc:screen_a > desc:label_b") if el == nil { t.Fatal("expected to find label_b under screen_a") } if el.Description != "label_b" { t.Fatalf("got %q, want label_b", el.Description) } } func TestPathQueryNotFound(t *testing.T) { tree, _ := Parse(pathDump) if tree.Find("id:A > id:NoSuch") != nil { t.Fatal("expected nil for missing second segment") } } func TestPathQueryFirstMatchesSecondDoesNot(t *testing.T) { tree, _ := Parse(pathDump) // B2 is under A2, so A > B2 should return nil (B2 is not a descendant of A) if tree.Find("id:A > id:B2") != nil { t.Fatal("B2 is not a descendant of A, expected nil") } } func TestPathQueryFindAllAcrossRoots(t *testing.T) { tree, _ := Parse(pathDump) // label_b appears under A (as B) and under A2 (as B2) // FindAll("desc:screen_a > desc:label_b") should only find B under A, not B2 under A2 matches := tree.FindAll("desc:screen_a > desc:label_b") if len(matches) != 1 { t.Fatalf("want 1 match, got %d", len(matches)) } if matches[0].ResourceID != "B" { t.Fatalf("got %q, want B", matches[0].ResourceID) } } func TestPathQueryFindAllMultipleRootMatches(t *testing.T) { tree, _ := Parse(pathDump) // Both A and A2 are children of root, both have a child with content-desc "label_b" matches := tree.FindAll("id:root > desc:label_b") if len(matches) != 2 { t.Fatalf("want 2 matches (B and B2), got %d", len(matches)) } } func TestIOSBoundsFormat(t *testing.T) { input := `{ "attributes": {"accessibilityText": "account_card:abc123, Tim, $100", "bounds": "[20,130][382,202]"}, "children": [] }` tree, _ := Parse(input) el := tree.Find("descPrefix:account_card:") if el == nil { t.Fatal("expected descPrefix to match iOS account card") } if el.Bounds.Left != 20 || el.Bounds.Top != 130 || el.Bounds.Right != 382 || el.Bounds.Bottom != 202 { t.Errorf("unexpected bounds: %+v", el.Bounds) } cx, cy := el.Bounds.Center() if cx != 201 || cy != 166 { t.Errorf("unexpected center: (%d, %d)", cx, cy) } }