95d68e3d3d
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).
1. Genre splitting in BuildSessionVector
The library has many tracks whose genre tag is a denormalized
multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
as one opaque tag means a single-genre "Pop" track and a multi-genre
track listing Pop both fail to share the Pop key, so the tags axis
in similarity scoring (weight 0.7!) returned 0 in nearly all cases
on real libraries. Result: contextual scoring couldn't differentiate.
Add splitGenres() that splits on ; and , and trims whitespace;
strings without a delimiter come back as a single-element slice
(so the existing single-genre cases keep working unchanged).
Concatenated-without-separator output ("ElectronicComplextroGlitch
Hop") stays opaque — that needs a genre dictionary, out of scope.
2. Play-radio button on TrackRow
playRadio() was only wired to the click handler on /search and
/search/tracks, leaving /library/liked, album pages, and search
results' inner clicks with no way to start a radio. Adds a small
radio button to TrackRow (between LikeButton and the +queue button).
Click → playRadio(track.id), with stopPropagation so the row's own
activate handler doesn't also fire.
Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
split, no-separator stays opaque. Existing single-genre tests pass
unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
does NOT trigger row play.
Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
223 lines
6.2 KiB
Go
223 lines
6.2 KiB
Go
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))
|
|
}
|
|
}
|