package explore import ( "context" "log/slog" "testing" "yellowjacket/backend/database" ) // The shelves are three queries and one rule about what to do when they // all come back empty, and the second half is the part that matters: // Explore's data is a downloaded artifact, so "no shelves" can mean the // catalog is absent, mid-build, or simply has nothing the user does not // already own — and rendering those identically is the blank panel this // feature exists to remove. // seedShelfRow writes one explore_index row. The parameter list is the // real upsert's, so a schema change breaks these tests in the same // place it breaks the app rather than leaving them passing against a // shape nothing writes any more. func seedShelfRow( t *testing.T, db *database.DB, entityType, mbid, title, artistName, artistMBID string, popularity int, inLibrary bool, ) { t.Helper() seedIndexResult(t, db, SearchIndexResult{ EntityType: entityType, MBID: testMBID(mbid), Title: title, ArtistName: artistName, ArtistMBID: testMBID(artistMBID), Popularity: popularity, ListenerCount: popularity, ReleaseName: "Album", InLibrary: inLibrary, }) } func newShelfService(t *testing.T) (*Service, *database.DB) { t.Helper() db := database.NewTestDB(t) index := NewSearchIndex(db, nil, nil, slog.Default()) return &Service{ index: index, db: db, logger: slog.Default(), ctx: context.Background(), }, db } // buildShelves builds the page. // // Nothing has to be marked ready first, which is the point: the gate is // a question asked of the database, so rows seeded after startup count. // Both cached alternatives failed here first — one because it is only // refreshed between build tiers, the other because it is set once at // startup, when a test has seeded nothing yet. func buildShelves(svc *Service) ShelfPage { return svc.GetExploreShelves() } func shelfByKind(page ShelfPage, kind ShelfKind) (Shelf, bool) { for _, shelf := range page.Shelves { if shelf.Kind == kind { return shelf, true } } return Shelf{}, false } func TestShelves_NoIndexSaysSoRatherThanShowingNothing(t *testing.T) { t.Parallel() svc, _ := newShelfService(t) page := buildShelves(svc) if page.State != ShelfStateNoIndex { t.Fatalf("state = %q, want %q", page.State, ShelfStateNoIndex) } if len(page.Shelves) != 0 { t.Fatalf("shelves = %d, want 0", len(page.Shelves)) } } func TestShelves_PopularOrdersByPopularityAndExcludesOwned(t *testing.T) { t.Parallel() svc, db := newShelfService(t) seedShelfRow(t, db, "release_group", "rg-quiet", "Quiet", "A", "mbid-a", 10, false) seedShelfRow(t, db, "release_group", "rg-loud", "Loud", "B", "mbid-b", 900, false) seedShelfRow(t, db, "release_group", "rg-mine", "Mine", "C", "mbid-c", 5000, true) // Popularity 0 is the dump's "no listens", not a low score: those // rows have no defined order among themselves. seedShelfRow(t, db, "release_group", "rg-unheard", "Unheard", "D", "mbid-d", 0, false) page := buildShelves(svc) shelf, ok := shelfByKind(page, ShelfPopularAlbums) if !ok { t.Fatal("no popular-albums shelf") } var titles []string for _, album := range shelf.Albums { titles = append(titles, album.Title) } if len(titles) != 2 || titles[0] != "Loud" || titles[1] != "Quiet" { t.Fatalf("albums = %v, want [Loud Quiet]", titles) } } func TestShelves_ArtistsAreTheirOwnShelf(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // Two different artists: the albums shelf takes one, and the // artists shelf must not simply repeat them (see the dedup test // below), so the shelf it gets is somebody else's. seedShelfRow(t, db, "release_group", "rg-1", "An Album", "Album Maker", "ar-album", 900, false) seedShelfRow(t, db, "artist", "ar-1", "Big Name", "Big Name", "ar-1", 800, false) page := buildShelves(svc) artists, ok := shelfByKind(page, ShelfPopularArtists) if !ok { t.Fatal("no popular-artists shelf") } // A shelf carries albums or artists, never both: the two render as // different cards and route to different pages. if len(artists.Artists) != 1 || len(artists.Albums) != 0 { t.Fatalf("artists shelf = %d artists / %d albums, want 1 / 0", len(artists.Artists), len(artists.Albums)) } if artists.Artists[0].Name != "Big Name" { t.Fatalf("artist name = %q", artists.Artists[0].Name) } } func TestShelves_OmitsAShelfWithNothingBehindIt(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // Albums only: the artists shelf has nothing and must not be // rendered empty, and nothing is owned so neither has the third. seedShelfRow(t, db, "release_group", "rg-1", "An Album", "A", "mbid-a", 100, false) page := buildShelves(svc) if len(page.Shelves) != 1 { var kinds []ShelfKind for _, shelf := range page.Shelves { kinds = append(kinds, shelf.Kind) } t.Fatalf("shelves = %v, want just popular-albums", kinds) } if page.State != ShelfStateReady { t.Fatalf("state = %q, want %q", page.State, ShelfStateReady) } } func TestShelves_MoreFromOwnedNeedsExactlyOneOwnedAlbum(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // One album owned by "Solo", so the rest of Solo's catalog is the // shelf. Two owned by "Complete", who is therefore not a gap. seedShelfRow(t, db, "release_group", "rg-solo-own", "Owned", "Solo", "mbid-solo", 100, true) seedShelfRow(t, db, "release_group", "rg-solo-a", "Second", "Solo", "mbid-solo", 90, false) seedShelfRow(t, db, "release_group", "rg-solo-b", "Third", "Solo", "mbid-solo", 80, false) seedShelfRow(t, db, "release_group", "rg-comp-1", "One", "Complete", "mbid-comp", 100, true) seedShelfRow(t, db, "release_group", "rg-comp-2", "Two", "Complete", "mbid-comp", 90, true) seedShelfRow(t, db, "release_group", "rg-comp-3", "Three", "Complete", "mbid-comp", 70, false) page := buildShelves(svc) shelf, ok := shelfByKind(page, ShelfMoreFromOwned) if !ok { t.Fatal("no more-from-owned shelf") } var titles []string for _, album := range shelf.Albums { titles = append(titles, album.Title) } if len(titles) != 2 || titles[0] != "Second" || titles[1] != "Third" { t.Fatalf("albums = %v, want [Second Third]", titles) } // A shelf that turns out to be about one artist says so by name. if shelf.Title != "More from Solo" { t.Fatalf("title = %q, want %q", shelf.Title, "More from Solo") } // It is the first row: it is the only one about this user. if page.Shelves[0].Kind != ShelfMoreFromOwned { t.Fatalf("first shelf = %q, want %q", page.Shelves[0].Kind, ShelfMoreFromOwned) } } func TestShelves_UntaggedLibraryProducesNoOwnershipShelf(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // This is the fixture library's shape, and every untagged library's: // `in_library` is set from MusicBrainz IDs, so nothing is marked // owned however much music is on disk. The shelf must be absent // rather than wrong. seedShelfRow(t, db, "release_group", "rg-1", "An Album", "A", "mbid-a", 100, false) seedShelfRow(t, db, "release_group", "rg-2", "Another", "A", "mbid-a", 90, false) page := buildShelves(svc) if _, ok := shelfByKind(page, ShelfMoreFromOwned); ok { t.Fatal("more-from-owned shelf built from a library that owns nothing") } } func TestShelves_TheSecondRowIsNotTheFirstRowsArtists(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // This is what the real catalog does. Ordered by raw ListenBrainz // listen count, the top albums are one act and its members, and the // artists shelf underneath was then the same people — one fandom // twice, on a page whose whole job is breadth. `home`'s duplicate // guard could not see it: the two shelves hold different entity // types, so no two rows share an id, and the page repeats itself // anyway because a person reads artists, not ids. seedShelfRow(t, db, "artist", "ar-huge", "Huge", "Huge", "ar-huge", 10000, false) seedShelfRow(t, db, "release_group", "rg-huge", "Hit", "Huge", "ar-huge", 9000, false) seedShelfRow(t, db, "artist", "ar-next", "Next", "Next", "ar-next", 500, false) page := buildShelves(svc) albums, ok := shelfByKind(page, ShelfPopularAlbums) if !ok { t.Fatal("no popular-albums shelf") } artists, ok := shelfByKind(page, ShelfPopularArtists) if !ok { t.Fatal("no popular-artists shelf") } if albums.Albums[0].ArtistMBID != testMBID("ar-huge") { t.Fatalf("albums shelf leads with %q, want ar-huge", albums.Albums[0].ArtistMBID) } for _, artist := range artists.Artists { if artist.MBID == testMBID("ar-huge") { t.Fatal("artists shelf repeats the artist the albums shelf just showed") } } } func TestShelves_OneAlbumPerArtist(t *testing.T) { t.Parallel() svc, db := newShelfService(t) // A shelf is a selection, not a leaderboard: twelve slots spent on // one artist is a row saying one thing twelve times. seedShelfRow(t, db, "release_group", "rg-a1", "First", "Prolific", "ar-a", 900, false) seedShelfRow(t, db, "release_group", "rg-a2", "Second", "Prolific", "ar-a", 800, false) seedShelfRow(t, db, "release_group", "rg-a3", "Third", "Prolific", "ar-a", 700, false) seedShelfRow(t, db, "release_group", "rg-b1", "Only", "Other", "ar-b", 100, false) page := buildShelves(svc) shelf, ok := shelfByKind(page, ShelfPopularAlbums) if !ok { t.Fatal("no popular-albums shelf") } var titles []string for _, album := range shelf.Albums { titles = append(titles, album.Title) } if len(titles) != 2 || titles[0] != "First" || titles[1] != "Only" { t.Fatalf("albums = %v, want [First Only]", titles) } }