package recommendation import ( "encoding/json" "testing" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func track(artistID pgtype.UUID, genre string) dbq.Track { t := dbq.Track{ ArtistID: artistID, } if genre != "" { g := genre t.Genre = &g } return t } func uuid(s string) pgtype.UUID { var u pgtype.UUID _ = u.Scan(s) return u } func TestBuildSessionVector_Empty_IsSeed(t *testing.T) { v := BuildSessionVector(nil) if !v.Seed { t.Errorf("Seed = false, want true for empty input") } if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 { t.Errorf("non-empty collections: %+v", v) } } func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") tr := track(a1, "rock") tr.ID = uuid("22222222-2222-2222-2222-222222222222") v := BuildSessionVector([]dbq.Track{tr}) if !v.Seed { t.Errorf("1 track: Seed=false, want true (< 3)") } if len(v.Artists) != 1 { t.Errorf("Artists: %+v", v.Artists) } if v.Tags["rock"] != 1 { t.Errorf("Tags: %+v", v.Tags) } if len(v.RecentTrackIDs) != 1 { t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs) } } func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")}) if !v.Seed { t.Errorf("2 tracks: Seed=false, want true (< 3)") } } func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ track(a1, "rock"), track(a1, "rock"), track(a1, "rock"), }) if v.Seed { t.Errorf("3 tracks: Seed=true, want false") } // All same artist → 1 entry. if len(v.Artists) != 1 { t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists)) } // All same genre → count 3. if v.Tags["rock"] != 3 { t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"]) } } func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") a2 := uuid("22222222-2222-2222-2222-222222222222") a3 := uuid("33333333-3333-3333-3333-333333333333") v := BuildSessionVector([]dbq.Track{ track(a1, "rock"), track(a2, "jazz"), track(a3, "rock"), track(a1, "experimental"), }) // 4 tracks → not seed. if v.Seed { t.Errorf("Seed=true, want false") } // Distinct artists: 3 (a1 dedup'd). if len(v.Artists) != 3 { t.Errorf("Artists len = %d, want 3", len(v.Artists)) } // Tag counts. if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 { t.Errorf("Tags = %+v", v.Tags) } if len(v.RecentTrackIDs) != 4 { t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs)) } } func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ track(a1, ""), // empty genre via the helper (nil pointer) track(a1, "rock"), track(a1, ""), }) if v.Tags["rock"] != 1 { t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"]) } if _, exists := v.Tags[""]; exists { t.Errorf("Tags has empty-string entry: %+v", v.Tags) } } func TestBuildSessionVector_MultiGenreSplitsOnSemicolon(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ track(a1, "Indie Pop; Pop; Alternative Pop"), track(a1, "Pop"), track(a1, "Boom Bap; Hip Hop; Lo-Fi"), }) // Pop appears once on track 1 and once on track 2 → count 2. if v.Tags["Pop"] != 2 { t.Errorf("Tags['Pop'] = %d, want 2 (one per matching track)", v.Tags["Pop"]) } // Indie Pop, Alternative Pop only on track 1. if v.Tags["Indie Pop"] != 1 || v.Tags["Alternative Pop"] != 1 { t.Errorf("Indie/Alt Pop counts wrong: %+v", v.Tags) } // Track 3's genres. if v.Tags["Boom Bap"] != 1 || v.Tags["Hip Hop"] != 1 || v.Tags["Lo-Fi"] != 1 { t.Errorf("track-3 genre counts wrong: %+v", v.Tags) } } func TestBuildSessionVector_NoSeparatorStaysOpaque(t *testing.T) { // Concatenated strings without separators (broken tag-editor output) // can't be parsed; they stay as one tag and only match identical strings. a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ track(a1, "ElectronicComplextroGlitch Hop"), track(a1, "ElectronicComplextroGlitch Hop"), }) if v.Tags["ElectronicComplextroGlitch Hop"] != 2 { t.Errorf("opaque tag count = %d, want 2", v.Tags["ElectronicComplextroGlitch Hop"]) } if len(v.Tags) != 1 { t.Errorf("len(Tags) = %d, want 1", len(v.Tags)) } } func TestSplitGenres(t *testing.T) { cases := []struct { in string want []string }{ {"", nil}, {"Pop", []string{"Pop"}}, {"Pop; Rock", []string{"Pop", "Rock"}}, {"Pop, Rock", []string{"Pop", "Rock"}}, {"Pop; Rock, Jazz", []string{"Pop", "Rock", "Jazz"}}, {" Pop ; Rock ", []string{"Pop", "Rock"}}, {";; Pop ;;", []string{"Pop"}}, {"ElectronicComplextroGlitch Hop", []string{"ElectronicComplextroGlitch Hop"}}, } for _, c := range cases { got := splitGenres(c.in) if c.want == nil { if len(got) != 0 { t.Errorf("splitGenres(%q) = %v, want empty", c.in, got) } continue } if len(got) != len(c.want) { t.Errorf("splitGenres(%q) = %v, want %v", c.in, got, c.want) continue } for i := range got { if got[i] != c.want[i] { t.Errorf("splitGenres(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) } } } } func TestBuildSessionVector_JSONRoundTrip(t *testing.T) { a1 := uuid("11111111-1111-1111-1111-111111111111") v := BuildSessionVector([]dbq.Track{ track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"), }) data, err := json.Marshal(v) if err != nil { t.Fatalf("marshal: %v", err) } var got SessionVector if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.Seed != v.Seed { t.Errorf("Seed: %v != %v", got.Seed, v.Seed) } if got.Tags["rock"] != v.Tags["rock"] { t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags) } if len(got.Artists) != len(v.Artists) { t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists)) } if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) { t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs)) } }