CI / check (push) Skipped
CI / e2e (push) Skipped
Three faults in how candidates are shaped and scored, one commit because they meet in the same completeness number. Multi-disc albums were split in two. Soulseek shares them as Album/CD1 and Album/CD2, and candidates were grouped by the immediate parent, so each disc became its own candidate titled "CD1": about half complete, with an album title that could not match. Such a release essentially never cleared auto-pick. AlbumDir groups a disc folder under its parent, ParsePath takes the disc number from the folder (a disc in the filename still wins), and collect keeps the disc folders in staging, where flattened, disc 2's "01 Intro.flac" overwrote disc 1's. A single-track request could never be served from Soulseek. A track search matches one file per folder, and the two-file floor that screens out noise for an album screened out every result. A recording request takes one. Completeness counted files. Ten files against a ten-track album scored full marks whether or not they were its tracks, and title fit is the mean over the files that did align, so a folder where three titles matched read as near-perfect on both. Coverage is now counted in aligned tracks, with the file count still setting the penalty for extras. Closes #270 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
746 lines
19 KiB
Go
746 lines
19 KiB
Go
package download
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// trackMillis is five minutes; okComputer's four of them make a
|
||
// twenty-minute release, which is what turns a candidate's byte count
|
||
// into a bitrate the assertions below can name.
|
||
const trackMillis = 5 * 60 * 1000
|
||
|
||
// okComputerRuntime is that release's runtime, for the helpers that
|
||
// need it directly.
|
||
const okComputerRuntime = 4 * trackMillis
|
||
|
||
// kbpsCandidate builds an annotated candidate whose audio adds up to
|
||
// the given average bitrate over okComputer's runtime.
|
||
func kbpsCandidate(id, ext string, kbps int) Candidate {
|
||
// bits = kbps × 1000 × (runtimeMillis / 1000), so the thousands
|
||
// cancel and the byte count is kbps × runtimeMillis / 8.
|
||
const bitsPerByte = 8
|
||
|
||
total := int64(kbps) * okComputerRuntime / bitsPerByte
|
||
|
||
c := candidateFor(id, allTitles(), ext, total/int64(len(allTitles())))
|
||
c.Files = AnnotateFiles(c.Files)
|
||
c.TotalSize = total
|
||
|
||
return c
|
||
}
|
||
|
||
// okComputer is the reference request used across ranking tests.
|
||
func okComputer() Download {
|
||
return Download{
|
||
ReleaseMBID: "mbid-ok-computer",
|
||
Artist: "Radiohead",
|
||
Album: "OK Computer",
|
||
// Four five-minute tracks: twenty minutes, so a candidate's
|
||
// bitrate is a number these tests can state exactly. Without
|
||
// lengths there is no runtime and the bitrate window has
|
||
// nothing to divide by.
|
||
Expected: []ExpectedTrack{
|
||
{Position: 1, Title: "Airbag", LengthMillis: trackMillis},
|
||
{Position: 2, Title: "Paranoid Android", LengthMillis: trackMillis},
|
||
{Position: 3, Title: "Subterranean Homesick Alien", LengthMillis: trackMillis},
|
||
{Position: 4, Title: "Exit Music (For a Film)", LengthMillis: trackMillis},
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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 TestAutoPickableRequiresAnchorAndTracklist(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,
|
||
)
|
||
}
|
||
})
|
||
|
||
// Two identical copies are a spare, not an ambiguity. This
|
||
// asserted the opposite while auto-pick required daylight over the
|
||
// runner-up — a rule that made abundance the thing that stopped a
|
||
// request being satisfied, which is backwards.
|
||
t.Run("two equally good candidates still are", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
twin := best
|
||
twin.ID = "twin"
|
||
|
||
if !AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
|
||
t.Error("identical good candidates must 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
|
||
aligned int
|
||
audio int
|
||
want int
|
||
minScore float64
|
||
maxScore float64
|
||
}{
|
||
{"exact", 10, 10, 10, 1.0, 1.0},
|
||
{"half missing", 5, 5, 10, 0.49, 0.51},
|
||
{"one bonus track", 10, 11, 10, 0.95, 1.0},
|
||
{"double", 10, 20, 10, 0.74, 0.76},
|
||
{"nothing", 0, 0, 10, 0, 0},
|
||
{"no expectation", 0, 5, 0, 0.5, 0.5},
|
||
// Ten files are not ten tracks: three that align are three.
|
||
{"right count, wrong tracks", 3, 10, 10, 0.29, 0.31},
|
||
{"files that align to nothing", 0, 10, 10, 0, 0},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := completeness(tt.aligned, tt.audio, tt.want)
|
||
if got < tt.minScore || got > tt.maxScore {
|
||
t.Errorf(
|
||
"completeness(%d, %d, %d) = %f, want in [%f, %f]",
|
||
tt.aligned, tt.audio, 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)
|
||
}
|
||
}
|
||
|
||
func TestAutoDownloadPrefsEligible(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
flacCandidate := kbpsCandidate("c", ".flac", 900)
|
||
mp3Candidate := kbpsCandidate("c", ".mp3", 128)
|
||
|
||
tests := []struct {
|
||
name string
|
||
prefs AutoDownloadPrefs
|
||
c Candidate
|
||
want bool
|
||
}{
|
||
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
|
||
{
|
||
"within the bitrate window",
|
||
AutoDownloadPrefs{MinKbps: 320, MaxKbps: 1200},
|
||
flacCandidate, true,
|
||
},
|
||
{
|
||
"below the minimum bitrate",
|
||
AutoDownloadPrefs{MinKbps: 500},
|
||
mp3Candidate, false,
|
||
},
|
||
{
|
||
"above the maximum bitrate",
|
||
AutoDownloadPrefs{MaxKbps: 500},
|
||
flacCandidate, false,
|
||
},
|
||
{
|
||
// The ceiling is bytes, not a rate, and it is the guard
|
||
// that still works when the bitrate cannot be worked out.
|
||
"above the hard size ceiling",
|
||
AutoDownloadPrefs{MaxSizeMB: 50},
|
||
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()
|
||
|
||
got := tt.prefs.eligible(tt.c, okComputerRuntime)
|
||
if got != tt.want {
|
||
t.Errorf("eligible() = %v, want %v", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A release nobody knows the length of cannot be judged on bitrate, and
|
||
// the window must not become a silent embargo because MusicBrainz is
|
||
// missing a track length. The size ceiling still applies — that is why
|
||
// it is a separate field.
|
||
func TestBitrateWindowPassesAnUnknownRuntime(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
c := kbpsCandidate("c", ".mp3", 128)
|
||
prefs := AutoDownloadPrefs{MinKbps: 900}
|
||
|
||
if !prefs.eligible(c, 0) {
|
||
t.Error("an unknown runtime must pass the bitrate window")
|
||
}
|
||
|
||
if prefs.eligible(c, okComputerRuntime) {
|
||
t.Error("a known runtime must still be judged")
|
||
}
|
||
|
||
ceiling := AutoDownloadPrefs{MaxSizeMB: 1}
|
||
if ceiling.eligible(c, 0) {
|
||
t.Error("the size ceiling must apply even with no runtime")
|
||
}
|
||
}
|
||
|
||
// Artwork is not part of the bitrate. A folder carrying 30 MB of
|
||
// scans would otherwise read as a better rip than the same music
|
||
// without them, which is backwards.
|
||
func TestBitrateIgnoresNonAudioFiles(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
c := kbpsCandidate("c", ".mp3", 320)
|
||
bare := candidateKbps(c, okComputerRuntime)
|
||
|
||
c.Files = append(c.Files, CandidateFile{
|
||
Path: "Radiohead - OK Computer/cover.jpg",
|
||
Size: 30 << 20,
|
||
})
|
||
c.Files = AnnotateFiles(c.Files)
|
||
|
||
if got := candidateKbps(c, okComputerRuntime); got != bare {
|
||
t.Errorf("bitrate with artwork = %f, want %f", got, bare)
|
||
}
|
||
}
|
||
|
||
// Where no runtime is known, a stated per-file bitrate is better than
|
||
// no answer at all.
|
||
func TestBitrateFallsBackToTheStatedRate(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
c := candidateFor("c", allTitles(), ".mp3", 3_000_000)
|
||
for i := range c.Files {
|
||
c.Files[i].Bitrate = 192
|
||
}
|
||
|
||
c.Files = AnnotateFiles(c.Files)
|
||
|
||
if got := candidateKbps(c, 0); got != 192 {
|
||
t.Errorf("stated bitrate = %f, want 192", got)
|
||
}
|
||
}
|
||
|
||
func TestAutoDownloadPrefsFilter(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
lossy := kbpsCandidate("lossy", ".mp3", 128)
|
||
lossless := kbpsCandidate("lossless", ".flac", 900)
|
||
|
||
prefs := AutoDownloadPrefs{MinKbps: 500}
|
||
|
||
filtered := prefs.filter(
|
||
[]Candidate{lossy, lossless}, okComputerRuntime,
|
||
)
|
||
|
||
if len(filtered) != 1 || filtered[0].ID != "lossless" {
|
||
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
|
||
}
|
||
}
|
||
|
||
func TestAutoDownloadPrefsBitrateFit(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const neutral = 0.5
|
||
|
||
tests := []struct {
|
||
name string
|
||
prefs AutoDownloadPrefs
|
||
c Candidate
|
||
want float64
|
||
}{
|
||
{
|
||
"no preference is neutral",
|
||
AutoDownloadPrefs{},
|
||
kbpsCandidate("c", ".flac", 900), neutral,
|
||
},
|
||
{
|
||
"exact match scores 1",
|
||
AutoDownloadPrefs{PreferredKbps: 320},
|
||
kbpsCandidate("c", ".mp3", 320), 1.0,
|
||
},
|
||
{
|
||
// The floor is neutral, not zero: this term carries 0.40
|
||
// of the quality score once a preference is set, and a
|
||
// span to zero would let "I like 320" quietly disqualify
|
||
// every FLAC from auto-pick.
|
||
"double the preferred rate falls to the neutral floor",
|
||
AutoDownloadPrefs{PreferredKbps: 320},
|
||
kbpsCandidate("c", ".flac", 640), neutral,
|
||
},
|
||
{
|
||
"half the preferred rate falls to the neutral floor",
|
||
AutoDownloadPrefs{PreferredKbps: 320},
|
||
kbpsCandidate("c", ".mp3", 160), neutral,
|
||
},
|
||
{
|
||
"an unknowable rate is neutral",
|
||
AutoDownloadPrefs{PreferredKbps: 320},
|
||
kbpsCandidate("c", ".mp3", 320), neutral,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// The last case deliberately withholds the runtime.
|
||
runtime := int64(okComputerRuntime)
|
||
if tt.name == "an unknowable rate is neutral" {
|
||
runtime = 0
|
||
}
|
||
|
||
if got := tt.prefs.bitrateFit(tt.c, runtime); got != tt.want {
|
||
t.Errorf("bitrateFit() = %f, want %f", got, tt.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// An otherwise-perfect candidate must not auto-pick when it falls
|
||
// outside the configured guardrails: they apply before the match and
|
||
// quality checks, not as one more input averaged into them.
|
||
func TestAutoPickableRejectsCandidateOutsideTheGuardrails(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
best := Score(dl, kbpsCandidate("a", ".flac", 900), 50, AutoDownloadPrefs{})
|
||
|
||
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
|
||
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
|
||
}
|
||
|
||
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxKbps: 320}) {
|
||
t.Error("candidate above the bitrate window must not auto-pick")
|
||
}
|
||
|
||
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxSizeMB: 1}) {
|
||
t.Error("candidate above the size ceiling must not auto-pick")
|
||
}
|
||
}
|
||
|
||
// The refusal has to name the gate that refused.
|
||
//
|
||
// Before AutoPickVeto, every one of these came back as the same
|
||
// sentence built from `ranked[0]` — the best candidate before the size
|
||
// and format guardrails — so a request refused because the user's size
|
||
// window excluded every copy reported a match and a quality that both
|
||
// cleared their thresholds. A refusal quoting numbers that pass is
|
||
// what made the matcher look broken from outside.
|
||
func TestAutoPickVetoNamesTheGate(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
best := Score(
|
||
dl,
|
||
candidateFor("a", allTitles(), ".flac", 30_000_000),
|
||
50,
|
||
AutoDownloadPrefs{},
|
||
)
|
||
|
||
// candidateFor sizes the files and leaves TotalSize at 0, which is
|
||
// what the guardrails read.
|
||
sized := func(c Candidate, total int64) Candidate {
|
||
c.TotalSize = total
|
||
|
||
return c
|
||
}
|
||
|
||
tests := []struct {
|
||
name string
|
||
dl Download
|
||
ranked []Candidate
|
||
prefs AutoDownloadPrefs
|
||
wantSub string
|
||
}{
|
||
{
|
||
name: "nothing found",
|
||
dl: dl,
|
||
ranked: nil,
|
||
wantSub: "nothing found",
|
||
},
|
||
{
|
||
name: "free text",
|
||
dl: Download{Artist: "Radiohead", Album: "OK Computer"},
|
||
ranked: []Candidate{best},
|
||
wantSub: "free text",
|
||
},
|
||
{
|
||
name: "no tracklist behind the anchor",
|
||
dl: Download{
|
||
ReleaseMBID: "mbid-ok-computer",
|
||
Artist: "Radiohead",
|
||
Album: "OK Computer",
|
||
},
|
||
ranked: []Candidate{best},
|
||
wantSub: "no tracklist",
|
||
},
|
||
{
|
||
// The candidate is 120 MB and the window tops out at 1 MB:
|
||
// the old message reported its match and quality instead.
|
||
name: "outside the size window",
|
||
dl: dl,
|
||
ranked: []Candidate{sized(best, 120<<20)},
|
||
prefs: AutoDownloadPrefs{MaxSizeMB: 1},
|
||
wantSub: "bitrate, size or format limits",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := AutoPickVeto(tt.dl, tt.ranked, tt.prefs)
|
||
if !strings.Contains(got, tt.wantSub) {
|
||
t.Errorf("veto = %q, want it to mention %q", got, tt.wantSub)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A clear winner has no veto at all — the sentence is empty, which is
|
||
// what AutoPickable reads.
|
||
func TestAutoPickVetoIsEmptyForAClearWinner(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
best := Score(
|
||
dl,
|
||
candidateFor("a", allTitles(), ".flac", 30_000_000),
|
||
50,
|
||
AutoDownloadPrefs{},
|
||
)
|
||
weak := Score(
|
||
dl,
|
||
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
|
||
50,
|
||
AutoDownloadPrefs{},
|
||
)
|
||
|
||
if got := AutoPickVeto(dl, []Candidate{best, weak}, AutoDownloadPrefs{}); got != "" {
|
||
t.Errorf("veto = %q, want none", got)
|
||
}
|
||
}
|
||
|
||
// With several candidates that all clear the bar, the preferred
|
||
// bitrate decides which one is taken.
|
||
//
|
||
// This is what replaced the daylight requirement. Auto-pick no longer
|
||
// refuses when the field is close; it takes the copy nearest the shape
|
||
// the user asked for, which is the question they actually answered in
|
||
// Settings.
|
||
func TestPreferredBitrateBreaksTheTie(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
prefs := AutoDownloadPrefs{PreferredKbps: 320}
|
||
|
||
// Same album, same completeness, same health, same provider — the
|
||
// only difference between them is the rate.
|
||
lossless := kbpsCandidate("lossless", ".flac", 900)
|
||
perfect := kbpsCandidate("perfect", ".mp3", 320)
|
||
|
||
ranked := Rank(
|
||
dl, []Candidate{lossless, perfect}, nil, prefs,
|
||
)
|
||
|
||
if ranked[0].ID != "perfect" {
|
||
t.Errorf(
|
||
"winner = %q (fit %f) over %q (fit %f), want the 320 kbps copy",
|
||
ranked[0].ID, ranked[0].Quality.BitrateFit,
|
||
ranked[1].ID, ranked[1].Quality.BitrateFit,
|
||
)
|
||
}
|
||
|
||
if AutoPickVeto(dl, ranked, prefs) != "" {
|
||
t.Error("a close field must still auto-pick")
|
||
}
|
||
}
|
||
|
||
// With no preference set, nothing changes: BitrateFit is the same
|
||
// neutral value for every candidate and the older tie-breaks decide.
|
||
func TestNoPreferredBitrateLeavesRankingAlone(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
|
||
lossless := kbpsCandidate("lossless", ".flac", 900)
|
||
lossy := kbpsCandidate("lossy", ".mp3", 320)
|
||
|
||
ranked := Rank(
|
||
dl, []Candidate{lossy, lossless}, nil, AutoDownloadPrefs{},
|
||
)
|
||
|
||
if ranked[0].ID != "lossless" {
|
||
t.Errorf(
|
||
"winner = %q, want the lossless copy on format alone",
|
||
ranked[0].ID,
|
||
)
|
||
}
|
||
}
|
||
|
||
// A preferred bitrate promotes the copy that matches it and must never
|
||
// disqualify the ones that do not. It carries 0.40 of the quality
|
||
// score, so a fit spanning down to zero would put a perfectly good FLAC
|
||
// under minQuality and out of auto-pick — turning a preference into a
|
||
// prohibition without saying so. MinKbps and MaxKbps are how a user
|
||
// says that on purpose.
|
||
func TestAPreferredBitrateNeverDisqualifies(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
dl := okComputer()
|
||
far := AutoDownloadPrefs{PreferredKbps: 128}
|
||
|
||
lossless := Score(dl, kbpsCandidate("flac", ".flac", 900), 50, far)
|
||
|
||
if lossless.Quality.Overall < minQuality {
|
||
t.Errorf(
|
||
"quality = %f under a far-off preference, want >= %f",
|
||
lossless.Quality.Overall, minQuality,
|
||
)
|
||
}
|
||
|
||
if veto := AutoPickVeto(dl, []Candidate{lossless}, far); veto != "" {
|
||
t.Errorf("a far-off preference vetoed the candidate: %s", veto)
|
||
}
|
||
}
|