package library import ( "encoding/json" "fmt" "strconv" "testing" "yellowjacket/backend/database" ) // seedTableLibrary seeds n albums of perAlbum tracks each, with a cover // on every album, so the table has the repetition it exists to remove. func seedTableLibrary(t *testing.T, lib *Library, albums, perAlbum int) { t.Helper() for a := range albums { for n := range perAlbum { database.InsertTestTrack(t, lib.db, database.TestTrack{ FilePath: fmt.Sprintf( "/music/artist-%d/album-%d/%02d - Some Track Title.flac", a%7, a, n+1, ), Title: fmt.Sprintf("Some Track Title %d", n+1), Artist: fmt.Sprintf("Artist %d", a%7), ArtistMBID: fmt.Sprintf("0b7a8d2e-0000-4000-8000-%012d", a%7), Album: fmt.Sprintf("Album Name %d", a), AlbumMBID: fmt.Sprintf("1c7a8d2e-0000-4000-8000-%012d", a), RecordingMBID: fmt.Sprintf("2d7a8d2e-0000-4000-8000-%012d", a*100+n), Genres: []string{"Ambient", fmt.Sprintf("Genre %d", a%3)}, TrackNumber: int64(n + 1), DiscNumber: 1, Year: 2000 + int64(a%20), LengthMs: 200_000 + int64(n), PlayCount: int64(n), }) } res, err := lib.db.ExecContext( "INSERT INTO cover_art (file_path, mime_type) VALUES (?, 'image/jpeg')", fmt.Sprintf("/data/covers/%064d.jpg", a), ) if err != nil { t.Fatalf("seed cover: %v", err) } coverID, _ := res.LastInsertId() if _, err := lib.db.ExecContext( "UPDATE albums SET cover_art_id = ? WHERE name = ?", coverID, fmt.Sprintf("Album Name %d", a), ); err != nil { t.Fatalf("seed album cover: %v", err) } } } // decodeTable is the Go mirror of frontend/src/utils/track-table.ts, // for asserting the encoding loses nothing it claims to carry. func decodeTable(t *testing.T, tbl TrackTable) []Track { t.Helper() str := func(col []uint32, i int) string { return tbl.Strings[col[i]] } tracks := make([]Track, len(tbl.FilePath)) for i := range tracks { var genres []string for _, g := range tbl.GenreSets[tbl.Genre[i]] { genres = append(genres, tbl.Strings[g]) } tracks[i] = Track{ FilePath: tbl.FilePath[i], TrackName: str(tbl.TrackName, i), ArtistName: str(tbl.ArtistName, i), Album: str(tbl.Album, i), Composer: str(tbl.Composer, i), FileType: str(tbl.FileType, i), ArtistMBID: str(tbl.ArtistMBID, i), ReleaseGroupMBID: str(tbl.ReleaseGroupMBID, i), RecordingMBID: str(tbl.RecordingMBID, i), CoverArtSmall: str(tbl.CoverArtSmall, i), Genre: genres, TrackLength: strconv.FormatInt(tbl.LengthMs[i], 10), TrackNumber: tbl.TrackNumber[i], DiscNumber: tbl.DiscNumber[i], Year: tbl.Year[i], SampleRate: tbl.SampleRate[i], BitDepth: tbl.BitDepth[i], Channels: tbl.Channels[i], Bitrate: tbl.Bitrate[i], FileSize: tbl.FileSize[i], PlayCount: tbl.PlayCount[i], } } return tracks } // #281: the table is an encoding of trackFromRow's Track, minus the // fields the Tracks view does not read. Decoding it must give back // exactly that, row for row. func TestTrackTableRoundTrip(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) seedTableLibrary(t, lib, 6, 4) tbl, err := lib.GetTrackTable(0) if err != nil { t.Fatalf("GetTrackTable: %v", err) } rows, err := lib.db.ReadQueries.GetTracks(lib.ctx, 0) if err != nil { t.Fatalf("GetTracks: %v", err) } if len(rows) != 24 || len(tbl.FilePath) != len(rows) { t.Fatalf("table has %d rows, query %d, want 24", len(tbl.FilePath), len(rows)) } got := decodeTable(t, tbl) for i, row := range rows { want := trackFromRow(row) // Not carried: the details dialog fetches these by path. want.LastPlayed, want.CoverArtPath, want.CoverArtMedium, want.CoverArtLarge = "", "", "", "" if fmt.Sprintf("%+v", got[i]) != fmt.Sprintf("%+v", want) { t.Fatalf("row %d:\n got %+v\nwant %+v", i, got[i], want) } } // It was the cover, genre and name repetition that was paid for; a // table that interned nothing would still round-trip. if len(tbl.GenreSets) != 3 { t.Errorf("genre sets = %d, want 3 distinct lists", len(tbl.GenreSets)) } if a, b := tbl.CoverArtSmall[0], tbl.CoverArtSmall[1]; a != b || tbl.Strings[a] == "" { t.Errorf("two tracks of one album hold cover indexes %d and %d", a, b) } } // The point of the table, pinned: the same rows encode to a fraction of // the object-per-track JSON they replace. A new column has to fit // under this or raise it on purpose. func TestTrackTableSizeBudget(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) seedTableLibrary(t, lib, 40, 10) tbl, err := lib.GetTrackTable(0) if err != nil { t.Fatalf("GetTrackTable: %v", err) } rows, err := lib.db.ReadQueries.GetTracks(lib.ctx, 0) if err != nil { t.Fatalf("GetTracks: %v", err) } asTable, err := json.Marshal(tbl) if err != nil { t.Fatal(err) } asObjects, err := json.Marshal(tracksFromRows(rows)) if err != nil { t.Fatal(err) } perTrack := len(asTable) / len(rows) ratio := float64(len(asTable)) / float64(len(asObjects)) t.Logf("table %d B (%d B/track), objects %d B, ratio %.2f", len(asTable), perTrack, len(asObjects), ratio) if ratio > 0.25 { t.Errorf("table is %.0f%% of the object encoding, budget 25%%", ratio*100) } } func TestTrackTableEmptyLibraryIsAnAnswer(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) tbl, err := lib.GetTrackTable(0) if err != nil { t.Fatalf("an empty library is an empty table, not an error: %v", err) } if len(tbl.FilePath) != 0 || len(tbl.Strings) != 1 { t.Errorf("empty table = %+v", tbl) } }