package playlists import ( "testing" "github.com/jackc/pgx/v5/pgtype" ) // genreTracks builds n tracks, each on its own album and artist (so the // caps never bind), numbered from start. func genreTracks(start, n int, familiar bool) []genreTrack { out := make([]genreTrack, n) for i := range out { id := uuidN(start + i) out[i] = genreTrack{ discoverTrack: discoverTrack{ID: id, AlbumID: id, ArtistID: id}, Familiar: familiar, } } return out } func kindsOf(tracks []rankedCandidate) string { s := make([]byte, len(tracks)) for i, t := range tracks { switch t.PickKind { case pickKindTaste: s[i] = 'F' case pickKindTasteUnheard: s[i] = 'U' default: s[i] = '?' } } return string(s) } func TestComposeGenreMix_InterleavesThreeFamiliarToTwoUnheard(t *testing.T) { tracks := append(genreTracks(1, 20, true), genreTracks(100, 20, false)...) got := composeGenreMix(tracks, 10) if want := "FFUFUFFUFU"; kindsOf(got) != want { t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) } } func TestComposeGenreMix_ShortSideIsFilledFromTheOther(t *testing.T) { // Two familiar tracks only: the rest of the mix is unheard, not short. tracks := append(genreTracks(1, 2, true), genreTracks(100, 20, false)...) got := composeGenreMix(tracks, 10) if len(got) != 10 { t.Fatalf("len = %d, want 10", len(got)) } if want := "FFUUUUUUUU"; kindsOf(got) != want { t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) } // No unheard tracks at all: an all-familiar mix. got = composeGenreMix(genreTracks(1, 12, true), 10) if want := "FFFFFFFFFF"; kindsOf(got) != want { t.Fatalf("pick kinds = %s, want %s", kindsOf(got), want) } } func TestComposeGenreMix_CapsOneArtistThenTopsUpANarrowGenre(t *testing.T) { // One artist, one album, 8 tracks, plus 4 single-track artists. The cap // keeps 2 of the big album; the top-up refills from it so the mix still // reaches 8. big := genreTracks(1, 8, false) for i := range big { big[i].AlbumID = uuidN(500) big[i].ArtistID = uuidN(500) } tracks := append(big, genreTracks(100, 4, false)...) got := composeGenreMix(tracks, 8) if len(got) != 8 { t.Fatalf("len = %d, want 8", len(got)) } // The first six are the capped pool: 2 from the big album, then the 4 // singles — the big album does not lead the mix wholesale. bigID := map[pgtype.UUID]bool{} for _, b := range big { bigID[b.ID] = true } inHead := 0 for _, c := range got[:6] { if bigID[c.TrackID] { inHead++ } } if inHead != 2 { t.Fatalf("big-album tracks in the capped head = %d, want 2", inHead) } } // songs returns the track ids from..to inclusive. func songs(from, to int) []pgtype.UUID { out := make([]pgtype.UUID, 0, to-from+1) for n := from; n <= to; n++ { out = append(out, uuidN(n)) } return out } func genreOf(label string, tier int32, ids ...[]pgtype.UUID) genreCandidate { var all []pgtype.UUID for _, part := range ids { all = append(all, part...) } return genreCandidate{Key: label, Label: label, Tier: tier, TrackIDs: all} } // A genre whose songs mostly sit in one already picked is the same channel // under another name: Lo-Fi Hip Hop after Lo-Fi. func TestSelectGenreMixes_AGenreOfMostlyPickedSongsGivesWay(t *testing.T) { cands := []genreCandidate{ genreOf("Lo-Fi", 1, songs(1, 100)), genreOf("Lo-Fi Hip Hop", 1, songs(1, 80), songs(500, 510)), genreOf("Jazz", 1, songs(1000, 1100)), } got := labels(selectGenreMixes(cands, buildN(30))) if len(got) != 2 || got[0] != "Lo-Fi mix" || got[1] != "Jazz mix" { t.Fatalf("mixes = %v, want [Lo-Fi mix Jazz mix]", got) } } // A broad genre whose songs mostly carry narrower genres gives way to them, // even when it ranks higher, and each later mix leaves out the songs of the // genres picked before it. func TestSelectGenreMixes_ABroadGenreGivesWayToItsSpecificOnes(t *testing.T) { cands := []genreCandidate{ genreOf("Rock", 1, songs(1, 1000)), genreOf("Pop Punk", 1, songs(1, 300)), genreOf("Ska Punk", 1, songs(250, 600)), } excluded := map[string][]string{} build := func(c genreCandidate, exclude []string) []rankedCandidate { excluded[c.Label] = append([]string(nil), exclude...) return make([]rankedCandidate, 30) } got := labels(selectGenreMixes(cands, build)) if len(got) != 2 || got[0] != "Pop Punk mix" || got[1] != "Ska Punk mix" { t.Fatalf("mixes = %v, want [Pop Punk mix Ska Punk mix]", got) } if ex := excluded["Ska Punk"]; len(ex) != 1 || ex[0] != "Pop Punk" { t.Fatalf("Ska Punk mix excludes %v, want [Pop Punk]", ex) } if ex := excluded["Pop Punk"]; len(ex) != 0 { t.Fatalf("first mix excludes %v, want nothing", ex) } } // A genre only partly covered by narrower ones is a channel of its own, and // keeps its rank. func TestSelectGenreMixes_AGenreMostlyItsOwnKeepsItsRank(t *testing.T) { cands := []genreCandidate{ genreOf("Rock", 1, songs(1, 2000)), genreOf("Pop Punk", 1, songs(1, 300)), genreOf("Jazz", 1, songs(3000, 3100)), } got := labels(selectGenreMixes(cands, buildN(30))) if len(got) != 2 || got[0] != "Rock mix" || got[1] != "Jazz mix" { t.Fatalf("mixes = %v, want [Rock mix Jazz mix]", got) } } // buildN returns a builder that gives every genre n tracks, except those // listed in short, which get too few to make a mix. func buildN(n int, short ...string) func(genreCandidate, []string) []rankedCandidate { thin := map[string]bool{} for _, s := range short { thin[s] = true } return func(c genreCandidate, _ []string) []rankedCandidate { if thin[c.Label] { return make([]rankedCandidate, genreMixMinLen-1) } return make([]rankedCandidate, n) } } func labels(built []builtPlaylist) []string { out := make([]string, len(built)) for i, b := range built { out[i] = b.Name } return out } // distinctGenre is a genre of 100 songs of its own, numbered from n*100. func distinctGenre(label string, tier int32, n int) genreCandidate { return genreOf(label, tier, songs(n*100, n*100+99)) } func TestSelectGenreMixes_TakesTheUsersGenresAndStopsAtTheCount(t *testing.T) { var cands []genreCandidate for i := 0; i < 14; i++ { cands = append(cands, distinctGenre(string(rune('A'+i)), 1, i+1)) } got := selectGenreMixes(cands, buildN(30)) if len(got) != genreMixCount { t.Fatalf("built %d mixes, want %d", len(got), genreMixCount) } if got[0].Name != "A mix" || got[0].Variant != "genre_mix" { t.Fatalf("first mix = %q (%s), want \"A mix\" (genre_mix)", got[0].Name, got[0].Variant) } } func TestSelectGenreMixes_ThinGenreGivesWayToTheNext(t *testing.T) { cands := []genreCandidate{ distinctGenre("Jazz", 1, 1), distinctGenre("Dub", 1, 2), distinctGenre("Soul", 2, 3), } got := labels(selectGenreMixes(cands, buildN(30, "Dub"))) if len(got) != 2 || got[0] != "Jazz mix" || got[1] != "Soul mix" { t.Fatalf("mixes = %v, want [Jazz mix Soul mix]", got) } } func TestSelectGenreMixes_LibraryGenresOnlyFillAShortShelf(t *testing.T) { // Two listened genres: tier 3 tops the shelf up to genreMixMinShelf. cands := []genreCandidate{ distinctGenre("Jazz", 1, 1), distinctGenre("Soul", 2, 2), distinctGenre("Polka", 3, 3), distinctGenre("Opera", 3, 4), distinctGenre("Ska", 3, 5), } got := labels(selectGenreMixes(cands, buildN(30))) if len(got) != genreMixMinShelf { t.Fatalf("mixes = %v, want %d", got, genreMixMinShelf) } // Enough listened genres: no tier-3 genre at all. var full []genreCandidate for i := 0; i < genreMixMinShelf; i++ { full = append(full, distinctGenre(string(rune('A'+i)), 1, i+1)) } full = append(full, distinctGenre("Polka", 3, 99)) for _, name := range labels(selectGenreMixes(full, buildN(30))) { if name == "Polka mix" { t.Fatal("a library-only genre was added to a shelf the user's own genres already fill") } } }