diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 02ba6d8..2592c3c 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -5,10 +5,10 @@ // // String selectors (global scan or element-scoped): // attribute:value - substring match; exact for "true"/"false" booleans -// id: - substring on resource-id / identifier (backward compat) +// id: - exact on resource-id / identifier, or on the local name after ":id/" // idPrefix: - starts-with on resource-id / identifier, package prefix skipped // text: - substring on text attribute, innermost match only -// desc: - substring on content-desc / accessibilityText +// desc: - exact on content-desc / accessibilityText, or on the part before ", " in an iOS merged label // descPrefix: - starts-with on content-desc / accessibilityText // tag: - exact match on the element's tag name (web) // @@ -40,7 +40,9 @@ import ( "strings" ) -// Bounds is an inclusive rectangle in device pixels. +// Bounds is a rectangle in device pixels. Right and Bottom are exclusive, which +// is how uiautomator, getBoundingClientRect and an XCUIElement frame all report +// them, so Width is Right - Left and a tap at Right lands on the next element. type Bounds struct { Left int `json:"left"` Top int `json:"top"` @@ -1020,10 +1022,13 @@ func parseSelector(selector string) (string, string, bool) { return selector[:index], selector[index+1:], true } -// boundsPattern matches "[l,t,r,b]" (4-value Android/sidecar format). +// boundsPattern matches "[l,t,r,b]", which the chrome driver and the sidecar's +// stub backend emit. var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`) -// boundsPatternTwo matches "[x1,y1][x2,y2]" (iOS XCUITest format). +// boundsPatternTwo matches "[x1,y1][x2,y2]", which both device backends emit: +// it is uiautomator's own form on Android and what hierarchymap builds from an +// XCUIElement frame on iOS. var boundsPatternTwo = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`) func parseBounds(text string) (Bounds, error) { diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 02e6bd5..dcd58a1 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -78,6 +78,59 @@ func TestBoundsCenter(t *testing.T) { } } +func TestBoundsRightAndBottomAreExclusive(t *testing.T) { + dump := `{ + "attributes": {"resource-id": "screen", "bounds": "[0,0,1080,2340]"}, + "children": [ + {"attributes": {"resource-id": "upper", "bounds": "[0,0,100,50]"}, "children": []}, + {"attributes": {"resource-id": "lower", "bounds": "[0,50,100,100]"}, "children": []}, + {"attributes": {"resource-id": "hairlineFlat", "bounds": "[7,3,8,4]"}, "children": []}, + {"attributes": {"resource-id": "hairlinePairs", "bounds": "[7,3][8,4]"}, "children": []} + ] + }` + tree, err := Parse(dump) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + for _, want := range []struct { + selector string + width, height, centerX, centerY int + }{ + {"id:screen", 1080, 2340, 540, 1170}, + {"id:upper", 100, 50, 50, 25}, + {"id:lower", 100, 50, 50, 75}, + {"id:hairlineFlat", 1, 1, 7, 3}, + {"id:hairlinePairs", 1, 1, 7, 3}, + } { + t.Run(want.selector, func(t *testing.T) { + element := tree.Find(want.selector) + if element == nil { + t.Fatalf("no match for %s", want.selector) + } + if got := element.Bounds.Width(); got != want.width { + t.Errorf("width %d, want %d", got, want.width) + } + if got := element.Bounds.Height(); got != want.height { + t.Errorf("height %d, want %d", got, want.height) + } + x, y := element.Bounds.Center() + if x != want.centerX || y != want.centerY { + t.Errorf("center %d,%d, want %d,%d", x, y, want.centerX, want.centerY) + } + }) + } + + upper, lower := tree.Find("id:upper"), tree.Find("id:lower") + if upper.Bounds.Bottom != lower.Bounds.Top { + t.Fatalf("fixture stopped abutting: %d, %d", upper.Bounds.Bottom, lower.Bounds.Top) + } + if upper.Bounds.Height()+lower.Bounds.Height() != lower.Bounds.Bottom-upper.Bounds.Top { + t.Errorf("abutting rows cover %d+%d of %d", + upper.Bounds.Height(), lower.Bounds.Height(), lower.Bounds.Bottom-upper.Bounds.Top) + } +} + func TestParseEmpty(t *testing.T) { tree, err := Parse("") if err != nil {