Phase 3 of AGENTS.md section 11.
Recipes file (FR-5)
- recipes/ holds one hand-editable file per task; recipes/spotify_play.recipe
is the first one.
- internal/recipe parses directives, guards, parameters and steps, and runs
them with a per-step and a whole-run timeout (FR-11).
- Steps name text ("tap the element whose text is Search"), never coordinates.
- guards name screens Artemis must never tap on, so a signed-out or
first-run Spotify fails with its own reason (FR-8).
- Every run appends task, params, steps, result and duration to
logs/runs.jsonl (FR-10).
spotify_play
- launch, search, play, then wait for the transport button to say "Pause".
- internal/ui/rows.go adds tap_row: a result row is a clickable container with
a title and a subtitle, so a one-line search suggestion is not a result, and
the tap lands on the row's centre rather than on its "More options" button.
This fixes a real wrong tap: a plain text tap hit a 126x12 pixel
"More options for song Honky Tonk Women - Live" button.
Locked screen (AGENTS.md section 13 Q2, decided)
- Artemis fails loudly on a secure keyguard and never types a PIN. No secret is
stored. A non-secure keyguard is still dismissed.
- internal/ui/keyguard.go reads the KeyguardServiceDelegate block plus
cmd lock_settings is-user-secure.
- Every acting command wakes the screen before it checks the keyguard: a dozing
phone reports showing=false, because the lock screen is not drawn yet.
- AGENTS.md records the decision and that storing a PIN for auto-unlock is
rejected for now.
Verified live: the phone locked itself mid-session, and keyguard, tap and
run spotify_play all refused with "phone is locked; unlock it and retry".
The live end-to-end playback run still needs an unlocked phone.
175 lines
8.5 KiB
Go
175 lines
8.5 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// spotifyRowDump is the shape of a real Spotify search-results screen, trimmed
|
|
// to the rows that matter. The first row is a search *suggestion* (one line of
|
|
// text); the rest are result rows (title + subtitle).
|
|
const spotifyRowDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
|
<hierarchy rotation="0">
|
|
<node index="0" class="androidx.recyclerview.widget.RecyclerView" resource-id="com.spotify.music:id/search_content_recyclerview" clickable="false" enabled="true" bounds="[0,251][1080,2400]">
|
|
<node index="0" class="android.view.ViewGroup" resource-id="com.spotify.music:id/row_root" clickable="true" enabled="true" bounds="[0,251][1080,425]">
|
|
<node index="0" class="android.widget.ImageView" resource-id="com.spotify.music:id/icon_view" clickable="false" enabled="true" bounds="[42,296][126,380]"/>
|
|
<node index="1" class="android.widget.TextView" resource-id="com.spotify.music:id/query" text="rolling stones honky tonk women single" clickable="false" enabled="true" bounds="[158,272][954,404]"/>
|
|
<node index="2" class="android.widget.ImageView" resource-id="com.spotify.music:id/tap_ahead_button" content-desc="Add suggestion “rolling stones honky tonk women single”" clickable="true" enabled="true" bounds="[954,296][1080,380]"/>
|
|
</node>
|
|
<node index="1" class="android.view.ViewGroup" resource-id="com.spotify.music:id/row_root" clickable="true" enabled="true" bounds="[0,794][1080,962]">
|
|
<node index="0" class="android.widget.ImageView" resource-id="com.spotify.music:id/artwork" clickable="false" enabled="true" bounds="[42,815][168,941]"/>
|
|
<node index="1" class="android.widget.TextView" resource-id="com.spotify.music:id/title" text="Honky Tonk Women" clickable="false" enabled="true" bounds="[200,815][828,881]"/>
|
|
<node index="2" class="android.widget.TextView" resource-id="com.spotify.music:id/subtitle" text="Song • The Rolling Stones" clickable="false" enabled="true" bounds="[200,886][828,941]"/>
|
|
<node index="3" class="android.view.ViewGroup" resource-id="com.spotify.music:id/action_bar" clickable="false" enabled="true" bounds="[828,815][1080,941]">
|
|
<node index="0" class="android.widget.ImageButton" resource-id="com.spotify.music:id/context_menu_button" content-desc="More options for song Honky Tonk Women" clickable="true" enabled="true" bounds="[828,815][954,941]"/>
|
|
<node index="1" class="android.widget.Button" resource-id="com.spotify.music:id/add_button" content-desc="Add Honky Tonk Women to My Library" clickable="true" enabled="true" bounds="[954,815][1080,941]"/>
|
|
</node>
|
|
</node>
|
|
<node index="2" class="android.view.ViewGroup" resource-id="com.spotify.music:id/row_root" clickable="true" enabled="true" bounds="[0,962][1080,1130]">
|
|
<node index="0" class="android.widget.TextView" resource-id="com.spotify.music:id/title" text="Honky Tonk Women - Mono Version" clickable="false" enabled="true" bounds="[200,983][828,1049]"/>
|
|
<node index="1" class="android.widget.TextView" resource-id="com.spotify.music:id/subtitle" text="Song • The Rolling Stones" clickable="false" enabled="true" bounds="[200,1054][828,1109]"/>
|
|
</node>
|
|
<node index="3" class="android.view.ViewGroup" resource-id="com.spotify.music:id/row_root" clickable="true" enabled="true" bounds="[0,1130][1080,1359]">
|
|
<node index="0" class="android.widget.TextView" resource-id="com.spotify.music:id/title" text="Honky Tonk Woman (The Rolling Stones) Covers" clickable="false" enabled="true" bounds="[200,1151][954,1283]"/>
|
|
<node index="1" class="android.widget.TextView" resource-id="com.spotify.music:id/subtitle" text="Playlist" clickable="false" enabled="true" bounds="[200,1283][954,1338]"/>
|
|
</node>
|
|
<node index="4" class="android.view.ViewGroup" resource-id="com.spotify.music:id/row_root" clickable="true" enabled="true" bounds="[0,2367][1080,2400]">
|
|
<node index="0" class="android.widget.TextView" resource-id="com.spotify.music:id/title" text="Honky Tonk Women - Live" clickable="false" enabled="true" bounds="[200,2388][828,2400]"/>
|
|
<node index="1" class="android.widget.TextView" resource-id="com.spotify.music:id/subtitle" text="Song • The Rolling Stones" clickable="false" enabled="true" bounds="[200,2388][828,2400]"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>`
|
|
|
|
const spotifyRowID = "com.spotify.music:id/row_root"
|
|
|
|
func spotifyRows(t *testing.T) *Hierarchy {
|
|
t.Helper()
|
|
h, err := parseHierarchy([]byte(spotifyRowDump))
|
|
if err != nil {
|
|
t.Fatalf("parseHierarchy: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func TestBestRowPicksTheTopResultOfARealScreen(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
row, err := h.BestRow("Rolling Stones Honky Tonk", spotifyRowID, 1080, 2400)
|
|
if err != nil {
|
|
t.Fatalf("BestRow: %v", err)
|
|
}
|
|
if row.Title != "Honky Tonk Women" {
|
|
t.Fatalf("chose row %q, want the canonical top hit; rows: %v", row.Title, row.Texts)
|
|
}
|
|
// The tap must land on the row, on the title, not on a row button.
|
|
if row.Centre.X < 200 || row.Centre.X > 828 {
|
|
t.Fatalf("tap x=%d is outside the title", row.Centre.X)
|
|
}
|
|
if row.Centre.Y < 794 || row.Centre.Y > 962 {
|
|
t.Fatalf("tap y=%d is outside the row", row.Centre.Y)
|
|
}
|
|
}
|
|
|
|
func TestBestRowIgnoresSearchSuggestions(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
// The suggestion row carries every query word, but it is a single line of
|
|
// text, so it is not a result row.
|
|
query := "rolling stones honky tonk women single"
|
|
row, err := h.BestRow(query, spotifyRowID, 1080, 2400)
|
|
if err != nil {
|
|
t.Fatalf("BestRow: %v", err)
|
|
}
|
|
if row.Title == "rolling stones honky tonk women single" {
|
|
t.Fatal("chose the search suggestion; a suggestion has no subtitle and is not a result")
|
|
}
|
|
}
|
|
|
|
func TestBestRowIgnoresRowsClippedByTheScreenEdge(t *testing.T) {
|
|
// The last row is cut off at the bottom of the list: only 33 pixels tall,
|
|
// with its centre underneath the navigation area. It matches the query
|
|
// better than any other row, and it must still lose, because tapping it
|
|
// would press something the user cannot see.
|
|
h := spotifyRows(t)
|
|
row, err := h.BestRow("Honky Tonk Women Live", spotifyRowID, 1080, 2400)
|
|
if err != nil {
|
|
t.Fatalf("BestRow: %v", err)
|
|
}
|
|
if row.Title == "Honky Tonk Women - Live" {
|
|
t.Fatal("chose a row clipped by the bottom of the list")
|
|
}
|
|
}
|
|
|
|
func TestBestRowRefusesAnUnknownQuery(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
if _, err := h.BestRow("Chopin Nocturne", spotifyRowID, 1080, 2400); err == nil {
|
|
t.Fatal("expected an error when no row matches the query")
|
|
}
|
|
}
|
|
|
|
func TestBestRowWorksWithoutARowID(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
row, err := h.BestRow("The Rolling Stones Honky Tonk Women", "", 1080, 2400)
|
|
if err != nil {
|
|
t.Fatalf("BestRow without a row id: %v", err)
|
|
}
|
|
if row.Title != "Honky Tonk Women" {
|
|
t.Fatalf("chose %q", row.Title)
|
|
}
|
|
}
|
|
|
|
func TestRowScorePrefersPrecision(t *testing.T) {
|
|
exact, _ := rowScore(tokenize("honky tonk women"), []string{"Honky Tonk Women", "Song • The Rolling Stones"})
|
|
noisy, _ := rowScore(tokenize("honky tonk women"), []string{"Honky Tonk Women Live Licks Tour 2009 Remastered Deluxe Edition", "Song • The Rolling Stones, Sheryl Crow"})
|
|
if exact <= noisy {
|
|
t.Fatalf("an exact title (%f) must beat a noisy one (%f)", exact, noisy)
|
|
}
|
|
}
|
|
|
|
func TestRowTextsSkipActionButtons(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
|
|
// The suggestion row: one line of text plus an "Add suggestion" button.
|
|
var suggestion *Node
|
|
h.Walk(func(n *Node) {
|
|
if n.ResourceID == spotifyRowID && n.Bounds == "[0,251][1080,425]" {
|
|
suggestion = n
|
|
}
|
|
})
|
|
if suggestion == nil {
|
|
t.Fatal("suggestion row not found in the sample dump")
|
|
}
|
|
texts := rowTexts(suggestion)
|
|
if len(texts) != 1 {
|
|
t.Fatalf("a suggestion row's own text = %v, want 1 line", texts)
|
|
}
|
|
|
|
// A result row: title + subtitle, without the row's action buttons.
|
|
var result *Node
|
|
h.Walk(func(n *Node) {
|
|
if n.ResourceID == spotifyRowID && n.Bounds == "[0,794][1080,962]" {
|
|
result = n
|
|
}
|
|
})
|
|
texts = rowTexts(result)
|
|
if len(texts) != 2 || texts[0] != "Honky Tonk Women" || texts[1] != "Song • The Rolling Stones" {
|
|
t.Fatalf("a result row's own text = %v", texts)
|
|
}
|
|
for _, bad := range texts {
|
|
if strings.Contains(bad, "More options") || strings.Contains(bad, "Add ") {
|
|
t.Fatalf("row text %q came from an action button", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTokenize(t *testing.T) {
|
|
got := tokenize("Honky Tonk Women - Mono Version (Remastered)")
|
|
want := []string{"honky", "tonk", "women", "mono", "version", "remastered"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("tokenize = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("tokenize = %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|