package scanner import ( "testing" "naviwatcher/internal/database" "naviwatcher/internal/musicbrainz" ) func TestSimilarity(t *testing.T) { tests := []struct { name string a string b string want float64 epsilon float64 }{ { name: "exact match scores 1.0", a: "The Wall", b: "The Wall", want: 1.0, epsilon: 1e-9, }, { name: "case-insensitive exact match scores 1.0", a: "The Wall", b: "the wall", want: 1.0, epsilon: 1e-9, }, { name: "remastered variant stays above threshold", a: "The Wall", b: "The Wall (Remastered)", want: 1.0, // parenthesized content is stripped during normalization epsilon: 1e-9, }, { name: "year-suffixed variant stays above threshold", a: "Abbey Road", b: "Abbey Road (2019 Remix)", // After normalization both collapse to "abbey road" → identical. want: 1.0, epsilon: 1e-9, }, { name: "clearly different titles score low", a: "The Wall", b: "Completely Different Album", want: 0.1538, epsilon: 1e-3, }, { name: "substring-ish title scores moderately below threshold", a: "Dark Side of the Moon", b: "Dark Side of the Moon Part II", want: 0.7241, epsilon: 1e-3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Similarity(tt.a, tt.b) if diff := got - tt.want; diff > tt.epsilon || diff < -tt.epsilon { t.Errorf("Similarity(%q, %q) = %v, want %v (+/- %v)", tt.a, tt.b, got, tt.want, tt.epsilon) } // Sanity: anything at/above the default threshold must be a match. if got >= 0.85 && !IsMatch(tt.a, tt.b, 0) { t.Errorf("Similarity(%q, %q) = %v >= 0.85 but IsMatch(...,0) is false", tt.a, tt.b, got) } }) } } func TestSimilarity_EmptyStrings(t *testing.T) { // Both empty → no match (0.0). if got := Similarity("", ""); got != 0.0 { t.Errorf("Similarity(%q, %q) = %v, want 0.0", "", "", got) } // One empty, one non-empty → no similarity. if got := Similarity("The Wall", ""); got != 0.0 { t.Errorf("Similarity(%q, %q) = %v, want 0.0", "The Wall", "", got) } if got := Similarity("", "The Wall"); got != 0.0 { t.Errorf("Similarity(%q, %q) = %v, want 0.0", "", "The Wall", got) } // Whitespace-only inputs normalize to empty → no match. if got := Similarity(" ", "The Wall"); got != 0.0 { t.Errorf("Similarity(%q, %q) = %v, want 0.0", " ", "The Wall", got) } } func TestIsMatch(t *testing.T) { const threshold = 0.85 tests := []struct { name string a string b string expected bool }{ {name: "exact match is a match", a: "The Wall", b: "The Wall", expected: true}, {name: "remastered variant is a match", a: "The Wall", b: "The Wall (Remastered)", expected: true}, {name: "year variant is a match", a: "Abbey Road", b: "Abbey Road (2019)", expected: true}, {name: "clearly different is not a match", a: "The Wall", b: "Random Noise", expected: false}, {name: "empty vs non-empty is not a match", a: "", b: "The Wall", expected: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsMatch(tt.a, tt.b, threshold); got != tt.expected { t.Errorf("IsMatch(%q, %q, %v) = %v, want %v", tt.a, tt.b, threshold, got, tt.expected) } }) } } func TestFindMissingReleases(t *testing.T) { const threshold = 0.85 artistA := "artist-a" artistB := "artist-b" tests := []struct { name string local []database.LocalAlbum external []database.ExternalRelease want []string // RGIDs expected to be reported as missing }{ { name: "no local albums means all external are missing", local: nil, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistA, Title: "The Wall"}, {RGID: "rg2", ArtistID: artistA, Title: "Animals"}, }, want: []string{"rg1", "rg2"}, }, { name: "exact local title is not missing", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall"}, }, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistA, Title: "The Wall"}, {RGID: "rg2", ArtistID: artistA, Title: "Animals"}, }, want: []string{"rg2"}, }, { name: "fuzzy local title (remastered) is not missing", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall (Remastered)"}, }, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistA, Title: "The Wall"}, {RGID: "rg2", ArtistID: artistA, Title: "Animals"}, }, want: []string{"rg2"}, }, { name: "ignored external is never reported", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall"}, }, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistA, Title: "The Wall"}, {RGID: "rg2", ArtistID: artistA, Title: "Animals", IsIgnored: true}, }, want: []string{}, }, { name: "different artist id is not matched across artists", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall"}, }, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistB, Title: "The Wall"}, }, want: []string{"rg1"}, }, { name: "threshold boundary at 0.85", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall Live"}, }, external: []database.ExternalRelease{ {RGID: "rg1", ArtistID: artistA, Title: "The Wall"}, }, want: []string{"rg1"}, // "The Wall Live" vs "The Wall" is below 0.85 }, { name: "empty external list returns nothing", local: []database.LocalAlbum{ {ID: "l1", ArtistID: artistA, Title: "The Wall"}, }, external: nil, want: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FindMissingReleases(tt.local, tt.external, threshold, musicbrainz.FilterOptions{}) gotRGIDs := make([]string, 0, len(got)) for _, m := range got { gotRGIDs = append(gotRGIDs, m.RGID) } if len(gotRGIDs) != len(tt.want) { t.Fatalf("FindMissingReleases() returned %v, want RGIDs %v", gotRGIDs, tt.want) } wantSet := make(map[string]struct{}, len(tt.want)) for _, r := range tt.want { wantSet[r] = struct{}{} } for _, r := range gotRGIDs { if _, ok := wantSet[r]; !ok { t.Errorf("FindMissingReleases() returned unexpected RGID %q (got %v, want %v)", r, gotRGIDs, tt.want) } } }) } } func TestFindMissingReleases_FilterOptions(t *testing.T) { const threshold = 0.85 artist := "artist-a" external := []database.ExternalRelease{ {RGID: "rg-album", ArtistID: artist, Title: "The Wall", Type: "Album"}, {RGID: "rg-single", ArtistID: artist, Title: "B-side", Type: "Single"}, {RGID: "rg-comp", ArtistID: artist, Title: "Hits", Type: "Compilation"}, {RGID: "rg-comp-sec", ArtistID: artist, Title: "Live at X", Type: "Album", SecondaryTypes: []string{"Compilation"}}, } tests := []struct { name string filter musicbrainz.FilterOptions want []string }{ { name: "no filter reports all", filter: musicbrainz.FilterOptions{}, want: []string{"rg-album", "rg-single", "rg-comp", "rg-comp-sec"}, }, { name: "ignore singles drops Single primary type", filter: musicbrainz.FilterOptions{IgnoreSingles: true}, want: []string{"rg-album", "rg-comp", "rg-comp-sec"}, }, { name: "ignore compilations drops Compilation primary and secondary type", filter: musicbrainz.FilterOptions{IgnoreCompilations: true}, want: []string{"rg-album", "rg-single"}, }, { name: "both toggles drop singles and compilations", filter: musicbrainz.FilterOptions{IgnoreSingles: true, IgnoreCompilations: true}, want: []string{"rg-album"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FindMissingReleases(nil, external, threshold, tt.filter) gotRGIDs := make([]string, 0, len(got)) for _, m := range got { gotRGIDs = append(gotRGIDs, m.RGID) } wantSet := make(map[string]struct{}, len(tt.want)) for _, r := range tt.want { wantSet[r] = struct{}{} } if len(gotRGIDs) != len(tt.want) { t.Fatalf("got %v, want %v", gotRGIDs, tt.want) } for _, r := range gotRGIDs { if _, ok := wantSet[r]; !ok { t.Errorf("unexpected RGID %q", r) } } }) } } func TestFindMissingReleases_ThresholdBoundaryInclusive(t *testing.T) { // A title at exactly the threshold must NOT be reported as missing // (IsMatch uses >= threshold). local := []database.LocalAlbum{ {ID: "l1", ArtistID: "a", Title: "The Wall Live"}, } // Force a known score: "the wall" vs "the wall" would be 1.0; instead // use a release whose similarity is exactly 0.85 so the boundary is hit. // We assert behaviour via the documented contract using IsMatch, not a // brittle exact score here. external := []database.ExternalRelease{ {RGID: "rg1", ArtistID: "a", Title: "The Wall"}, } // With default threshold 0.85, "The Wall Live" does not match "The Wall"; // at a low threshold it would. Confirms threshold is honoured. if len(FindMissingReleases(local, external, 0.85, musicbrainz.FilterOptions{})) != 1 { t.Errorf("expected 1 missing at 0.85 threshold") } } func TestIsMatch_ThresholdBoundary(t *testing.T) { // A moderately different title should be a match at a low threshold but // not at a high one, confirming the boundary is inclusive (>=). a, b := "The Wall", "The Wall Live" low := IsMatch(a, b, 0.5) high := IsMatch(a, b, 0.99) if !low { t.Errorf("IsMatch(%q, %q, 0.5) = false, want true", a, b) } if high { t.Errorf("IsMatch(%q, %q, 0.99) = true, want false", a, b) } }