Files
artemis_control/internal/ui/dump_test.go
Paperclip Builder 1115531684 feat: Phase 2 act primitives
Add the Artemis Go CLI and its packages:
- internal/adb: timeout-bounded adb wrapper, device list, wireless-debugging
  port scan and auto-connect (never hardcodes the moving port).
- internal/ui: uiautomator dump parsing, find-by-text, tap the centre of a
  text match (FR-4), type, swipe, scroll, back, home, launch, wake.
- cmd/artemis: CLI for the primitives plus 'demo', the Phase 2 DoD run.

Failures are loud: a missing or disabled element errors instead of tapping a
guessed coordinate. The screen is woken before acting, and every adb call has
a deadline.

Also record the section 13 Q4 decision (adb lives in the user profile on .13,
wireless ADB needs no udev rules; service packaging still open until Phase 6),
add unit tests, and document the phase evidence.
2026-10-16 11:36:03 +11:00

119 lines
4.0 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")
}
}
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)
}
}