package playlists_test import ( "context" "fmt" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" ) // seedGenre creates artists × perArtist tracks tagged genre, each artist on // its own album, and returns the tracks. seedTrack mints a new artist on // every call (an artist without an mbid has no conflict key), so each // artist's first track creates it and the rest join that track's album. func seedGenre(t *testing.T, pool *pgxpool.Pool, genre string, artists, perArtist int) []dbq.Track { t.Helper() var out []dbq.Track for a := 0; a < artists; a++ { first := seedTrack(t, pool, fmt.Sprintf("%s %d-0", genre, a), fmt.Sprintf("%s Artist %d", genre, a)) setGenre(t, pool, first.ID, genre) out = append(out, first) for k := 1; k < perArtist; k++ { tr := seedTrackForArtist(t, pool, fmt.Sprintf("%s %d-%d", genre, a, k), first.AlbumID, first.ArtistID) setGenre(t, pool, tr.ID, genre) out = append(out, tr) } } return out } // TestListGenreMixCandidates_TiersAndSizeFloor: a taste-profile genre is // tier 1 (matched case-insensitively), a genre the user has only played is // tier 2, an untouched one is tier 3, and a genre too small for a mix is not // offered at all. func TestListGenreMixCandidates_TiersAndSizeFloor(t *testing.T) { pool := newPool(t) ctx := context.Background() u := seedUser(t, pool, "gmixcand") now := time.Now().UTC() seedGenre(t, pool, "Shoegaze", 6, 5) kraut := seedGenre(t, pool, "Krautrock", 6, 5) seedGenre(t, pool, "Dub", 6, 5) seedGenre(t, pool, "Zydeco", 2, 5) // 10 tracks, 2 artists: below the floor seedTasteTag(t, pool, u.ID, "shoegaze", 4) seedPlayEvent(t, pool, u.ID, kraut[0].ID, now.Add(-time.Hour), false) rows, err := dbq.New(pool).ListGenreMixCandidates(ctx, u.ID) if err != nil { t.Fatalf("list candidates: %v", err) } want := map[string]int32{"Shoegaze": 1, "Krautrock": 2, "Dub": 3} tier := map[string]int32{} order := []string{} for _, r := range rows { tier[r.Label] = r.Tier if _, mine := want[r.Label]; mine { order = append(order, r.Label) } if r.Label == "Shoegaze" && len(r.ArtistIds) != 6 { t.Errorf("Shoegaze artist_ids = %d, want 6", len(r.ArtistIds)) } } for label, w := range want { if got, ok := tier[label]; !ok || got != w { t.Errorf("%s tier = %d (present %v), want %d", label, got, ok, w) } } if _, ok := tier["Zydeco"]; ok { t.Error("Zydeco (10 tracks, 2 artists) offered as a genre mix") } if strings.Join(order, ",") != "Shoegaze,Krautrock,Dub" { t.Errorf("order = %v, want tier order Shoegaze, Krautrock, Dub", order) } } // TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks: played and // liked tracks come back familiar, the rest unheard; a track skipped more // often than played through is left out, while a favourite with stray skips // stays; tracks of other genres never appear. func TestListGenreMixTracks_SplitsFamiliarAndDropsRejectedTracks(t *testing.T) { pool := newPool(t) ctx := context.Background() u := seedUser(t, pool, "gmixtracks") now := time.Now().UTC() shoegaze := seedGenre(t, pool, "Shoegaze", 6, 5) other := seedGenre(t, pool, "Dub", 3, 2) played, liked, rejected, favourite := shoegaze[0], shoegaze[1], shoegaze[2], shoegaze[3] seedPlayEvent(t, pool, u.ID, played.ID, now.Add(-time.Hour), false) if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, liked.ID); err != nil { t.Fatalf("like: %v", err) } for i := 0; i < 2; i++ { seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-time.Duration(i+2)*time.Hour), true) } for i := 0; i < 5; i++ { seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+10)*time.Hour), false) } for i := 0; i < 2; i++ { seedPlayEvent(t, pool, u.ID, favourite.ID, now.Add(-time.Duration(i+20)*time.Hour), true) } rows, err := dbq.New(pool).ListGenreMixTracks(ctx, dbq.ListGenreMixTracksParams{ UserID: u.ID, DateSeed: "2026-11-05", GenreKey: "shoegaze", }) if err != nil { t.Fatalf("list tracks: %v", err) } familiar := map[pgtype.UUID]bool{} for _, r := range rows { familiar[r.ID] = r.Familiar } if len(rows) != len(shoegaze)-1 { t.Errorf("rows = %d, want %d (every Shoegaze track but the rejected one)", len(rows), len(shoegaze)-1) } for _, tr := range other { if _, in := familiar[tr.ID]; in { t.Fatalf("a Dub track appeared in the Shoegaze pool") } } if _, in := familiar[rejected.ID]; in { t.Error("a track skipped twice and never played through was offered") } for name, tr := range map[string]dbq.Track{"played": played, "liked": liked, "favourite": favourite} { if f, in := familiar[tr.ID]; !in || !f { t.Errorf("%s track: present %v familiar %v, want present and familiar", name, in, f) } } if f := familiar[shoegaze[4].ID]; f { t.Error("an untouched track came back familiar") } } // TestBuildSystemPlaylists_GenreMixes: a full build gives the user a mix for // the genre they listen to, holding only that genre's tracks, with both // halves of the blend stamped. func TestBuildSystemPlaylists_GenreMixes(t *testing.T) { pool := newPool(t) ctx := context.Background() u := seedUser(t, pool, "gmixbuild") now := time.Now().UTC() shoegaze := seedGenre(t, pool, "Shoegaze", 8, 5) seedGenre(t, pool, "Dub", 6, 5) seedTasteTag(t, pool, u.ID, "Shoegaze", 5) for i, tr := range shoegaze[:12] { seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i+1)*time.Hour), false) } if err := playlists.BuildSystemPlaylists(ctx, pool, discardLogger(), u.ID, now, t.TempDir()); err != nil { t.Fatalf("build: %v", err) } var plID pgtype.UUID if err := pool.QueryRow(ctx, ` SELECT id FROM playlists WHERE user_id = $1 AND system_variant = 'genre_mix' AND name = 'Shoegaze mix'`, u.ID).Scan(&plID); err != nil { t.Fatalf("no Shoegaze mix built: %v", err) } inGenre := map[pgtype.UUID]bool{} for _, tr := range shoegaze { inGenre[tr.ID] = true } rows, err := pool.Query(ctx, `SELECT track_id, pick_kind FROM playlist_tracks WHERE playlist_id = $1`, plID) if err != nil { t.Fatalf("list mix tracks: %v", err) } defer rows.Close() kinds := map[string]int{} n := 0 for rows.Next() { var id pgtype.UUID var kind *string if err := rows.Scan(&id, &kind); err != nil { t.Fatalf("scan: %v", err) } n++ if !inGenre[id] { t.Errorf("track %s in the Shoegaze mix is not a Shoegaze track", uuidString(id)) } if kind != nil { kinds[*kind]++ } } if n < 15 { t.Fatalf("Shoegaze mix holds %d tracks, want at least 15", n) } if kinds["taste"] == 0 || kinds["taste_unheard"] == 0 { t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds) } }