release / web (push) Successful in 1m25s
release / go (push) Successful in 1m49s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 4m52s
release / android (push) Successful in 4m22s
release / Build signed APK (push) Successful in 4m46s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m12s
release / Verify release artifacts (tag releases only) (push) Skipped
seedTrack mints a new artist per call when there is no mbid, so the genre fixtures held one artist per track and the candidate query's artist_ids counted 30 where the test meant 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
6.7 KiB
Go
203 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|