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