mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(hierarchy): match identifiers by role prefix
idPrefix: is id: with starts-with in place of equality, so a list whose
rows are named <role>_<record id> is reachable by the durable half. The
Android package prefix is skipped the same way id: skips it.
Routing both prefix kinds through matchAttr also makes the object form
work: {descPrefix: ...} matched nothing on the native side while the web
runtime honoured it.
This commit is contained in:
1 parent
b8431c3e13
commit
1fcb7a9f7d
2 files changed
+124
-2
No files matched your search
@@ -6,6 +6,7 @@
|
|||||||
// String selectors (global scan or element-scoped):
|
// String selectors (global scan or element-scoped):
|
||||||
// attribute:value - substring match; exact for "true"/"false" booleans
|
// attribute:value - substring match; exact for "true"/"false" booleans
|
||||||
// id:<suffix> - substring on resource-id / identifier (backward compat)
|
// id:<suffix> - substring on resource-id / identifier (backward compat)
|
||||||
|
// idPrefix:<prefix> - starts-with on resource-id / identifier, package prefix skipped
|
||||||
// text:<value> - substring on text attribute
|
// text:<value> - substring on text attribute
|
||||||
// desc:<value> - substring on content-desc / accessibilityText
|
// desc:<value> - substring on content-desc / accessibilityText
|
||||||
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
||||||
@@ -132,11 +133,45 @@ var attributeAliases = map[string][]string{
|
|||||||
"elementType": {"class"},
|
"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 "<package>: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.
|
// matchAttr returns true when the element has an attribute matching attr:value.
|
||||||
// Alias expansion is applied so cross-platform names resolve correctly.
|
// Alias expansion is applied so cross-platform names resolve correctly.
|
||||||
// Boolean values ("true"/"false") use exact comparison; all others use substring.
|
// Boolean values ("true"/"false") use exact comparison; all others use substring.
|
||||||
// Returns false gracefully when no candidate attribute has data.
|
// Returns false gracefully when no candidate attribute has data.
|
||||||
func matchAttr(element *Element, attr, value string) bool {
|
func matchAttr(element *Element, attr, value string) bool {
|
||||||
|
if matched, handled := matchPrefixKind(element, attr, value); handled {
|
||||||
|
return matched
|
||||||
|
}
|
||||||
candidates := append([]string{attr}, attributeAliases[attr]...)
|
candidates := append([]string{attr}, attributeAliases[attr]...)
|
||||||
for _, key := range candidates {
|
for _, key := range candidates {
|
||||||
attrVal, ok := element.Attributes[key]
|
attrVal, ok := element.Attributes[key]
|
||||||
@@ -613,8 +648,6 @@ func match(element *Element, kind, value string) bool {
|
|||||||
return matchAttr(element, "text", value)
|
return matchAttr(element, "text", value)
|
||||||
case "desc":
|
case "desc":
|
||||||
return element.Description == value || strings.HasPrefix(element.Description, value+", ")
|
return element.Description == value || strings.HasPrefix(element.Description, value+", ")
|
||||||
case "descPrefix":
|
|
||||||
return strings.HasPrefix(element.Description, value)
|
|
||||||
default:
|
default:
|
||||||
return matchAttr(element, kind, value)
|
return matchAttr(element, kind, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestBoolFieldsFromNode(t *testing.T) {
|
||||||
input := `{
|
input := `{
|
||||||
"attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},
|
"attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},
|
||||||
|
|||||||
Reference in new issue
Block a user