spotify_radio opens a track's own context menu and starts its song radio.
The labels came from a fresh uiautomator dump on the phone, not from a guess:
the entry is "Go to song radio" and it sits below the fold, so the recipe
scrolls before it matches.
Recipe format:
- menu_row "{query}" finds the row like tap_row, then presses the action
button inside that row's own subtree (ui.Row.Action), so the More options
button of any other row is never a candidate. It records the row as {row}.
- a step can name its own failure: wait ... else "reason". The named reason
comes first and the screen's labels still follow (FR-8).
- scroll up|down moves the current list.
- playing with no title waits for the media session to be PLAYING. A radio
changes track every few minutes, so a title match would fail on a working
radio; spotify_play still passes the title it wants.
ui:
- pickBestMatch ignores a match with no usable bounds. An element scrolled out
of its list reports [0,0][0,0], and tapping its "centre" would be a blind tap
in the corner. A wait now only succeeds once the element is on screen.
Run log (FR-10): appendRunLog rotates runs.jsonl when a write would pass
ARTEMIS_LOG_MAX_BYTES (default 1 MiB), keeping ARTEMIS_LOG_KEEP (default 5)
files. History is bounded, never deleted wholesale, and the per-entry cap of
6 screen labels of 60 characters is unchanged.
Live evidence in docs/phase-4-song-radio.md: ok:true in 56.6s, the menu that
opened belonged to the pressed row, the station name "Honky Tonk Women Radio"
on the phone, and the radio playing past its seed track (Layla) per the media
session and dumpsys audio. Both new failure modes were demonstrated failing
with distinct reasons. The mini-bar's transport label stays "Play" while
playing on this build, which is why playback is confirmed from the media
session.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
269 lines
12 KiB
Go
269 lines
12 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.view.ViewGroup" resource-id="com.spotify.music:id/play_indicator" clickable="false" enabled="true" bounds="[200,832][232,864]">
|
|
<node index="0" class="android.view.View" content-desc="Paused" clickable="false" enabled="true" bounds="[200,832][232,864]"/>
|
|
</node>
|
|
<node index="2" class="android.widget.TextView" resource-id="com.spotify.music:id/title" text="Honky Tonk Women" clickable="false" enabled="true" bounds="[243,815][828,881]"/>
|
|
<node index="3" 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="4" 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)
|
|
}
|
|
}
|
|
|
|
// TestBestRowPrefersTheTopHitWhenItsRowCarriesStateText is the regression that
|
|
// cost the most: the top hit's row carries a "Paused" state indicator once it is
|
|
// the loaded track, and counting that description as row text pushed the score
|
|
// below an identical duplicate further down the list. tap_row then pressed the
|
|
// duplicate at y=1443 and playback never started.
|
|
func TestBestRowPrefersTheTopHitWhenItsRowCarriesStateText(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 %q", row.Title)
|
|
}
|
|
if row.Top != 794 {
|
|
t.Fatalf("chose the row at y=%d, want the top hit at y=794", row.Top)
|
|
}
|
|
if row.Centre.Y != (794+962)/2 {
|
|
t.Fatalf("tap point y=%d is not the top row's centre", row.Centre.Y)
|
|
}
|
|
}
|
|
|
|
func TestRowTextsIgnoreStateDescriptions(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
var top *Node
|
|
h.Walk(func(n *Node) {
|
|
if n.ResourceID == spotifyRowID && n.Bounds == "[0,794][1080,962]" {
|
|
top = n
|
|
}
|
|
})
|
|
if top == nil {
|
|
t.Fatal("top row not found in the sample dump")
|
|
}
|
|
for _, line := range rowTexts(top) {
|
|
if line == "Paused" {
|
|
t.Fatal("the row's state description must not count as row text")
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRowActionFindsTheButtonsInsideTheChosenRow is the safety property behind
|
|
// menu_row: the More options button is taken from the row's own subtree, so a
|
|
// recipe can never press the button of a different row.
|
|
func TestRowActionFindsTheButtonsInsideTheChosenRow(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 %q", row.Title)
|
|
}
|
|
|
|
button, err := row.Action("More options")
|
|
if err != nil {
|
|
t.Fatalf("Action: %v", err)
|
|
}
|
|
// The top hit's menu button, not the duplicate row's at y=1380.
|
|
if button.Bounds != "[828,815][954,941]" {
|
|
t.Fatalf("chose button %s, want the top row's", button.Bounds)
|
|
}
|
|
if !strings.Contains(button.ContentDesc, "More options for song Honky Tonk Women") {
|
|
t.Fatalf("chose %q", button.ContentDesc)
|
|
}
|
|
|
|
// A label the row does not carry fails instead of falling back to any
|
|
// clickable node.
|
|
if _, err := row.Action("Share"); err == nil {
|
|
t.Fatal("expected an error for a label the row does not have")
|
|
}
|
|
}
|
|
|
|
// TestRowActionRefusesARowWithoutAButton covers a result row that has no action
|
|
// bar at all: the step must fail, not tap the row itself.
|
|
func TestRowActionRefusesARowWithoutAButton(t *testing.T) {
|
|
h := spotifyRows(t)
|
|
var bare *Node
|
|
h.Walk(func(n *Node) {
|
|
if n.ResourceID == spotifyRowID && n.Bounds == "[0,962][1080,1130]" {
|
|
bare = n
|
|
}
|
|
})
|
|
if bare == nil {
|
|
t.Fatal("the sample row without an action bar was not found")
|
|
}
|
|
row := &Row{Node: bare, Title: "Honky Tonk Women - Mono Version"}
|
|
if _, err := row.Action("More options"); err == nil {
|
|
t.Fatal("a row with no action bar must not yield a button")
|
|
}
|
|
}
|