diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index c54a052..92a2e02 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -6,6 +6,7 @@ // String selectors (global scan or element-scoped): // attribute:value - substring match; exact for "true"/"false" booleans // id: - substring on resource-id / identifier (backward compat) +// idPrefix: - starts-with on resource-id / identifier, package prefix skipped // text: - substring on text attribute // desc: - substring on content-desc / accessibilityText // descPrefix: - starts-with on content-desc / accessibilityText @@ -132,11 +133,45 @@ var attributeAliases = map[string][]string{ "elementType": {"class"}, } +// matchPrefixKind resolves the selector kinds that mean starts-with rather than +// the default substring/exact attribute rule. The second return is false when +// kind names an ordinary attribute. Routing them here rather than in match() +// keeps the string form ("idPrefix:customer_row_") and the object form +// ({idPrefix: "customer_row_"}) on one definition. +func matchPrefixKind(element *Element, kind, value string) (bool, bool) { + switch kind { + case "idPrefix": + return matchIDPrefix(element.ResourceID, value), true + case "descPrefix": + return strings.HasPrefix(element.Description, value), true + default: + return false, false + } +} + +// matchIDPrefix is the id: rule with starts-with in place of equality: the +// whole identifier, or the local name after Android's ":id/". Without +// the second form a role prefix would only match when the caller wrote the +// package out, which is exactly the string that varies between build variants. +func matchIDPrefix(resourceID, value string) bool { + if strings.HasPrefix(resourceID, value) { + return true + } + const marker = ":id/" + if index := strings.Index(resourceID, marker); index >= 0 { + return strings.HasPrefix(resourceID[index+len(marker):], value) + } + return false +} + // matchAttr returns true when the element has an attribute matching attr:value. // Alias expansion is applied so cross-platform names resolve correctly. // Boolean values ("true"/"false") use exact comparison; all others use substring. // Returns false gracefully when no candidate attribute has data. func matchAttr(element *Element, attr, value string) bool { + if matched, handled := matchPrefixKind(element, attr, value); handled { + return matched + } candidates := append([]string{attr}, attributeAliases[attr]...) for _, key := range candidates { attrVal, ok := element.Attributes[key] @@ -613,8 +648,6 @@ func match(element *Element, kind, value string) bool { return matchAttr(element, "text", value) case "desc": return element.Description == value || strings.HasPrefix(element.Description, value+", ") - case "descPrefix": - return strings.HasPrefix(element.Description, value) default: return matchAttr(element, kind, value) } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 91abd4c..d95c173 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -106,6 +106,95 @@ func TestDescPrefix(t *testing.T) { } } +// idPrefixDump is a list whose rows carry a durable role prefix followed by the +// record's identifier, the convention that makes every row's full id unique and +// unwritable in a spec. +const idPrefixDump = `{ + "attributes": {"resource-id": "com.example:id/customer_list", "bounds": "[0,0,100,400]"}, + "children": [ + {"attributes": {"resource-id": "com.example:id/customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []}, + {"attributes": {"resource-id": "com.example:id/customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []}, + {"attributes": {"resource-id": "com.example:id/supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []}, + {"attributes": {"identifier": "customer_row_ghi-789", "bounds": "[0,300,100,400]"}, "children": []} + ] +}` + +func TestIDPrefixMatchesEveryRowSharingTheRole(t *testing.T) { + tree, _ := Parse(idPrefixDump) + rows := tree.FindAll("idPrefix:customer_row_") + if len(rows) != 3 { + t.Fatalf("want 3 customer rows, got %d", len(rows)) + } +} + +func TestIDPrefixDoesNotRequireThePackagePrefix(t *testing.T) { + tree, _ := Parse(idPrefixDump) + el := tree.Find("idPrefix:customer_row_abc") + if el == nil { + t.Fatal("expected the local name after :id/ to match on its own") + } + if el.ResourceID != "com.example:id/customer_row_abc-123" { + t.Fatalf("got %q", el.ResourceID) + } +} + +func TestIDPrefixAlsoMatchesTheWholeIdentifier(t *testing.T) { + tree, _ := Parse(idPrefixDump) + if tree.Find("idPrefix:com.example:id/customer_row_") == nil { + t.Fatal("expected a package-qualified prefix to match") + } +} + +func TestIDPrefixMatchesIOSAccessibilityIdentifier(t *testing.T) { + tree, _ := Parse(idPrefixDump) + el := tree.Find("idPrefix:customer_row_ghi") + if el == nil { + t.Fatal("expected identifier to match on a node with no resource-id") + } + if el.Bounds.Top != 300 { + t.Fatalf("matched the wrong node: %+v", el.Bounds) + } +} + +func TestIDPrefixMatchesNothingWhenNoIDStartsWithIt(t *testing.T) { + tree, _ := Parse(idPrefixDump) + if rows := tree.FindAll("idPrefix:invoice_row_"); len(rows) != 0 { + t.Fatalf("want no matches, got %d", len(rows)) + } +} + +func TestIDPrefixIsNotASubstringMatch(t *testing.T) { + tree, _ := Parse(idPrefixDump) + if tree.Find("idPrefix:row_") != nil { + t.Fatal("expected starts-with, not substring") + } +} + +// The string and object forms are one rule, so a prefix filter combined with a +// second attribute has to keep the same meaning it has on its own. +func TestIDPrefixInObjectSelector(t *testing.T) { + tree, _ := Parse(idPrefixDump) + sel := Selector{Filters: []AttrFilter{{Attr: "idPrefix", Value: "customer_row_"}}} + if nodes := tree.Root.FindAllBySelector(sel); len(nodes) != 3 { + t.Fatalf("want 3 customer rows, got %d", len(nodes)) + } +} + +func TestDescPrefixInObjectSelector(t *testing.T) { + input := `{ + "attributes": {}, + "children": [ + {"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []}, + {"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,100,100,200]"}, "children": []} + ] + }` + tree, _ := Parse(input) + sel := Selector{Filters: []AttrFilter{{Attr: "descPrefix", Value: "customer_row_"}}} + if nodes := tree.Root.FindAllBySelector(sel); len(nodes) != 1 { + t.Fatalf("want 1 customer row, got %d", len(nodes)) + } +} + func TestBoolFieldsFromNode(t *testing.T) { input := `{ "attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},