package explore import ( "context" "fmt" "log/slog" "testing" "yellowjacket/backend/database" ) // seedMixTrack inserts one owned track by the given artist (creating // the artist/artist_credit/recording/audio_file chain as needed), // tagged with the given genres. func seedMixTrack( t *testing.T, db *database.DB, id int, artistName, artistMBID string, genreNames ...string, ) string { t.Helper() fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id) database.InsertTestTrack(t, db, database.TestTrack{ FilePath: fp, Title: fmt.Sprintf("Track %d", id), Artist: artistName, ArtistMBID: artistMBID, Genres: genreNames, LengthMs: 180000, }) return fp } // seedSimilarArtist records a pre-computed similarity row, as the // Tier 4 index build / lazy LB fetch would. func seedSimilarArtist( t *testing.T, db *database.DB, sourceMBID, similarMBID, similarName string, score int, ) { t.Helper() _, err := db.ExecContext( "INSERT INTO similar_artist_map "+ "(source_artist_mbid, similar_artist_mbid, similar_artist_name, score) "+ "VALUES (?, ?, ?, ?)", sourceMBID, similarMBID, similarName, score, ) if err != nil { t.Fatalf("insert similar_artist_map row: %v", err) } } func newMixTestService(db *database.DB) *Service { return &Service{db: db, logger: slog.Default()} } func TestGenerateMix_ExpandsToSimilarLibraryArtists(t *testing.T) { t.Parallel() db := database.NewTestDB(t) e := newMixTestService(db) seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed") similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar") unrelatedPath := seedMixTrack(t, db, 3, "Unrelated Artist", "mbid-unrelated") seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90) paths, label, err := e.GenerateMix(context.Background(), []string{seedPath}, false) if err != nil { t.Fatalf("GenerateMix: %v", err) } if len(paths) != 1 || paths[0] != similarPath { t.Errorf("paths: got %v, want [%q]", paths, similarPath) } for _, p := range paths { if p == unrelatedPath { t.Error("mix included a track by an artist with no recorded similarity") } } if label == "" { t.Error("label: got empty string, want a seed-derived label") } } func TestGenerateMix_BoostsSharedGenre(t *testing.T) { t.Parallel() db := database.NewTestDB(t) e := newMixTestService(db) seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed", "Shoegaze") matchingGenrePath := seedMixTrack(t, db, 2, "Similar A", "mbid-similar-a", "Shoegaze") differentGenrePath := seedMixTrack(t, db, 3, "Similar B", "mbid-similar-b", "Ambient") // Same base similarity score for both, so genre is what breaks the tie. seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-a", "Similar A", 50) seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-b", "Similar B", 50) candidates := e.mixCandidates( context.Background(), map[string]int{"mbid-seed": 1}, map[string]bool{"Shoegaze": true}, []string{seedPath}, nil, ) if candidates[matchingGenrePath] <= candidates[differentGenrePath] { t.Errorf( "weight: shared-genre candidate (%v) should outweigh the other (%v)", candidates[matchingGenrePath], candidates[differentGenrePath], ) } } func TestGenerateMix_ExcludesAlreadyPlayed(t *testing.T) { t.Parallel() db := database.NewTestDB(t) e := newMixTestService(db) seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed") similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar") seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90) ctx := context.Background() first, _, err := e.GenerateMix(ctx, []string{seedPath}, false) if err != nil { t.Fatalf("first GenerateMix: %v", err) } if len(first) != 1 || first[0] != similarPath { t.Fatalf("first batch: got %v, want [%q]", first, similarPath) } // Continuing the same session, with nothing new to offer: rather // than dead-ending, it should replay from the pool instead of // returning nothing. second, _, err := e.GenerateMix(ctx, nil, true) if err != nil { t.Fatalf("second GenerateMix: %v", err) } if len(second) != 1 || second[0] != similarPath { t.Errorf( "second batch: got %v, want [%q] (replayed after exhausting the pool)", second, similarPath, ) } } func TestGenerateMix_ContinuingIgnoresNewSeed(t *testing.T) { t.Parallel() db := database.NewTestDB(t) e := newMixTestService(db) originalSeed := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed") _ = seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar") unrelatedSeed := seedMixTrack(t, db, 3, "Other Artist", "mbid-other") similarToUnrelated := seedMixTrack(t, db, 4, "Other Similar", "mbid-other-similar") seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90) seedSimilarArtist(t, db, "mbid-other", "mbid-other-similar", "Other Similar", 90) ctx := context.Background() if _, _, err := e.GenerateMix(ctx, []string{originalSeed}, false); err != nil { t.Fatalf("GenerateMix: %v", err) } // A second, unrelated seed passed while "continuing" is ignored — // the mix stays anchored to what it started with. paths, _, err := e.GenerateMix(ctx, []string{unrelatedSeed}, true) if err != nil { t.Fatalf("GenerateMix (continuing): %v", err) } for _, p := range paths { if p == similarToUnrelated { t.Error("continuing mix drifted to the newly passed seed instead of the original") } } } func TestGenerateMix_NoSeedArtistDataReturnsNothing(t *testing.T) { t.Parallel() db := database.NewTestDB(t) e := newMixTestService(db) // A file path with no matching audio_files row at all. paths, label, err := e.GenerateMix(context.Background(), []string{"/nowhere.mp3"}, false) if err != nil { t.Fatalf("GenerateMix: %v", err) } if len(paths) != 0 || label != "" { t.Errorf("got (%v, %q), want (nil, \"\")", paths, label) } }