Files
artemis_control/internal/ui/dump_test.go
Sam Rolfe be22eba386 fix: address the Maps search box by id, and re-prove Phase 5
The Maps omnibox hint rotates: it read "Search here" in the Phase 5 run and
"Try petrol stations, ATMs" in the AUD-128 re-verification, so the recipe's
text match on it failed. The resource id does not rotate.

- internal/ui: add Find/FindID and the "id:" selector; Find dispatches an
  id: selector to the resource id and everything else to text as before.
- internal/recipe: wait/tap/wait_gone/tap_if and the tap helper use Find, so a
  recipe may name a resource id. Guards stay text-only.
- recipes/maps_navigate.recipe: wait/tap id:search_omnibox_text_box.
- tests for the id selector; docs and README updated.
2026-10-17 09:12:01 +11:00

172 lines
6.2 KiB
Go

package ui
import (
"strings"
"testing"
)
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" class="android.widget.FrameLayout" package="com.tailscale.ipn" clickable="false" enabled="true" scrollable="false" bounds="[0,0][1080,2400]">
<node index="0" text="samuelrolfe@gmail.com" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,155][798,217]"/>
<node index="1" text="Connected" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,217][453,277]"/>
<node index="2" text="" content-desc="Navigate up" class="android.widget.ImageButton" package="com.tailscale.ipn" clickable="true" enabled="true" bounds="[42,121][189,268]"/>
</node>
<node index="1" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="true" bounds="[221,663][748,727]">
<node index="0" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="false" enabled="true" bounds="[221,663][748,727]"/>
</node>
<node index="2" text="Disabled thing" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="false" bounds="[0,0][10,10]"/>
</hierarchy>`
func parseSample(t *testing.T) *Hierarchy {
t.Helper()
h, err := parseHierarchy([]byte(sampleDump))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
return h
}
func TestBoundsAndCenter(t *testing.T) {
n := &Node{Bounds: "[221,155][798,217]"}
r, err := n.BoundsRect()
if err != nil {
t.Fatalf("BoundsRect: %v", err)
}
if r.X1 != 221 || r.Y1 != 155 || r.X2 != 798 || r.Y2 != 217 {
t.Fatalf("unexpected rect %+v", r)
}
cx, cy, err := n.Center()
if err != nil {
t.Fatalf("Center: %v", err)
}
if cx != (221+798)/2 || cy != (155+217)/2 {
t.Fatalf("unexpected centre (%d,%d)", cx, cy)
}
}
func TestBoundsRejectsGarbage(t *testing.T) {
for _, bad := range []string{"", "nope", "[1,2][3,4]junk", "[10,10][10,20]"} {
if _, err := (&Node{Bounds: bad}).BoundsRect(); err == nil {
t.Fatalf("expected error for bounds %q", bad)
}
}
}
func TestFindTextExactAndSubstring(t *testing.T) {
h := parseSample(t)
if got := len(h.FindText("Connected", true)); got != 1 {
t.Fatalf("exact match: want 1, got %d", got)
}
// Substring is case-insensitive and matches more than one node.
if got := len(h.FindText("network", false)); got != 2 {
t.Fatalf("substring match: want 2 (parent + child), got %d", got)
}
// Content descriptions count as text.
matches := h.FindText("Navigate up", true)
if len(matches) != 1 || !matches[0].Clickable {
t.Fatalf("content-desc match failed: %+v", matches)
}
if got := h.FindText("does not exist", false); got != nil {
t.Fatalf("expected no matches, got %d", len(got))
}
}
func TestPickBestMatchPrefersClickable(t *testing.T) {
h := parseSample(t)
matches := h.FindText("network", false)
if len(matches) != 2 {
t.Fatalf("want 2 matches, got %d", len(matches))
}
best, err := pickBestMatch(matches)
if err != nil {
t.Fatalf("pickBestMatch: %v", err)
}
if !best.Clickable {
t.Fatalf("expected a clickable match, got %s", best.Label())
}
}
func TestPickBestMatchRejectsDisabledOnly(t *testing.T) {
h := parseSample(t)
matches := h.FindText("Disabled thing", true)
if len(matches) != 1 {
t.Fatalf("want 1 match, got %d", len(matches))
}
if _, err := pickBestMatch(matches); err == nil {
t.Fatal("expected an error when the only match is disabled")
}
}
// The Maps search box keeps a stable resource id but a rotating label, so it is
// addressed with the "id:" selector. This covers the full and short id, the
// prefix dispatch, and that a plain selector is still matched by text.
const sampleSelectorDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy rotation="0">
<node index="0" text="" resource-id="com.google.android.apps.maps:id/search_omnibox_text_box" class="android.widget.EditText" package="com.google.android.apps.maps" content-desc="Try petrol stations, ATMs" clickable="true" enabled="true" bounds="[31,142][663,268]"/>
<node index="1" text="Search" resource-id="com.example:id/other" class="android.widget.TextView" package="com.example" clickable="true" enabled="true" bounds="[0,0][10,10]"/>
</hierarchy>`
func parseSelectorSample(t *testing.T) *Hierarchy {
t.Helper()
h, err := parseHierarchy([]byte(sampleSelectorDump))
if err != nil {
t.Fatalf("parseHierarchy: %v", err)
}
return h
}
func TestFindIDSelector(t *testing.T) {
h := parseSelectorSample(t)
// Full resource id, and the short name after the "/".
for _, sel := range []string{
"id:search_omnibox_text_box",
"id:com.google.android.apps.maps:id/search_omnibox_text_box",
} {
matches := h.Find(sel, false)
if len(matches) != 1 {
t.Fatalf("Find(%q): want 1 match, got %d", sel, len(matches))
}
if matches[0].Class != "android.widget.EditText" {
t.Fatalf("Find(%q) matched %q", sel, matches[0].Class)
}
}
// Exact matching of the short id.
if got := len(h.Find("id:search_omnibox_text_box", true)); got != 1 {
t.Fatalf("exact id: want 1, got %d", got)
}
// A plain selector is not diverted to the id match.
if got := len(h.Find("search_omnibox_text_box", false)); got != 0 {
t.Fatalf("plain text must not match a resource id, got %d", got)
}
// Text still works through Find.
if got := len(h.Find("Search", true)); got != 1 {
t.Fatalf("text via Find: want 1, got %d", got)
}
// An unknown id is a clean miss, not a fallthrough to text.
if got := h.Find("id:no_such_id", false); len(got) != 0 {
t.Fatalf("unknown id: want 0, got %d", len(got))
}
}
func TestEscapeInputText(t *testing.T) {
cases := map[string]string{
"hello world": "'hello%sworld'",
"Rolling Stones": "'Rolling%sStones'",
"it's": `'it'\''s'`,
}
for in, want := range cases {
if got := escapeInputText(in); got != want {
t.Errorf("escapeInputText(%q) = %q, want %q", in, got, want)
}
}
// A single quote must not be able to break out of the quoting.
got := escapeInputText("a'; rm -rf /; 'b")
if strings.Count(got, "'")%2 != 0 {
t.Fatalf("unbalanced quotes in %q", got)
}
}