Files
yellowjacket/backend/download/rank_test.go
T
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -04:00

439 lines
10 KiB
Go

package download
import "testing"
// okComputer is the reference request used across ranking tests.
func okComputer() Download {
return Download{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)"},
},
}
}
// candidateFor builds a candidate whose files follow "NN - Title.ext".
func candidateFor(id string, titles []string, ext string, size int64) Candidate {
files := make([]CandidateFile, 0, len(titles))
for i, tt := range titles {
files = append(files, CandidateFile{
Path: "Radiohead - OK Computer/" +
trackToken(i+1) + " - " + tt + ext,
Size: size,
})
}
return Candidate{
ID: id,
Protocol: ProtocolDirect,
Title: "Radiohead - OK Computer",
Artist: "Radiohead",
Files: files,
Health: 0.5,
}
}
func allTitles() []string {
return []string{
"Airbag",
"Paranoid Android",
"Subterranean Homesick Alien",
"Exit Music (For a Film)",
}
}
// The headline behaviour: a well-matched FLAC beats a well-matched
// 128kbps MP3, but a mismatched FLAC loses to both.
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
t.Parallel()
dl := okComputer()
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
for i := range mp3.Files {
mp3.Files[i].Bitrate = 128
}
ranked := Rank(dl, []Candidate{mp3, flac}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "flac" {
t.Fatalf("winner = %s, want flac", ranked[0].ID)
}
if ranked[0].Match.Overall < 0.9 {
t.Errorf("flac match = %f, want high", ranked[0].Match.Overall)
}
if ranked[0].Quality.Overall <= ranked[1].Quality.Overall {
t.Errorf(
"flac quality %f should exceed mp3 %f",
ranked[0].Quality.Overall, ranked[1].Quality.Overall,
)
}
}
func TestRankMatchDominatesQuality(t *testing.T) {
t.Parallel()
dl := okComputer()
// Right album, poor bitrate.
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
for i := range right.Files {
right.Files[i].Bitrate = 128
}
// Wrong album, pristine FLAC.
wrong := candidateFor("wrong", []string{
"Enter Sandman", "Sad But True", "Holier Than Thou", "The Unforgiven",
}, ".flac", 30_000_000)
wrong.Title = "Metallica - Metallica"
wrong.Artist = "Metallica"
for i := range wrong.Files {
wrong.Files[i].Path = "Metallica - Metallica/" +
trackToken(i+1) + " - x.flac"
}
ranked := Rank(dl, []Candidate{wrong, right}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "right" {
t.Fatalf(
"winner = %s (score %f vs %f), want the correctly matched album",
ranked[0].ID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestIncompleteCandidateScoresLower(t *testing.T) {
t.Parallel()
dl := okComputer()
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
ranked := Rank(dl, []Candidate{partial, full}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "full" {
t.Fatalf("winner = %s, want full", ranked[0].ID)
}
if ranked[1].Match.Completeness >= ranked[0].Match.Completeness {
t.Errorf(
"partial completeness %f should be below full %f",
ranked[1].Match.Completeness, ranked[0].Match.Completeness,
)
}
}
func TestMixedFormatIsPenalized(t *testing.T) {
t.Parallel()
dl := okComputer()
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
mixed := candidateFor("mixed", allTitles(), ".flac", 30_000_000)
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
mixed.Files[2].Format = FormatUnknown
ranked := Rank(dl, []Candidate{mixed, clean}, nil, AutoDownloadPrefs{})
var mixedScore QualityScore
for _, c := range ranked {
if c.ID == "mixed" {
mixedScore = c.Quality
}
}
if !mixedScore.Mixed {
t.Error("mixed-format candidate not flagged")
}
if ranked[0].ID != "clean" {
t.Errorf("winner = %s, want clean", ranked[0].ID)
}
}
// Without an MBID there is no tracklist to be right about, so the match
// score must not look confident regardless of how good the strings are.
func TestUnanchoredMatchIsCapped(t *testing.T) {
t.Parallel()
dl := Download{Artist: "Radiohead", Album: "OK Computer"}
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
scored := Score(dl, c, 50, AutoDownloadPrefs{})
if scored.Match.Anchored {
t.Error("free-text request reported as anchored")
}
if scored.Match.Overall > unanchoredCap {
t.Errorf(
"unanchored match = %f, want <= %f",
scored.Match.Overall, unanchoredCap,
)
}
}
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
t.Run("clear winner is auto-pickable", func(t *testing.T) {
t.Parallel()
weak := Score(
dl,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
AutoDownloadPrefs{},
)
if !AutoPickable(dl, []Candidate{best, weak}, AutoDownloadPrefs{}) {
t.Errorf(
"want auto-pickable: match %f quality %f lead %f",
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
)
}
})
t.Run("two close candidates are not", func(t *testing.T) {
t.Parallel()
twin := best
twin.ID = "twin"
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical candidates must not auto-pick")
}
})
t.Run("free text is never auto-pickable", func(t *testing.T) {
t.Parallel()
free := Download{Artist: "Radiohead", Album: "OK Computer"}
if AutoPickable(free, []Candidate{best}, AutoDownloadPrefs{}) {
t.Error("unanchored request must not auto-pick")
}
})
t.Run("empty list is not", func(t *testing.T) {
t.Parallel()
if AutoPickable(dl, nil, AutoDownloadPrefs{}) {
t.Error("empty candidate list must not auto-pick")
}
})
}
func TestCompleteness(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
minScore float64
maxScore float64
}{
{"exact", 10, 10, 1.0, 1.0},
{"half missing", 5, 10, 0.49, 0.51},
{"one bonus track", 11, 10, 0.95, 1.0},
{"double", 20, 10, 0.74, 0.76},
{"nothing", 0, 10, 0, 0},
{"no expectation", 5, 0, 0.5, 0.5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := completeness(tt.got, tt.want)
if got < tt.minScore || got > tt.maxScore {
t.Errorf(
"completeness(%d, %d) = %f, want in [%f, %f]",
tt.got, tt.want, got, tt.minScore, tt.maxScore,
)
}
})
}
}
func TestProviderPriorityBreaksTies(t *testing.T) {
t.Parallel()
dl := okComputer()
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
a.ProviderID = 1
b := candidateFor("b", allTitles(), ".flac", 30_000_000)
b.ProviderID = 2
priority := func(id int64) int {
if id == 2 {
return 90
}
return 10
}
ranked := Rank(dl, []Candidate{a, b}, priority, AutoDownloadPrefs{})
if ranked[0].ID != "b" {
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
}
}
const mb = 1 << 20
func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Parallel()
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
flacCandidate.TotalSize = 300 * mb
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
mp3Candidate.TotalSize = 30 * mb
tests := []struct {
name string
prefs AutoDownloadPrefs
c Candidate
want bool
}{
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
{
"within min/max window",
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
flacCandidate, true,
},
{
"below minimum",
AutoDownloadPrefs{MinSizeMB: 400},
flacCandidate, false,
},
{
"above maximum",
AutoDownloadPrefs{MaxSizeMB: 200},
flacCandidate, false,
},
{
"allowed format passes",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
flacCandidate, true,
},
{
"disallowed format rejected",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
mp3Candidate, false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.eligible(tt.c); got != tt.want {
t.Errorf("eligible() = %v, want %v", got, tt.want)
}
})
}
}
func TestAutoDownloadPrefsFilter(t *testing.T) {
t.Parallel()
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
small.TotalSize = 50 * mb
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
big.TotalSize = 500 * mb
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
filtered := prefs.filter([]Candidate{small, big})
if len(filtered) != 1 || filtered[0].ID != "big" {
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
}
}
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
t.Parallel()
const neutral = 0.5
tests := []struct {
name string
prefs AutoDownloadPrefs
totalSize int64
want float64
}{
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
{
"exact match scores 1",
AutoDownloadPrefs{PreferredSizeMB: 300},
300 * mb, 1.0,
},
{
"double the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
600 * mb, 0.0,
},
{
"half the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
150 * mb, 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
}
})
}
}
// An otherwise-perfect candidate must not auto-pick when it falls
// outside the configured size guard: the guardrail applies before the
// match/quality/lead checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
best.TotalSize = 500 * mb
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
}
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
if AutoPickable(dl, []Candidate{best}, tight) {
t.Error("candidate outside the size guard must not auto-pick")
}
}