Files
NaviWatcher/internal/scanner/scanner_test.go
Vladimir Zagainov e73b17673e
Some checks failed
Build and Push Docker Image / build (pull_request) Failing after 38s
feat: implement Live/Remix filtering and add CI/CD pipeline
This commit includes:

1. Live/Remix Filtering Feature:
   - Added ignore_live and ignore_remix columns to artist_settings table (migration 010)
   - Updated ArtistSettings struct with IgnoreLive and IgnoreRemix fields
   - Modified SaveArtistSettings and UpdateArtistSettings to handle new fields
   - Extended FilterOptions struct with IgnoreLive and IgnoreRemix
   - Updated ApplyTypeToggles and ApplyTypeTogglesToReleaseGroups to filter Live/Remix types
   - Added toggleIgnoreLive and toggleIgnoreRemix handlers in web layer
   - Updated ArtistData view model and artist.html template with new toggle UI
   - Comprehensive test coverage for all new functionality

2. CI/CD Pipeline with Gitea Actions:
   - Added .gitea/workflows/docker-build.yml for automated Docker builds
   - Workflow triggers on pushes to main/master and tags, plus PRs
   - Runs Go tests before building
   - Builds and pushes multi-architecture Docker images to gitea.mrixs.me
   - Includes caching for faster subsequent builds
   - Proper tagging strategy (branch, semver, SHA)
   - CI-CD-GUIDE.md documentation

3. Cleanup:
   - Removed temporary build artifacts and coverage files
2026-08-05 22:55:40 +03:00

323 lines
9.5 KiB
Go

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)
}
}