feat: Phase 3 recipes file, spotify_play, and locked-screen refusal

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.
This commit is contained in:
2026-10-16 13:24:22 +11:00
parent 7767651c11
commit 2604fea483
15 changed files with 2192 additions and 23 deletions

View File

@@ -128,6 +128,17 @@ func TapText(r *adb.Runner, query string, exact bool) (*Node, error) {
if len(matches) == 0 {
return nil, fmt.Errorf("element with text %q not found on screen; refusing to tap blindly", query)
}
return TapNode(r, matches)
}
// TapNode taps the centre of the most tappable node among matches.
//
// Callers that already hold a hierarchy use this instead of TapText, so the
// coordinates come from the dump they just read rather than an older one.
func TapNode(r *adb.Runner, matches []*Node) (*Node, error) {
if len(matches) == 0 {
return nil, fmt.Errorf("no element was matched, refusing to tap blindly")
}
node, err := pickBestMatch(matches)
if err != nil {
return nil, err
@@ -170,6 +181,24 @@ func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration)
return nil, fmt.Errorf("text %q did not appear within %s", query, timeout)
}
// WaitForNoText polls the screen until no element matches query. Recipes use it
// to wait for one list to be replaced by another, for example a search result
// list replacing the "Recents" list.
func WaitForNoText(r *adb.Runner, query string, exact bool, timeout time.Duration) error {
if timeout <= 0 {
timeout = 10 * time.Second
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
h, err := Dump(r)
if err == nil && len(h.FindText(query, exact)) == 0 {
return nil
}
time.Sleep(400 * time.Millisecond)
}
return fmt.Errorf("text %q was still on screen after %s", query, timeout)
}
// ScreenSize reads the physical screen size.
func ScreenSize(r *adb.Runner) (width, height int, err error) {
out, err := r.Shell("wm size")
@@ -247,6 +276,10 @@ func Scroll(r *adb.Runner, direction string) error {
}
// Wake makes sure the screen is on before any action (FR-7).
//
// It dismisses the keyguard only when that keyguard is not secure. A secure
// keyguard (PIN, pattern or password) is left exactly as it is: Artemis never
// tries to get past it (AGENTS.md section 13 Q2).
func Wake(r *adb.Runner) error {
if _, err := r.Shell("input keyevent " + strconv.Itoa(KeyWakeup)); err != nil {
return fmt.Errorf("wake keyevent: %w", err)
@@ -255,8 +288,11 @@ func Wake(r *adb.Runner) error {
for time.Now().Before(deadline) {
out, err := r.Shell("dumpsys power | grep mWakefulness=")
if err == nil && strings.Contains(out, "mWakefulness=Awake") {
// Dismiss a non-secure keyguard; harmless when the phone is unlocked.
_, _ = r.Shell("wm dismiss-keyguard")
// Only a non-secure keyguard may be dismissed. If the state cannot
// be read we leave the keyguard alone rather than guess.
if showing, secure, kerr := KeyguardState(r); kerr == nil && (!showing || !secure) {
_, _ = r.Shell("wm dismiss-keyguard")
}
return nil
}
time.Sleep(300 * time.Millisecond)

114
internal/ui/keyguard.go Normal file
View File

@@ -0,0 +1,114 @@
package ui
import (
"errors"
"fmt"
"strings"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
)
// ErrLocked is returned when a secure keyguard is showing. Artemis never tries
// to get past it: no PIN, pattern or password is stored, read or typed
// (AGENTS.md section 13 Q2).
var ErrLocked = errors.New("phone is locked; unlock it and retry")
// KeyguardState reports whether the lock screen is showing and, separately,
// whether the device has a secure lock (PIN, pattern or password) at all.
//
// The two are independent: a phone with no PIN still shows a keyguard when it
// wakes, but that one may be dismissed. A secure one may not.
func KeyguardState(r *adb.Runner) (showing bool, secure bool, err error) {
dump, err := r.Shell("dumpsys window")
if err != nil {
return false, false, fmt.Errorf("read window state: %w", err)
}
showing, err = keyguardShowing(dump)
if err != nil {
return false, false, err
}
secure, err = userHasSecureLock(r)
if err != nil {
return false, false, err
}
return showing, secure, nil
}
// keyguardShowing parses `dumpsys window` for the KeyguardServiceDelegate
// block. An unparsable dump is an error: guessing here would mean acting on a
// phone whose lock state is unknown.
func keyguardShowing(dump string) (bool, error) {
showing := false
sawDelegate := false
sawShowing := false
inDelegate := false
for _, raw := range strings.Split(dump, "\n") {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "KeyguardServiceDelegate"):
inDelegate, sawDelegate = true, true
continue
case strings.HasPrefix(line, "isKeyguardShowing="):
if strings.TrimPrefix(line, "isKeyguardShowing=") == "true" {
showing = true
}
continue
}
if !inDelegate {
continue
}
// The delegate block is a flat list of key=value lines.
if !strings.Contains(line, "=") {
inDelegate = false
continue
}
if strings.HasPrefix(line, "showing=") {
showing = strings.TrimPrefix(line, "showing=") == "true"
sawShowing = true
}
}
if !sawDelegate || !sawShowing {
return false, fmt.Errorf("could not read the keyguard state from dumpsys window")
}
return showing, nil
}
// userHasSecureLock asks the lock settings service whether the current user has
// a secure lock screen. It never asks for or handles the credential itself.
func userHasSecureLock(r *adb.Runner) (bool, error) {
out, err := r.Shell("cmd lock_settings is-user-secure")
if err != nil {
return false, fmt.Errorf("read lock state: %w", err)
}
switch strings.TrimSpace(out) {
case "true":
return true, nil
case "false":
return false, nil
}
return false, fmt.Errorf("unexpected lock state %q from cmd lock_settings is-user-secure", strings.TrimSpace(out))
}
// RequireUnlocked refuses to act while a secure keyguard is showing.
//
// A non-secure keyguard (the phone has no PIN at all) is dismissed, because
// that screen guards nothing. A secure one is left alone: Artemis stops and
// says so.
func RequireUnlocked(r *adb.Runner) error {
showing, secure, err := KeyguardState(r)
if err != nil {
return fmt.Errorf("cannot confirm the phone is unlocked: %w", err)
}
if !showing {
return nil
}
if secure {
return ErrLocked
}
if _, err := r.Shell("wm dismiss-keyguard"); err != nil {
return fmt.Errorf("dismiss the non-secure keyguard: %w", err)
}
return nil
}

View File

@@ -0,0 +1,81 @@
package ui
import "testing"
// A real (trimmed) KeyguardServiceDelegate block from `dumpsys window`.
const keyguardLockedDump = `WINDOW MANAGER POLICY STATE (dumpsys window policy)
mSafeMode=false mSystemReady=true
mKeyguardOccluded=false mPendingKeyguardOccluded=false
KeyguardServiceDelegate
showing=true
inputRestricted=true
occluded=false
trusted=false
simSecure=false
dreaming=false
systemReady=true
deviceHasKeyguard=true
enabled=true
userId=0
bootCompleted=true
screenState=SCREEN_STATE_ON
interactiveState=INTERACTIVE_STATE_AWAKE
Looper state:
Looper (android.ui, tid 22) {4c5c23e}
`
const keyguardUnlockedDump = `WINDOW MANAGER POLICY STATE (dumpsys window policy)
mKeyguardOccluded=false mPendingKeyguardOccluded=false
KeyguardServiceDelegate
showing=false
inputRestricted=false
occluded=false
trusted=false
simSecure=false
dreaming=false
systemReady=true
deviceHasKeyguard=true
enabled=true
userId=0
bootCompleted=true
screenState=SCREEN_STATE_ON
interactiveState=INTERACTIVE_STATE_AWAKE
Looper state:
Looper (android.ui, tid 22) {4c5c23e}
`
func TestKeyguardShowingParsesTheDelegateBlock(t *testing.T) {
showing, err := keyguardShowing(keyguardLockedDump)
if err != nil {
t.Fatalf("keyguardShowing: %v", err)
}
if !showing {
t.Fatal("want showing=true")
}
showing, err = keyguardShowing(keyguardUnlockedDump)
if err != nil {
t.Fatalf("keyguardShowing: %v", err)
}
if showing {
t.Fatal("want showing=false")
}
}
func TestKeyguardShowingRejectsAnUnparsableDump(t *testing.T) {
// Acting on a phone whose lock state is unknown is not allowed.
if _, err := keyguardShowing("dumpsys window: service not found\n"); err == nil {
t.Fatal("expected an error when the delegate block is missing")
}
}
func TestKeyguardShowingReadsIsKeyguardShowing(t *testing.T) {
dump := " KeyguardServiceDelegate\n showing=false\n mDisableSecureWindows=false\n isKeyguardShowing=true\n"
showing, err := keyguardShowing(dump)
if err != nil {
t.Fatalf("keyguardShowing: %v", err)
}
if !showing {
t.Fatal("want showing=true from isKeyguardShowing")
}
}

252
internal/ui/rows.go Normal file
View File

@@ -0,0 +1,252 @@
package ui
import (
"fmt"
"math"
"strings"
"unicode"
)
// Rows are the only safe way to press a list result.
//
// A search result row is a clickable container that holds a title and a
// subtitle ("Honky Tonk Women" / "Song • The Rolling Stones"). The container is
// the text-matched element, and its centre is the tap point: that lands on the
// title, never on the row's "More options" or "Add to library" buttons, which
// sit in a small action bar at the row's right edge.
//
// Selecting the row instead of the smallest clickable child matters. On the
// real Spotify screen a plain text match resolved to a 126x12 pixel "More
// options for song ..." button, because that was the smallest clickable node.
// Row is one pressable list row.
type Row struct {
Node *Node
Texts []string
Score float64
Title string
Top int
Centre Point
}
// Point is a screen coordinate.
type Point struct {
X, Y int
}
// BestRow chooses the row that best matches query.
//
// rowID, when set, restricts candidates to elements with that resource id
// (either the full "package:id/name" form or a suffix of it).
func (h *Hierarchy) BestRow(query, rowID string, screenW, screenH int) (*Row, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("row match needs a non-empty query")
}
qTokens := tokenize(query)
if len(qTokens) == 0 {
return nil, fmt.Errorf("query %q has no matchable words", query)
}
candidates := h.rowCandidates(rowID, screenW, screenH)
if len(candidates) == 0 {
return nil, fmt.Errorf("no result row is fully drawn on screen; refusing to tap blindly")
}
var best *Row
for _, node := range candidates {
rect, err := node.BoundsRect()
if err != nil {
continue
}
texts := rowTexts(node)
score, matched := rowScore(qTokens, texts)
// More than half of the query words must be on the row, and the row
// must not be buried under unrelated words.
if matched*2 < len(qTokens) || score < minRowScore {
continue
}
cx, cy, err := node.Center()
if err != nil {
continue
}
candidate := &Row{
Node: node,
Texts: texts,
Score: score,
Title: texts[0],
Top: rect.Y1,
Centre: Point{X: cx, Y: cy},
}
if best == nil || betterRow(candidate, best) {
best = candidate
}
}
if best == nil {
return nil, fmt.Errorf("no result row matches %q; refusing to tap blindly", query)
}
return best, nil
}
// minRowScore is the F1 floor for an acceptable row match.
const minRowScore = 0.5
// rowCandidates returns the fully drawn rows on screen: clickable containers
// that carry at least a title and a subtitle.
//
// Two shapes are rejected on purpose. A one-line search *suggestion* is not a
// result. And a row clipped by the bottom of the list is much shorter than a
// row that is fully scrolled into view, so its centre is not a point a human
// could press; rows shorter than half the tallest candidate are dropped.
func (h *Hierarchy) rowCandidates(rowID string, screenW, screenH int) []*Node {
var found []*Node
tallest := 0
h.Walk(func(n *Node) {
if !n.Clickable || !n.Enabled || !rowMatchesID(n, rowID) {
return
}
rect, err := n.BoundsRect()
if err != nil || !onScreen(rect, screenW, screenH) {
return
}
if len(rowTexts(n)) < 2 {
return
}
if h := rect.Y2 - rect.Y1; h > tallest {
tallest = h
}
found = append(found, n)
})
var out []*Node
for _, n := range found {
rect, err := n.BoundsRect()
if err != nil {
continue
}
if (rect.Y2-rect.Y1)*2 < tallest {
continue
}
out = append(out, n)
}
return out
}
// betterRow ranks rows: the best text match wins, and equal matches go to the
// row nearest the top, because search results are ranked by relevance.
func betterRow(a, b *Row) bool {
if math.Abs(a.Score-b.Score) > 1e-9 {
return a.Score > b.Score
}
return a.Top < b.Top
}
func rowMatchesID(n *Node, rowID string) bool {
if rowID == "" {
return true
}
return n.ResourceID == rowID || strings.HasSuffix(n.ResourceID, rowID)
}
// textDescendants returns the visible text lines inside a node, in reading
// order, without duplicates. It is used for dumps and diagnostics.
func textDescendants(n *Node) []string {
var out []string
seen := map[string]bool{}
n.Walk(func(c *Node) {
for _, t := range []string{c.Text, c.ContentDesc} {
t = strings.TrimSpace(t)
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
})
return out
}
// rowTexts returns the text that belongs to the row itself: its title and
// subtitle.
//
// Anything inside a clickable child is an action button ("More options", "Add
// to library", "Add suggestion"), not the row's own text, so that subtree is
// skipped. This is what keeps a one-line search suggestion from looking like a
// two-line result.
func rowTexts(n *Node) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
var visit func(c *Node, root bool)
visit = func(c *Node, root bool) {
if !root && c.Clickable {
return
}
add(c.Text)
if !c.Clickable {
add(c.ContentDesc)
}
for i := range c.Children {
visit(&c.Children[i], false)
}
}
visit(n, true)
return out
}
// onScreen reports whether the whole rectangle is inside the display. A row
// clipped by the screen edge would be tapped at a point the user cannot see,
// so it is not a candidate.
func onScreen(r Rect, w, h int) bool {
if w <= 0 || h <= 0 {
return true
}
return r.X1 >= 0 && r.Y1 >= 0 && r.X2 <= w && r.Y2 <= h
}
// rowScore returns the F1 score of the query words against the row's words,
// plus how many distinct query words were found.
func rowScore(query []string, texts []string) (score float64, matched int) {
row := map[string]bool{}
for _, t := range texts {
for _, w := range tokenize(t) {
row[w] = true
}
}
hit := map[string]bool{}
for _, w := range query {
if row[w] {
hit[w] = true
}
}
if len(hit) == 0 || len(row) == 0 {
return 0, 0
}
precision := float64(len(hit)) / float64(len(row))
recall := float64(len(hit)) / float64(len(query))
if precision+recall == 0 {
return 0, len(hit)
}
return 2 * precision * recall / (precision + recall), len(hit)
}
// tokenize splits text into lowercase words.
func tokenize(s string) []string {
fields := strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
out := make([]string, 0, len(fields))
for _, f := range fields {
out = append(out, strings.ToLower(f))
}
return out
}

174
internal/ui/rows_test.go Normal file
View File

@@ -0,0 +1,174 @@
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)
}
}
}