package playlist import ( "math" "testing" ) func TestScoreCandidateExactFilename(t *testing.T) { t.Parallel() pp := newPhantomProfile( "/old/path/Artist/Album/01 - Song.flac", "Artist - Song", 243, ) score := scoreCandidate( pp, "/new/path/Artist/Album/01 - Song.flac", "Song", "Artist", 243000, ) if score < 0.9 { t.Errorf("expected score >= 0.9, got %f", score) } } func TestScoreCandidateNoMatch(t *testing.T) { t.Parallel() pp := newPhantomProfile( "/music/Artist/Album/01 - Song.flac", "Artist - Song", 243, ) score := scoreCandidate( pp, "/music/Completely/Different/track.mp3", "Other Title", "Other Artist", 180000, ) if score > 0.3 { t.Errorf("expected score <= 0.3, got %f", score) } } func TestScoreCandidateSameFilenameNewDir(t *testing.T) { t.Parallel() // Common case: file moved to a different directory. pp := newPhantomProfile( "/music/Old Dir/Artist/01 - Song.flac", "Artist - Song", 243, ) score := scoreCandidate( pp, "/music/New Dir/Artist/01 - Song.flac", "Song", "Artist", 243000, ) if score < 0.8 { t.Errorf( "expected score >= 0.8 for same filename, got %f", score, ) } } func TestScoreCandidateDurationOnly(t *testing.T) { t.Parallel() // Very close duration, but different filenames. score := scoreDuration(243, 243500) if score < 0.8 { t.Errorf( "expected duration score >= 0.8 for ~0.5s diff, got %f", score, ) } // Exact match. score = scoreDuration(180, 180000) if score != 1.0 { t.Errorf( "expected 1.0 for exact match, got %f", score, ) } // Far apart. score = scoreDuration(100, 200000) if score != 0.0 { t.Errorf( "expected 0.0 for 100s diff, got %f", score, ) } // Unknown duration. score = scoreDuration(0, 180000) if score != 0.0 { t.Errorf( "expected 0.0 for unknown, got %f", score, ) } } func TestScoreFilename(t *testing.T) { t.Parallel() tests := []struct { name string phantom string cand string minScore float64 maxScore float64 }{ { name: "exact match", phantom: "/a/b/song.flac", cand: "/c/d/song.flac", minScore: 1.0, maxScore: 1.0, }, { name: "same stem different ext", phantom: "/a/song.flac", cand: "/b/song.mp3", minScore: 0.7, maxScore: 0.9, }, { name: "completely different", phantom: "/a/song.flac", cand: "/b/other.mp3", minScore: 0.0, maxScore: 0.2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() pp := newPhantomProfile(tt.phantom, "", 0) score := scoreFilename(pp, tt.cand) if score < tt.minScore || score > tt.maxScore { t.Errorf( "scoreFilename(%q, %q) = %f, want [%f, %f]", tt.phantom, tt.cand, score, tt.minScore, tt.maxScore, ) } }) } } func TestScoreTitleArtist(t *testing.T) { t.Parallel() tests := []struct { name string display string title string artist string minScore float64 }{ { name: "exact match", display: "Pink Floyd - Comfortably Numb", title: "Comfortably Numb", artist: "Pink Floyd", minScore: 0.9, }, { name: "title only match", display: "Comfortably Numb", title: "Comfortably Numb", artist: "Pink Floyd", minScore: 0.7, }, { name: "no match", display: "Something Else", title: "Completely Different", artist: "Other Artist", minScore: 0.0, }, { name: "empty display title", display: "", title: "Any Title", artist: "Any Artist", minScore: 0.0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() pp := newPhantomProfile( "/dummy/path.flac", tt.display, 0, ) score := scoreTitleArtist( pp, tt.title, tt.artist, ) if score < tt.minScore { t.Errorf( "scoreTitleArtist(%q, %q, %q) = %f, want >= %f", tt.display, tt.title, tt.artist, score, tt.minScore, ) } }) } } func TestExtractKeywords(t *testing.T) { t.Parallel() tests := []struct { name string path string expected []string }{ { name: "typical music path", path: "/music/Pink Floyd/The Wall/03 - Another Brick in the Wall.flac", expected: []string{ "music", "pink", "floyd", "the", "wall", "another", "brick", "in", }, }, { name: "simple filename", path: "song.mp3", expected: []string{"song"}, }, { name: "track number stripped", path: "01 - Song Title.flac", expected: []string{"song", "title"}, }, { name: "empty path", path: "", expected: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := extractKeywords(tt.path) if !stringSliceEqual(result, tt.expected) { t.Errorf( "extractKeywords(%q) = %v, want %v", tt.path, result, tt.expected, ) } }) } } func TestParseDisplayTitle(t *testing.T) { t.Parallel() tests := []struct { input string artist string title string }{ { input: "Artist - Title", artist: "Artist", title: "Title", }, { input: "Just a Title", artist: "", title: "Just a Title", }, { input: "", artist: "", title: "", }, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { t.Parallel() artist, title := parseDisplayTitle(tt.input) if artist != tt.artist || title != tt.title { t.Errorf( "parseDisplayTitle(%q) = (%q, %q), want (%q, %q)", tt.input, artist, title, tt.artist, tt.title, ) } }) } } func TestKeywordOverlap(t *testing.T) { t.Parallel() // Full overlap. score := keywordOverlap( []string{"a", "b", "c"}, []string{"a", "b", "c", "d"}, ) if score != 1.0 { t.Errorf("expected 1.0, got %f", score) } // Partial overlap. score = keywordOverlap( []string{"a", "b", "c"}, []string{"a", "d", "e"}, ) expected := 1.0 / 3.0 if math.Abs(score-expected) > 0.01 { t.Errorf("expected ~%f, got %f", expected, score) } // No overlap. score = keywordOverlap( []string{"a", "b"}, []string{"c", "d"}, ) if score != 0.0 { t.Errorf("expected 0.0, got %f", score) } // Empty source. score = keywordOverlap(nil, []string{"a"}) if score != 0.0 { t.Errorf("expected 0.0 for empty source, got %f", score) } } func TestSortCandidatesByScore(t *testing.T) { t.Parallel() candidates := []CandidateTrack{ {FilePath: "a", Score: 0.3}, {FilePath: "b", Score: 0.9}, {FilePath: "c", Score: 0.6}, } sortCandidatesByScore(candidates) if candidates[0].FilePath != "b" { t.Errorf( "expected first candidate to be 'b', got %q", candidates[0].FilePath, ) } if candidates[1].FilePath != "c" { t.Errorf( "expected second candidate to be 'c', got %q", candidates[1].FilePath, ) } if candidates[2].FilePath != "a" { t.Errorf( "expected third candidate to be 'a', got %q", candidates[2].FilePath, ) } } // stringSliceEqual compares two string slices. func stringSliceEqual(a, b []string) bool { if len(a) == 0 && len(b) == 0 { return true } if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // TestScoreCandidateUnknownCandidateDuration pins which side may be the // one that does not know a duration. An M3U8 without EXTINF lines was // already handled; a *library* file whose length was never read was not, // so an otherwise exact match was scored out of 0.9 and could not reach // the auto-match threshold. func TestScoreCandidateUnknownCandidateDuration(t *testing.T) { t.Parallel() pp := newPhantomProfile( "/old/Music/Artist/01 - Song.mp3", "Artist - Song", 240, ) score := scoreCandidate( pp, "/new/Music/Artist/01 - Song.mp3", "Song", "Artist", 0, // the library never read this file's length ) if score < autoMatchMinimum { t.Errorf( "score = %f, want >= %f for an exact match with no "+ "candidate duration", score, autoMatchMinimum, ) } } // TestAssignBestFirst covers the rule that one library file cannot // resolve two phantom tracks, and which phantom gets it when both want // the same one. func TestAssignBestFirst(t *testing.T) { t.Parallel() offers := []phantomOffer{ // The playlist's first phantom wants this file, but only // just — and the third one is a better answer for it. { phantomPath: "/gone/a.mp3", candidate: CandidateTrack{ FilePath: "/lib/shared.mp3", Score: 0.86, }, }, { phantomPath: "/gone/b.mp3", candidate: CandidateTrack{ FilePath: "/lib/b.mp3", Score: 0.90, }, }, { phantomPath: "/gone/c.mp3", candidate: CandidateTrack{ FilePath: "/lib/shared.mp3", Score: 0.98, }, }, } matches := assignBestFirst(offers) if len(matches) != 2 { t.Fatalf("matches = %d, want 2", len(matches)) } got := make(map[string]string, len(matches)) for _, m := range matches { got[m.PhantomPath] = m.Candidate.FilePath } if got["/gone/c.mp3"] != "/lib/shared.mp3" { t.Errorf( "the shared file went to %v, want /gone/c.mp3 to have it", got, ) } if _, claimed := got["/gone/a.mp3"]; claimed { t.Error("/gone/a.mp3 took a file a better match had claimed") } if got["/gone/b.mp3"] != "/lib/b.mp3" { t.Errorf("/gone/b.mp3 matched %q, want /lib/b.mp3", got["/gone/b.mp3"]) } } // TestAssignBestFirstKeepsOnePerPhantom: a phantom with several // confident candidates takes its best one and no more. func TestAssignBestFirstKeepsOnePerPhantom(t *testing.T) { t.Parallel() matches := assignBestFirst([]phantomOffer{ { phantomPath: "/gone/a.mp3", candidate: CandidateTrack{ FilePath: "/lib/one.mp3", Score: 0.90, }, }, { phantomPath: "/gone/a.mp3", candidate: CandidateTrack{ FilePath: "/lib/two.mp3", Score: 0.95, }, }, }) if len(matches) != 1 { t.Fatalf("matches = %d, want 1", len(matches)) } if matches[0].Candidate.FilePath != "/lib/two.mp3" { t.Errorf( "matched %q, want the higher-scoring /lib/two.mp3", matches[0].Candidate.FilePath, ) } }