Files
yellowjacket/backend/library/completeness_test.go
logan 4bf59b45b7 feat(library): answer album completeness for a screenful in one query
A card grid has to know how much of an album is here — an album held 2
tracks of 10 wearing the same green tick as one held whole is the
complaint the badge-accuracy work was filed about — and
`GetAlbumCompleteness` is one query per album, which is fifty round
trips for a grid of fifty.

`GetAlbumsCompleteness` is the same question over a slice. It is two
grouping levels rather than the single-album form's correlated
subqueries, because a correlated subquery in the FROM clause is not
something SQLite will reliably do, and because the slice may only be
spelled once or sqlc expands it twice with independently numbered
placeholders.

An album with no files is absent from the result rather than zeroed:
"I have none of this" and "I have no idea" are the third state `Known`
exists to keep apart.

The test that matters is that the two spellings never disagree — they
are genuinely different SQL, so the risk is a drift in meaning (a
disc's total counted once per file, a duplicate counted twice) rather
than a typo.
2026-08-19 00:37:46 -04:00

344 lines
9.3 KiB
Go

package library
import (
"fmt"
"testing"
"yellowjacket/backend/database"
)
// track is one file as the scan would write it: a position on a disc,
// and whatever total the file's tag declared (0 meaning the tag did not
// say).
type track struct {
recordingID int
disc int
number int
total int
}
// stageAlbum writes an album's files straight in. The completeness
// query reads only audio_files now - the totals used to live on a join
// table - so this exercises the arithmetic without standing up a scan.
func stageAlbum(t *testing.T, lib *Library, albumID int, tracks []track) {
t.Helper()
for _, tr := range tracks {
database.InsertTestTrack(t, lib.db, database.TestTrack{
FilePath: fmt.Sprintf("/music/album%d/%d.mp3", albumID, tr.recordingID),
Title: "Test Track",
Artist: "Test Artist",
Album: fmt.Sprintf("Test Album %d", albumID),
TrackNumber: int64(tr.number),
DiscNumber: int64(tr.disc),
TotalTracks: int64(tr.total),
})
}
}
// albumIDFor reads back the id stageAlbum's files were filed under.
func albumIDFor(t *testing.T, lib *Library, albumID int) int64 {
t.Helper()
var id int64
if err := lib.db.QueryRowWriter(
"SELECT id FROM albums WHERE name = ?", fmt.Sprintf("Test Album %d", albumID),
).Scan(&id); err != nil {
t.Fatalf("read album id: %v", err)
}
return id
}
// disc builds a run of tracks on one disc, each declaring the same
// total — which is what a correctly tagged rip looks like.
func disc(discNum, firstRecordingID, held, declared int) []track {
out := make([]track, 0, held)
for i := range held {
out = append(out, track{
recordingID: firstRecordingID + i,
disc: discNum,
number: i + 1,
total: declared,
})
}
return out
}
func TestGetAlbumCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tracks []track
wantOwned int
wantExpected int
wantKnown bool
wantComplete bool
}{
{
name: "every track present",
tracks: disc(1, 100, 12, 12),
wantOwned: 12,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
name: "three tracks short",
tracks: disc(1, 200, 9, 12),
wantOwned: 9,
wantExpected: 12,
wantKnown: true,
wantComplete: false,
},
{
// A bonus track puts the folder over its declared total.
// That is a complete album, not a broken one — which is
// why Complete is >= and not ==.
name: "bonus track over the declared total",
tracks: disc(1, 300, 13, 12),
wantOwned: 13,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
// The whole reason Known exists: an untagged rip declares
// no total, and a ring drawn from that would mark most of
// an untagged library incomplete on no evidence.
name: "no totals declared at all",
tracks: []track{
{recordingID: 400, disc: 1, number: 1},
{recordingID: 401, disc: 1, number: 2},
},
wantOwned: 2,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
{
// Totals are per disc, so the expectation is a sum and not
// a single number — the bug this shape exists to catch is
// reading one disc's "10" as the whole album's.
name: "two discs, one short",
tracks: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
wantOwned: 12,
wantExpected: 15,
wantKnown: true,
wantComplete: false,
},
{
name: "two discs, both complete",
tracks: append(disc(1, 700, 10, 10), disc(2, 800, 5, 5)...),
wantOwned: 15,
wantExpected: 15,
wantKnown: true,
wantComplete: true,
},
{
// One disc ripped by a tagger that wrote totals, one by a
// tagger that did not. The album's total is unknowable —
// the disc that did declare cannot stand in for the one
// that did not.
name: "one disc untotalled",
tracks: append(
disc(1, 900, 10, 10),
track{recordingID: 950, disc: 2, number: 1},
),
wantOwned: 11,
wantExpected: 10,
wantKnown: false,
wantComplete: false,
},
{
// This app detects duplicates, so it must not be fooled by
// them: two files of track 3 are one track held, and
// counting both would report a short album as complete.
name: "a duplicated track counts once",
tracks: append(
disc(1, 1000, 5, 6),
track{recordingID: 1099, disc: 1, number: 3, total: 6},
),
wantOwned: 5,
wantExpected: 6,
wantKnown: true,
wantComplete: false,
},
{
// Untotalled *and* unnumbered: the fallback keys off the
// recording id, so these must not collapse into one.
name: "unnumbered tracks stay distinct",
tracks: []track{
{recordingID: 1100, disc: 1},
{recordingID: 1101, disc: 1},
{recordingID: 1102, disc: 1},
},
wantOwned: 3,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
albumID := i + 1
stageAlbum(t, lib, albumID, tc.tracks)
got, err := lib.GetAlbumCompleteness(albumIDFor(t, lib, albumID))
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Owned != tc.wantOwned {
t.Errorf("owned = %d, want %d", got.Owned, tc.wantOwned)
}
if got.Expected != tc.wantExpected {
t.Errorf("expected = %d, want %d", got.Expected, tc.wantExpected)
}
if got.Known != tc.wantKnown {
t.Errorf("known = %v, want %v", got.Known, tc.wantKnown)
}
if got.Complete != tc.wantComplete {
t.Errorf("complete = %v, want %v", got.Complete, tc.wantComplete)
}
})
}
}
// An album with no rows at all must not read as "complete" by virtue of
// holding everything it knows about, which is nothing.
func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
got, err := lib.GetAlbumCompleteness(999)
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Known || got.Complete || got.Owned != 0 {
t.Errorf("empty album reported %+v, want zero and unknown", got)
}
}
// The batch and the single-album query are two spellings of one
// question, and the thing worth pinning is that they never disagree.
//
// They are genuinely different SQL — the single-album form is
// correlated subqueries over one album, the batch is two grouping
// levels over a slice — so the risk is not a typo but a drift in
// meaning: a disc's total counted once per file, a duplicate counted
// twice, a disc with no total silently covered by one that had one.
// Every shape the table above cares about is staged here at once,
// because a batch that is only ever asked about one album is not being
// asked the question that can go wrong.
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
shapes := map[int][]track{
1: disc(1, 100, 12, 12),
2: disc(1, 200, 9, 12),
3: disc(1, 300, 13, 12),
4: {{recordingID: 400, disc: 1, number: 1}},
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
6: append(
disc(1, 700, 10, 10),
track{recordingID: 750, disc: 2, number: 1},
),
7: append(
disc(1, 800, 5, 6),
track{recordingID: 899, disc: 1, number: 3, total: 6},
),
}
ids := make([]int64, 0, len(shapes))
for albumID, tracks := range shapes {
stageAlbum(t, lib, albumID, tracks)
ids = append(ids, albumIDFor(t, lib, albumID))
}
batch, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if len(batch) != len(ids) {
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
}
for _, id := range ids {
one, err := lib.GetAlbumCompleteness(id)
if err != nil {
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
}
if got := batch[id]; got != one {
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
}
}
}
// An album with no files is absent from the batch, not zeroed.
//
// "I have none of this" and "I have no idea" are the third state Known
// exists to keep apart, and a caller reading a missing key gets nothing
// rather than a confident zero it would have to know to distrust.
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
held := albumIDFor(t, lib, 1)
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if _, ok := got[4242]; ok {
t.Errorf("an album with no files answered %+v, want absent", got[4242])
}
if !got[held].Complete {
t.Errorf("held album reported %+v, want complete", got[held])
}
}
// A caller with nothing to ask about must not issue a query at all —
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
// a perfectly valid query returning nothing, so this is about the round
// trip rather than the answer.
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
got, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
}
if len(got) != 0 {
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
}
}
}