d212621eaa
Latent failures exposed now that integration tests run in CI (#339). All test/test-harness only — no production code changes. A (dbtest.ResetDB): also truncate cover_art_provider_settings + cover_art_sources_meta. The monotonic source-version counter (seeded 1 by 0018) accumulated across internal/coverart tests → CurrentVersion=4 want 1, key-only-bump assertions, enricher source skips. SettingsService re-seeds both at boot, so a truncated start is the correct fresh state. B (playlists system_test.seedPlayEvent): inserted play_events with a random session_id → play_events_session_id_fkey violation (7 tests). Create the parent play_sessions row in the same statement (CTE). C (similarity worker_integration_test.newTestWorker): built the client with only BaseURL. Post-4fca0e6 similarity hits the Labs API via LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub. F (library scanner_test): NOT a flake — deterministic. Synthetic ID3-only MP3s have no decodable duration; the scanner intentionally won't skip duration_ms=0 rows (retries duration backfill), so every re-scan reported Updated not Skipped. Seed duration_ms>0 before the second scan so the mtime-based incremental-skip path under test is actually exercised. Production scanner behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
470 lines
15 KiB
Go
470 lines
15 KiB
Go
package similarity
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
|
)
|
|
|
|
func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping in -short mode")
|
|
}
|
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
dbtest.ResetDB(t, pool)
|
|
return pool, dbq.New(pool)
|
|
}
|
|
|
|
type fixture struct {
|
|
pool *pgxpool.Pool
|
|
q *dbq.Queries
|
|
user pgtype.UUID
|
|
artist dbq.Artist
|
|
album dbq.Album
|
|
}
|
|
|
|
func newFixture(t *testing.T) fixture {
|
|
t.Helper()
|
|
pool, q := testPool(t)
|
|
ctx := context.Background()
|
|
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
|
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("user: %v", err)
|
|
}
|
|
artistMbid := "aaaaaaaa-1111-1111-1111-111111111111"
|
|
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
|
Name: "X", SortName: "X", Mbid: &artistMbid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
|
|
Title: "X", SortTitle: "X", ArtistID: a.ID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("album: %v", err)
|
|
}
|
|
return fixture{pool: pool, q: q, user: u.ID, artist: a, album: al}
|
|
}
|
|
|
|
func seedTrack(t *testing.T, f fixture, title string, mbid *string) dbq.Track {
|
|
t.Helper()
|
|
tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: title,
|
|
AlbumID: f.album.ID,
|
|
ArtistID: f.artist.ID,
|
|
FilePath: "/tmp/" + title + ".flac",
|
|
DurationMs: 200_000,
|
|
Mbid: mbid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("track: %v", err)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) {
|
|
t.Helper()
|
|
var sessionID pgtype.UUID
|
|
if err := f.pool.QueryRow(context.Background(),
|
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
|
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
|
|
f.user).Scan(&sessionID); err != nil {
|
|
t.Fatalf("session: %v", err)
|
|
}
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
|
|
VALUES ($1, $2, $3, now() - interval '1 minute', now(), 250000, 0.83, false)`,
|
|
f.user, trackID, sessionID); err != nil {
|
|
t.Fatalf("play_event: %v", err)
|
|
}
|
|
}
|
|
|
|
func newTestWorker(f fixture, lbBaseURL string) *Worker {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return &Worker{
|
|
pool: f.pool,
|
|
// LabsBaseURL must point at the stub too: similarity calls hit
|
|
// the Labs API (commit 4fca0e6), so an unset LabsBaseURL would
|
|
// fall through to the real labs.api and the stub never runs.
|
|
client: &listenbrainz.Client{BaseURL: lbBaseURL, LabsBaseURL: lbBaseURL, HTTP: http.DefaultClient},
|
|
logger: logger,
|
|
tick: 1 * time.Hour,
|
|
batch: 5,
|
|
topK: 20,
|
|
}
|
|
}
|
|
|
|
// stubLB returns an httptest server that responds to similar-recordings and
|
|
// similar-artists with the given JSON payloads.
|
|
func stubLB(recordingsBody, artistsBody string, status int) *httptest.Server {
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if status != 0 && status != http.StatusOK {
|
|
w.WriteHeader(status)
|
|
return
|
|
}
|
|
if strings.Contains(r.URL.Path, "/similar-recordings/") {
|
|
_, _ = w.Write([]byte(recordingsBody))
|
|
return
|
|
}
|
|
if strings.Contains(r.URL.Path, "/similar-artists/") {
|
|
_, _ = w.Write([]byte(artistsBody))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
}
|
|
|
|
func countTrackSim(t *testing.T, f fixture, a pgtype.UUID) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := f.pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, a).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func countArtistSim(t *testing.T, f fixture, a pgtype.UUID) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := f.pool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1`, a).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestTickOnce_NoPlayedTracks_NoOp(t *testing.T) {
|
|
f := newFixture(t)
|
|
srv := stubLB(`[]`, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
var n int
|
|
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity`).Scan(&n)
|
|
if n != 0 {
|
|
t.Errorf("track_similarity rows = %d, want 0", n)
|
|
}
|
|
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity`).Scan(&n)
|
|
if n != 0 {
|
|
t.Errorf("artist_similarity rows = %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbidA := "11111111-1111-1111-1111-111111111111"
|
|
mbidB := "22222222-2222-2222-2222-222222222222"
|
|
mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library
|
|
trackA := seedTrack(t, f, "A", &mbidA)
|
|
_ = seedTrack(t, f, "B", &mbidB)
|
|
markPlayed(t, f, trackA.ID)
|
|
body := `[
|
|
{"recording_mbid": "` + mbidB + `", "score": 0.9},
|
|
{"recording_mbid": "` + mbidC + `", "score": 0.7}
|
|
]`
|
|
srv := stubLB(body, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
if got := countTrackSim(t, f, trackA.ID); got != 1 {
|
|
t.Errorf("track_similarity rows = %d, want 1 (only mbidB is in-library)", got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_TopKEnforced(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbidSeed := "11111111-1111-1111-1111-111111111111"
|
|
seed := seedTrack(t, f, "Seed", &mbidSeed)
|
|
markPlayed(t, f, seed.ID)
|
|
type lbRow struct {
|
|
MBID string `json:"recording_mbid"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
rows := make([]lbRow, 0, 25)
|
|
for i := 0; i < 25; i++ {
|
|
mbid := fmt.Sprintf("20000000-0000-0000-0000-%012d", i+1)
|
|
_ = seedTrack(t, f, fmt.Sprintf("T%02d", i+1), &mbid)
|
|
rows = append(rows, lbRow{MBID: mbid, Score: 1.0 - 0.01*float64(i)})
|
|
}
|
|
body, _ := json.Marshal(rows)
|
|
srv := stubLB(string(body), `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
if got := countTrackSim(t, f, seed.ID); got != 20 {
|
|
t.Errorf("got %d rows, want exactly 20 (all 25 in-library; top 20 by score)", got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_RespectsSevenDayCap(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbid := "11111111-1111-1111-1111-111111111111"
|
|
trackA := seedTrack(t, f, "A", &mbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
otherMbid := "55555555-5555-5555-5555-555555555555"
|
|
other := seedTrack(t, f, "Other", &otherMbid)
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
|
|
VALUES ($1, $2, 0.5, 'listenbrainz', now())`, trackA.ID, other.ID); err != nil {
|
|
t.Fatalf("seed sim: %v", err)
|
|
}
|
|
beforeCount := countTrackSim(t, f, trackA.ID)
|
|
srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.99}]`, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
_ = w.tickOnce(context.Background())
|
|
if got := countTrackSim(t, f, trackA.ID); got != beforeCount {
|
|
t.Errorf("worker re-queried fresh track: before=%d after=%d", beforeCount, got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_RefreshesStaleRow(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbid := "11111111-1111-1111-1111-111111111111"
|
|
trackA := seedTrack(t, f, "A", &mbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
otherMbid := "55555555-5555-5555-5555-555555555555"
|
|
other := seedTrack(t, f, "Other", &otherMbid)
|
|
if _, err := f.pool.Exec(context.Background(),
|
|
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
|
|
VALUES ($1, $2, 0.5, 'listenbrainz', now() - interval '8 days')`, trackA.ID, other.ID); err != nil {
|
|
t.Fatalf("seed sim: %v", err)
|
|
}
|
|
srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.95}]`, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
var score float64
|
|
var fetchedAt time.Time
|
|
_ = f.pool.QueryRow(context.Background(),
|
|
`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
|
|
trackA.ID, other.ID).Scan(&score, &fetchedAt)
|
|
if score != 0.95 {
|
|
t.Errorf("score = %v, want 0.95 (refreshed)", score)
|
|
}
|
|
if time.Since(fetchedAt) > time.Minute {
|
|
t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt))
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_429AbortsTick(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbid := "11111111-1111-1111-1111-111111111111"
|
|
trackA := seedTrack(t, f, "A", &mbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Retry-After", "60")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
}))
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
_ = w.tickOnce(context.Background())
|
|
if got := countTrackSim(t, f, trackA.ID); got != 0 {
|
|
t.Errorf("rows on 429 = %d, want 0 (tick aborted before any inserts)", got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_TransientErrorSkipsTrack(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbidA := "11111111-1111-1111-1111-111111111111"
|
|
mbidB := "22222222-2222-2222-2222-222222222222"
|
|
otherMbid := "55555555-5555-5555-5555-555555555555"
|
|
trackA := seedTrack(t, f, "A", &mbidA)
|
|
trackB := seedTrack(t, f, "B", &mbidB)
|
|
_ = seedTrack(t, f, "Other", &otherMbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
markPlayed(t, f, trackB.ID)
|
|
var seen atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.URL.Path, "/similar-recordings/") {
|
|
_, _ = w.Write([]byte(`[]`))
|
|
return
|
|
}
|
|
n := seen.Add(1)
|
|
if n == 1 {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`[{"recording_mbid":"` + otherMbid + `","score":0.7}]`))
|
|
}))
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
_ = w.tickOnce(context.Background())
|
|
a := countTrackSim(t, f, trackA.ID)
|
|
b := countTrackSim(t, f, trackB.ID)
|
|
if a+b != 1 {
|
|
t.Errorf("expected exactly one track to succeed: a=%d b=%d", a, b)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_FiltersInLibrary(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbid := "11111111-1111-1111-1111-111111111111"
|
|
trackA := seedTrack(t, f, "A", &mbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
body := `[
|
|
{"recording_mbid":"99999999-9999-9999-9999-999999999991","score":0.9},
|
|
{"recording_mbid":"99999999-9999-9999-9999-999999999992","score":0.8},
|
|
{"recording_mbid":"99999999-9999-9999-9999-999999999993","score":0.7}
|
|
]`
|
|
srv := stubLB(body, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
if got := countTrackSim(t, f, trackA.ID); got != 0 {
|
|
t.Errorf("filtered out-of-library: got %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_ArtistPassMirrors(t *testing.T) {
|
|
f := newFixture(t)
|
|
mbid := "11111111-1111-1111-1111-111111111111"
|
|
trackA := seedTrack(t, f, "A", &mbid)
|
|
markPlayed(t, f, trackA.ID)
|
|
bMbid := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
_, err := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
|
Name: "Other", SortName: "Other", Mbid: &bMbid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
body := `[{"artist_mbid":"` + bMbid + `","score":0.85}]`
|
|
srv := stubLB(`[]`, body, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
if got := countArtistSim(t, f, f.artist.ID); got != 1 {
|
|
t.Errorf("artist_similarity rows = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) {
|
|
f := newFixture(t)
|
|
trackNoMbid := seedTrack(t, f, "NoMbid", nil)
|
|
markPlayed(t, f, trackNoMbid.ID)
|
|
srv := stubLB(`[{"recording_mbid":"11111111-1111-1111-1111-111111111111","score":0.9}]`, `[]`, http.StatusOK)
|
|
defer srv.Close()
|
|
w := newTestWorker(f, srv.URL)
|
|
if err := w.tickOnce(context.Background()); err != nil {
|
|
t.Fatalf("tickOnce: %v", err)
|
|
}
|
|
if got := countTrackSim(t, f, trackNoMbid.ID); got != 0 {
|
|
t.Errorf("no-MBID track produced rows: %d", got)
|
|
}
|
|
}
|
|
|
|
// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls
|
|
// upsertArtistSimilar directly with a mix of in-library and out-of-library
|
|
// similar-artist payloads; verifies the matched path lands in
|
|
// artist_similarity (1 row), the unmatched path lands in
|
|
// artist_similarity_unmatched (3 rows), and rows with empty Name are
|
|
// skipped (we can't render a suggestion without a name).
|
|
func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
|
f := newFixture(t)
|
|
ctx := context.Background()
|
|
|
|
// Seed one additional in-library artist that will be the in-library match.
|
|
inLibMBID := "in-lib-mbid-123"
|
|
inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
|
Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed in-lib artist: %v", err)
|
|
}
|
|
|
|
w := newTestWorker(f, "")
|
|
|
|
similars := []listenbrainz.SimilarArtist{
|
|
{MBID: inLibMBID, Name: "InLib Artist", Score: 0.95},
|
|
{MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85},
|
|
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80},
|
|
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70},
|
|
{MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped
|
|
}
|
|
w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)
|
|
|
|
// Matched path: 1 row in artist_similarity for the in-library candidate.
|
|
var matchedCount int
|
|
if err := f.pool.QueryRow(ctx,
|
|
"SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1",
|
|
f.artist.ID,
|
|
).Scan(&matchedCount); err != nil {
|
|
t.Fatalf("count matched: %v", err)
|
|
}
|
|
if matchedCount != 1 {
|
|
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
|
|
}
|
|
|
|
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
|
|
var unmatchedCount int
|
|
if err := f.pool.QueryRow(ctx,
|
|
"SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1",
|
|
f.artist.ID,
|
|
).Scan(&unmatchedCount); err != nil {
|
|
t.Fatalf("count unmatched: %v", err)
|
|
}
|
|
if unmatchedCount != 3 {
|
|
t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount)
|
|
}
|
|
|
|
// Verify a specific row's name + score round-tripped correctly.
|
|
var name string
|
|
var score float64
|
|
if err := f.pool.QueryRow(ctx,
|
|
"SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2",
|
|
f.artist.ID, "out-mbid-1",
|
|
).Scan(&name, &score); err != nil {
|
|
t.Fatalf("fetch out-mbid-1: %v", err)
|
|
}
|
|
if name != "Outsider One" || score != 0.85 {
|
|
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
|
|
}
|
|
|
|
_ = inLibArtist
|
|
}
|