Three faults, one subsystem, and the middle one is why a request that looked obviously satisfiable came back refused. **The guardrails were in megabytes, which cannot mean anything.** 300 MB is a generous FLAC single and a suspiciously small boxset, and whoever fills the field in has no idea which release the pipeline will apply it to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided by how long the music is, so one number holds across a nine-minute EP and a three-hour opera. The runtime comes from `Download.Expected`, which every anchored request already carries, so this costs no lookup; the rate is audio bytes over that, falling back to the mean stated per-file bitrate when the runtime is unknown. Artwork is excluded from the numerator, or a folder with 30 MB of scans reads as a better rip. An unknown runtime *passes* the window rather than failing it: the window is a statement about quality, and refusing everything the moment MusicBrainz is missing a track length would be a silent embargo. `MaxFileSizeMB` survives as a separate ceiling, still in megabytes on purpose -- it is a question about disk space, and it has to apply to a candidate whose bitrate cannot be worked out at all. **Auto-pick required daylight over the runner-up**, 0.08 on the combined score, and so fired hardest in the case it was never written for: a popular album turns up five *correct* copies, all matching the tracklist at 95%+ and differing only in format and seeders, their scores land within a point of each other, and it refused forever on the grounds that the choice was the user's. It was not. There was no question about what to fetch, only about which copy -- and abundance is the condition under which that matters least. A candidate no longer has to beat the field, only clear the bars on its own terms; where several do, ranking puts the one closest to the preferred bitrate first. That tie-break needed the preference to carry weight or it would have been decorative in a new unit: `BitrateFit` was 0.05 against format's 0.42, so asking for 320 and being handed a FLAC every time was the designed behaviour. When a preference is set the weights shift to fit 0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics that exist as stand-ins for the preference the user has now given. Health and priority are untouched. And the fit spans 0.5 to 1.0 rather than 0 to 1, so a preference can promote the copy that matches it and can never push the others under `minQuality` -- turning "I like 320" into "never take anything else" silently is what `MinKbps`/`MaxKbps` are for, out loud. **And a refusal quoted numbers that passed.** The request list built its message from `ranked[0]` -- the best candidate *before* the guardrails and before the lead check -- so a request killed by the size window, or by having too many good copies, reported "best of 12 found is not a confident enough match (match 96%, quality 88%)". `AutoPickVeto` names the gate that actually refused, and `AutoPickable` is that returning empty. Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not migrated. A number meaning "300 MB" cannot be reinterpreted as a rate without knowing the album it was aimed at, so carrying it over would be inventing an intent nobody expressed. Those two fall back to no window, which is the permissive default and what a fresh install gets; `MaxFileSizeMB` carries over unchanged, because a ceiling on bytes still means exactly what it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
742 lines
19 KiB
Go
742 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
|
||
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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|