Files
minstrel/internal/recommendation/similarity_test.go
T
bvandeusen ad56eb279f style(recommendation): drop trailing comments that gofmt-1.23 wants aligned
CI's golangci-lint (`gofmt -s`) on Go 1.23 flagged inline comments after
the SessionVector literals in TestContextualMatchScore_TakesMax because
the variable-length values produced misaligned trailing columns. Local
gofmt on Go 1.26 was lenient. Replace with a leading comment that covers
all three rows; same intent, no alignment dance.
2026-04-27 22:41:24 -04:00

159 lines
5.7 KiB
Go

package recommendation
import (
"math"
"testing"
)
func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) {
v := SessionVector{
Artists: []string{"a1", "a2"},
Tags: map[string]int{"rock": 2, "indie": 1},
}
got := Similarity(v, v, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("Similarity(v,v) = %v, want 1.0", got)
}
}
func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("disjoint = %v, want 0.0", got)
}
}
func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.7) {
t.Errorf("tags-only = %v, want 0.7", got)
}
}
func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.3) {
t.Errorf("artists-only = %v, want 0.3", got)
}
}
func TestSimilarity_EitherSeed_Returns0(t *testing.T) {
v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) {
t.Errorf("v vs seed = %v, want 0.0", got)
}
if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) {
t.Errorf("seed vs v = %v, want 0.0", got)
}
}
func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) {
a := SessionVector{}
b := SessionVector{}
got := Similarity(a, b, DefaultSimilarityWeights)
if math.IsNaN(got) || !approxEq(got, 0.0) {
t.Errorf("empty = %v, want 0.0 (not NaN)", got)
}
}
func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) {
a := SessionVector{Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a1"}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("one-axis-each = %v, want 0.0", got)
}
}
func TestSimilarity_PartialTagsOverlap(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
want := 0.7*(1.0/3.0) + 0.3*1.0
if !approxEq(got, want) {
t.Errorf("partial = %v, want %v", got, want)
}
}
func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("set-collapse = %v, want 1.0", got)
}
}
func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
got := ContextualMatchScore(current, nil, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("no likes = %v, want 0.0", got)
}
got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("empty likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) {
current := SessionVector{Seed: true}
likes := []SessionVector{
{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("current seed = %v, want 0.0", got)
}
}
func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("all-seed likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_TakesMax(t *testing.T) {
// Three likes: full match, partial match, mismatch. Expect full match (1.0).
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
// Three likes covering 1.0 (full match), 0.7 (tags-only match), 0.0 (mismatch).
likes := []SessionVector{
{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("takes-max = %v, want 1.0", got)
}
}
func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) {
// One Seed=true match (would be 1.0 if not filtered) + one partial match.
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
// Seed=true filtered out → only partial match counts → 0.7
if !approxEq(got, 0.7) {
t.Errorf("filter-then-max = %v, want 0.7", got)
}
}