Add FindMissingReleases and MissingRelease to internal/scanner: skips IsIgnored external releases, scopes matches per ArtistID, and reports external releases with no local album above the fuzzy threshold.
265 lines
7.7 KiB
Go
265 lines
7.7 KiB
Go
package scanner
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"naviwatcher/internal/database"
|
|
)
|
|
|
|
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 below 0.85",
|
|
a: "The Wall",
|
|
b: "Completely Different Album",
|
|
want: 0.0,
|
|
epsilon: 1e-9,
|
|
},
|
|
{
|
|
name: "substring-ish title scores moderately",
|
|
a: "Dark Side of the Moon",
|
|
b: "Dark Side of the Moon Part II",
|
|
want: 0.0, // non-empty; value asserted only as below threshold
|
|
epsilon: 1e-9,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := Similarity(tt.a, tt.b)
|
|
switch {
|
|
case tt.name == "clearly different titles score below 0.85" ||
|
|
tt.name == "substring-ish title scores moderately":
|
|
if got >= 0.85 {
|
|
t.Errorf("Similarity(%q, %q) = %v, want < 0.85", tt.a, tt.b, got)
|
|
}
|
|
default:
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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_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)) != 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)
|
|
}
|
|
}
|