mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
162 lines
6.6 KiB
Go
162 lines
6.6 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type testProvider struct {
|
|
snapshot CatalogSnapshot
|
|
}
|
|
|
|
func (p testProvider) Snapshot(context.Context, string) (CatalogSnapshot, error) {
|
|
return p.snapshot, nil
|
|
}
|
|
|
|
func TestRecommendBlendsContentAndCollaborativeSignals(t *testing.T) {
|
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
|
engine := NewEngine(EngineConfig{
|
|
Now: func() time.Time { return now },
|
|
ContentWeight: 0.44,
|
|
CollabWeight: 0.28,
|
|
PopularityWeight: 0.08,
|
|
ExplorationWeight: 0.20,
|
|
DiversityLambda: 0.74,
|
|
})
|
|
|
|
tracks := []Track{
|
|
track("liked", "Known Good", "A", []string{"synth"}, 0.7, AudioFeatures{Danceability: 0.8, Energy: 0.8, Loudness: -5, Valence: 0.7, Tempo: 120, TimeSignature: 4, Key: 1, Mode: 1}),
|
|
track("neighbor", "Neighbor Pick", "B", []string{"synth"}, 0.6, AudioFeatures{Danceability: 0.76, Energy: 0.77, Loudness: -6, Valence: 0.66, Tempo: 121, TimeSignature: 4, Key: 2, Mode: 1}),
|
|
track("far", "Far Away", "C", []string{"folk"}, 0.5, AudioFeatures{Danceability: 0.2, Energy: 0.2, Loudness: -18, Acousticness: 0.9, Valence: 0.3, Tempo: 80, TimeSignature: 3, Key: 9, Mode: 0}),
|
|
}
|
|
|
|
recs, profile, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
|
Tracks: tracks,
|
|
Interactions: []Interaction{
|
|
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
|
{UserID: "u1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-2 * time.Hour)},
|
|
{UserID: "n1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-3 * time.Hour)},
|
|
{UserID: "n1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-4 * time.Hour)},
|
|
{UserID: "n1", TrackID: "neighbor", Type: InteractionLike, OccurredAt: now.Add(-5 * time.Hour)},
|
|
},
|
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
|
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
|
if err != nil {
|
|
t.Fatalf("recommend: %v", err)
|
|
}
|
|
if len(recs) == 0 {
|
|
t.Fatal("expected recommendations")
|
|
}
|
|
if recs[0].Track.ID != "neighbor" {
|
|
t.Fatalf("expected neighbor track first, got %q", recs[0].Track.ID)
|
|
}
|
|
if profile.Confidence <= 0 {
|
|
t.Fatalf("expected non-zero confidence, got %v", profile.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestRecommendRespectsControls(t *testing.T) {
|
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
|
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.8})
|
|
explicit := track("explicit", "Explicit", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4})
|
|
explicit.Explicit = true
|
|
clean := track("clean", "Clean", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.69, Energy: 0.71, Loudness: -6, Valence: 0.68, Tempo: 121, TimeSignature: 4})
|
|
|
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
|
Tracks: []Track{
|
|
track("seed", "Seed", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4}),
|
|
explicit,
|
|
clean,
|
|
},
|
|
Interactions: []Interaction{{UserID: "u1", TrackID: "seed", Type: InteractionLike, OccurredAt: now}},
|
|
Controls: UserControls{UserID: "u1", AllowExplicit: false},
|
|
}}, RecommendRequest{UserID: "u1", Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("recommend: %v", err)
|
|
}
|
|
for _, rec := range recs {
|
|
if rec.Track.ID == "explicit" {
|
|
t.Fatal("explicit track should be filtered")
|
|
}
|
|
}
|
|
if len(recs) != 1 || recs[0].Track.ID != "clean" {
|
|
t.Fatalf("expected only clean track, got %#v", recs)
|
|
}
|
|
}
|
|
|
|
func TestRecommendUsesMetadataWhenAudioFeaturesAreMissing(t *testing.T) {
|
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
|
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.85})
|
|
|
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
|
Tracks: []Track{
|
|
track("seed", "Seed", "Seed Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
|
track("genre-match", "Genre Match", "Other Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
|
track("unrelated", "Unrelated", "Far Artist", []string{"folk"}, 0.5, AudioFeatures{}),
|
|
},
|
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
|
}}, RecommendRequest{UserID: "u1", SeedTrackIDs: []string{"seed"}, Limit: 2})
|
|
if err != nil {
|
|
t.Fatalf("recommend: %v", err)
|
|
}
|
|
if len(recs) == 0 {
|
|
t.Fatal("expected recommendations")
|
|
}
|
|
if recs[0].Track.ID != "genre-match" {
|
|
t.Fatalf("expected metadata genre match first, got %q", recs[0].Track.ID)
|
|
}
|
|
for _, rec := range recs {
|
|
if rec.Track.ID == "seed" {
|
|
t.Fatal("seed track should not be recommended back")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecommendPenalizesSkippedNeighborhoods(t *testing.T) {
|
|
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
|
engine := NewEngine(EngineConfig{
|
|
Now: func() time.Time { return now },
|
|
ContentWeight: 0.74,
|
|
PopularityWeight: 0.08,
|
|
ExplorationWeight: 0.18,
|
|
DiversityLambda: 0.9,
|
|
})
|
|
|
|
audio := AudioFeatures{Danceability: 0.74, Energy: 0.76, Loudness: -5, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.04, Liveness: 0.12, Valence: 0.66, Tempo: 124, TimeSignature: 4, Key: 2, Mode: 1}
|
|
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
|
Tracks: []Track{
|
|
track("liked", "Liked", "A", []string{"dance"}, 0.7, audio),
|
|
track("skipped", "Skipped", "B", []string{"metal"}, 0.7, audio),
|
|
track("penalized", "Penalized", "C", []string{"metal"}, 0.7, audio),
|
|
track("safe", "Safe", "D", []string{"dance"}, 0.62, AudioFeatures{Danceability: 0.72, Energy: 0.74, Loudness: -6, Speechiness: 0.05, Acousticness: 0.14, Instrumentalness: 0.05, Liveness: 0.1, Valence: 0.64, Tempo: 125, TimeSignature: 4, Key: 3, Mode: 1}),
|
|
},
|
|
Interactions: []Interaction{
|
|
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
|
{UserID: "u1", TrackID: "skipped", Type: InteractionSkip, OccurredAt: now.Add(-30 * time.Minute)},
|
|
},
|
|
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
|
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
|
if err != nil {
|
|
t.Fatalf("recommend: %v", err)
|
|
}
|
|
if len(recs) == 0 {
|
|
t.Fatal("expected recommendations")
|
|
}
|
|
if recs[0].Track.ID != "safe" {
|
|
t.Fatalf("expected non-skipped neighborhood first, got %q", recs[0].Track.ID)
|
|
}
|
|
}
|
|
|
|
func track(id, title, artist string, genres []string, popularity float64, features AudioFeatures) Track {
|
|
return Track{
|
|
ID: id,
|
|
Title: title,
|
|
Artist: artist,
|
|
Genres: genres,
|
|
Popularity: popularity,
|
|
Features: features,
|
|
DiscoveryAllowed: true,
|
|
}
|
|
}
|