Files
artemis_control/internal/ui/rows_test.go
Sam Rolfe c178f92851 feat: confirm playback from the media session, and stop demoting the top row
Phase 3 DoD reached end to end: `artemis run spotify_play query="Rolling
Stones Honky Tonk"` played the right song in 13 steps / 35.3s, with playback
paused first so the pass proves the row tap started it.

Two things stopped it before that, both found on the real phone:

- The playback check asserted on an on-screen "Pause" label that Spotify
  never shows: its transport button keeps the label "Play" while music is
  playing. The recipe now ends with a `playing` step that reads the app's
  media session (state PLAYING) and confirms the track title contains the
  query words. A failure reports the state and the track it found.
- tap_row pressed the wrong row. Once a track is loaded its row carries a
  `Paused` state indicator, and counting that content description as row text
  dropped the top hit below an identical duplicate further down, so the tap
  went to y=1443 instead of y=878. Rows are now scored on their title and
  subtitle only (state lives in content descriptions), and scores within
  rowScoreTolerance are treated as equal so the higher row wins, because
  Spotify already ranks by relevance.

internal/ui/rows_test.go reproduces that results screen, including the state
indicator and the duplicate row.

docs/phase-3-spotify-recipe.md records the successful run, the independent
media-session and audio-output reads, and the failure that preceded it.
2026-10-16 14:32:24 +11:00

218 lines
10 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)
}
}
}