ai-code-battle/cmd/acb-index-builder/rivalry_test.go
jedarden ea04f4debb style: apply gofmt alignment fixes across codebase
Tab/space alignment consistency from running gofmt on all packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:40:33 -04:00

513 lines
15 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func TestComputeRivalries_BasicPair(t *testing.T) {
now := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
// Create 12 matches between bot1 and bot2 (above min threshold of 10)
matches := make([]MatchData, 12)
for i := 0; i < 6; i++ {
matches[i] = MatchData{
ID: fmt.Sprintf("m_a_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(12-i) * 24 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 5 - i%3, Won: true},
{BotID: "bot2", Score: 2 + i%2, Won: false},
},
}
}
for i := 0; i < 6; i++ {
matches[6+i] = MatchData{
ID: fmt.Sprintf("m_b_%d", i),
WinnerID: "bot2",
PlayedAt: now.Add(-time.Duration(6-i) * 24 * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 2 + i%2, Won: false},
{BotID: "bot2", Score: 5 - i%3, Won: true},
},
}
}
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "bot1", Name: "AlphaBot"},
{ID: "bot2", Name: "BetaBot"},
},
Matches: matches,
}
botNameMap := map[string]string{"bot1": "AlphaBot", "bot2": "BetaBot"}
rivalries := computeRivalries(data, botNameMap)
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry, got %d", len(rivalries))
}
r := rivalries[0]
if r.BotA.ID != "bot1" || r.BotB.ID != "bot2" {
t.Errorf("Bot IDs: got %q/%q, want bot1/bot2", r.BotA.ID, r.BotB.ID)
}
if r.TotalMatches != 12 {
t.Errorf("TotalMatches: got %d, want 12", r.TotalMatches)
}
if r.Record.AWins != 6 || r.Record.BWins != 6 {
t.Errorf("Record: got %d-%d, want 6-6", r.Record.AWins, r.Record.BWins)
}
if r.Score <= 0 {
t.Errorf("Score should be positive, got %f", r.Score)
}
if r.Narrative == "" {
t.Error("Narrative should not be empty")
}
if r.ClosestMatch == "" {
t.Error("ClosestMatch should not be empty")
}
if len(r.RecentMatches) > 10 {
t.Errorf("RecentMatches: got %d, want at most 10", len(r.RecentMatches))
}
}
func TestComputeRivalries_BelowThreshold(t *testing.T) {
now := time.Now()
// Only 5 matches — below rivalryMinMatches (10)
matches := make([]MatchData, 5)
for i := 0; i < 5; i++ {
matches[i] = MatchData{
ID: fmt.Sprintf("m_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
},
}
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 0 {
t.Errorf("Expected 0 rivalries below threshold, got %d", len(rivalries))
}
}
func TestComputeRivalries_ScoreRanking(t *testing.T) {
now := time.Now()
// Pair A: 20 matches, 10-10 split (perfect balance, high volume)
var matchesA []MatchData
for i := 0; i < 10; i++ {
matchesA = append(matchesA, MatchData{
ID: fmt.Sprintf("a_win_%d", i), WinnerID: "botA",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botA", Score: 3, Won: true},
{BotID: "botB", Score: 1, Won: false},
},
})
matchesA = append(matchesA, MatchData{
ID: fmt.Sprintf("b_win_%d", i), WinnerID: "botB",
PlayedAt: now.Add(-time.Duration(10+i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botA", Score: 1, Won: false},
{BotID: "botB", Score: 3, Won: true},
},
})
}
// Pair C/D: 12 matches, 10-2 split (imbalanced, lower score)
var matchesCD []MatchData
for i := 0; i < 10; i++ {
matchesCD = append(matchesCD, MatchData{
ID: fmt.Sprintf("cd_a_%d", i), WinnerID: "botC",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botC", Score: 5, Won: true},
{BotID: "botD", Score: 1, Won: false},
},
})
}
for i := 0; i < 2; i++ {
matchesCD = append(matchesCD, MatchData{
ID: fmt.Sprintf("cd_b_%d", i), WinnerID: "botD",
PlayedAt: now.Add(-time.Duration(10+i) * time.Hour),
Participants: []ParticipantData{
{BotID: "botC", Score: 1, Won: false},
{BotID: "botD", Score: 3, Won: true},
},
})
}
allMatches := append(matchesA, matchesCD...)
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "botA", Name: "Alpha"},
{ID: "botB", Name: "Beta"},
{ID: "botC", Name: "Charlie"},
{ID: "botD", Name: "Delta"},
},
Matches: allMatches,
}
botNameMap := map[string]string{
"botA": "Alpha", "botB": "Beta", "botC": "Charlie", "botD": "Delta",
}
rivalries := computeRivalries(data, botNameMap)
if len(rivalries) != 2 {
t.Fatalf("Expected 2 rivalries, got %d", len(rivalries))
}
// The balanced pair (A/B) should rank higher than the imbalanced pair (C/D)
if rivalries[0].BotA.ID == "botC" || rivalries[0].BotB.ID == "botC" {
// Check if the balanced pair is second (would be wrong)
if rivalries[1].BotA.ID == "botA" || rivalries[1].BotB.ID == "botA" {
t.Error("Balanced pair (A/B) should rank higher than imbalanced pair (C/D)")
}
}
}
func TestComputeRivalries_TopKLimit(t *testing.T) {
now := time.Now()
// Create 25 qualifying pairs (each with 10 matches)
var matches []MatchData
for pair := 0; pair < 25; pair++ {
botA := fmt.Sprintf("bot_%02da", pair)
botB := fmt.Sprintf("bot_%02db", pair)
for i := 0; i < 10; i++ {
winner := botA
if i >= 5 {
winner = botB
}
matches = append(matches, MatchData{
ID: fmt.Sprintf("pair%d_m%d", pair, i),
WinnerID: winner,
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: botA, Score: 3, Won: winner == botA},
{BotID: botB, Score: 2, Won: winner == botB},
},
})
}
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) > rivalryTopK {
t.Errorf("Expected at most %d rivalries, got %d", rivalryTopK, len(rivalries))
}
if len(rivalries) != rivalryTopK {
t.Errorf("Expected exactly %d rivalries (25 pairs qualify), got %d", rivalryTopK, len(rivalries))
}
}
func TestComputeRivalries_Draws(t *testing.T) {
now := time.Now()
// 10 matches with 4 draws
matches := []MatchData{
{ID: "m1", WinnerID: "bot1", PlayedAt: now.Add(-10 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3, Won: true}, {BotID: "bot2", Score: 1, Won: false}}},
{ID: "m2", WinnerID: "bot2", PlayedAt: now.Add(-9 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1, Won: false}, {BotID: "bot2", Score: 3, Won: true}}},
{ID: "m3", PlayedAt: now.Add(-8 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2}, {BotID: "bot2", Score: 2}}}, // draw
{ID: "m4", WinnerID: "bot1", PlayedAt: now.Add(-7 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 4, Won: true}, {BotID: "bot2", Score: 2, Won: false}}},
{ID: "m5", PlayedAt: now.Add(-6 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2}, {BotID: "bot2", Score: 2}}}, // draw
{ID: "m6", WinnerID: "bot2", PlayedAt: now.Add(-5 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1, Won: false}, {BotID: "bot2", Score: 3, Won: true}}},
{ID: "m7", WinnerID: "bot1", PlayedAt: now.Add(-4 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3, Won: true}, {BotID: "bot2", Score: 2, Won: false}}},
{ID: "m8", PlayedAt: now.Add(-3 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 3}, {BotID: "bot2", Score: 3}}}, // draw
{ID: "m9", WinnerID: "bot2", PlayedAt: now.Add(-2 * time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 2, Won: false}, {BotID: "bot2", Score: 4, Won: true}}},
{ID: "m10", PlayedAt: now.Add(-time.Hour), Participants: []ParticipantData{{BotID: "bot1", Score: 1}, {BotID: "bot2", Score: 1}}}, // draw
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, map[string]string{"bot1": "A", "bot2": "B"})
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry, got %d", len(rivalries))
}
r := rivalries[0]
if r.Record.Draws != 4 {
t.Errorf("Draws: got %d, want 4", r.Record.Draws)
}
if r.Record.AWins+r.Record.BWins+r.Record.Draws != 10 {
t.Errorf("Total: got %d, want 10", r.Record.AWins+r.Record.BWins+r.Record.Draws)
}
}
func TestComputeRivalries_MultiPlayerSkipped(t *testing.T) {
now := time.Now()
// 10 two-player matches + 5 three-player matches (should be ignored)
var matches []MatchData
for i := 0; i < 10; i++ {
matches = append(matches, MatchData{
ID: fmt.Sprintf("2p_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
},
})
}
for i := 0; i < 5; i++ {
matches = append(matches, MatchData{
ID: fmt.Sprintf("3p_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 1, Won: false},
{BotID: "bot3", Score: 0, Won: false},
},
})
}
data := &IndexData{
GeneratedAt: now,
Matches: matches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 1 {
t.Fatalf("Expected 1 rivalry (only 2-player matches), got %d", len(rivalries))
}
// Should only count 10 two-player matches, not 15 total
if rivalries[0].TotalMatches != 10 {
t.Errorf("TotalMatches: got %d, want 10", rivalries[0].TotalMatches)
}
}
func TestComputeRivalries_RecencyBoost(t *testing.T) {
now := time.Now()
// Pair 1: all matches in the last week (high recency), balanced 5-5 split
var recentMatches []MatchData
for i := 0; i < 5; i++ {
recentMatches = append(recentMatches, MatchData{
ID: fmt.Sprintf("recent_a_%d", i),
WinnerID: "bot1",
PlayedAt: now.Add(-time.Duration(i*12) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 2, Won: false},
},
})
recentMatches = append(recentMatches, MatchData{
ID: fmt.Sprintf("recent_b_%d", i),
WinnerID: "bot2",
PlayedAt: now.Add(-time.Duration(i*12+6) * time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 2, Won: false},
{BotID: "bot2", Score: 3, Won: true},
},
})
}
// Pair 2: all matches 6 months ago (low recency), balanced 5-5 split
var oldMatches []MatchData
for i := 0; i < 5; i++ {
oldMatches = append(oldMatches, MatchData{
ID: fmt.Sprintf("old_a_%d", i),
WinnerID: "bot3",
PlayedAt: now.Add(-180*24*time.Hour - time.Duration(i)*time.Hour),
Participants: []ParticipantData{
{BotID: "bot3", Score: 3, Won: true},
{BotID: "bot4", Score: 2, Won: false},
},
})
oldMatches = append(oldMatches, MatchData{
ID: fmt.Sprintf("old_b_%d", i),
WinnerID: "bot4",
PlayedAt: now.Add(-180*24*time.Hour - time.Duration(i+5)*time.Hour),
Participants: []ParticipantData{
{BotID: "bot3", Score: 2, Won: false},
{BotID: "bot4", Score: 3, Won: true},
},
})
}
allMatches := append(recentMatches, oldMatches...)
data := &IndexData{
GeneratedAt: now,
Matches: allMatches,
}
rivalries := computeRivalries(data, nil)
if len(rivalries) != 2 {
t.Fatalf("Expected 2 rivalries, got %d", len(rivalries))
}
// The recent pair should rank higher than the old pair
if rivalries[0].BotA.ID != "bot1" && rivalries[0].BotB.ID != "bot1" {
t.Error("Recent pair should rank higher than old pair due to recency weighting")
}
}
func TestLongestStreak(t *testing.T) {
tests := []struct {
name string
winners []string
botA string
botB string
wantLen int
wantNil bool
}{
{"empty", []string{}, "a", "b", 0, true},
{"single", []string{"a"}, "a", "b", 0, true}, // < 2
{"two streak", []string{"a", "a"}, "a", "b", 2, false},
{"alternating", []string{"a", "b", "a", "b"}, "a", "b", 0, true},
{"long streak", []string{"a", "a", "a", "b", "a", "a", "a", "a"}, "a", "b", 4, false},
{"draws break streak", []string{"a", "a", "draw", "a", "a"}, "a", "b", 2, false},
{"all draws", []string{"draw", "draw"}, "a", "b", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := longestStreak(tt.winners, tt.botA, tt.botB)
if tt.wantNil {
if result != nil {
t.Errorf("expected nil, got %+v", result)
}
} else {
if result == nil {
t.Fatal("expected non-nil streak")
}
if result.Length != tt.wantLen {
t.Errorf("streak length: got %d, want %d", result.Length, tt.wantLen)
}
}
})
}
}
func TestBuildRivalryNarrative(t *testing.T) {
tests := []struct {
name string
aWins int
bWins int
draws int
streak *RivalryStreak
wantEmpty bool
}{
{"tied", 5, 5, 0, nil, false},
{"tied with draws", 5, 5, 2, nil, false},
{"dominant", 8, 2, 0, nil, false},
{"with streak", 7, 3, 0, &RivalryStreak{Holder: "AlphaBot", Length: 4}, false},
{"close", 6, 4, 0, nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
narrative := buildRivalryNarrative("AlphaBot", "BetaBot", "alpha-id", "beta-id", tt.aWins+tt.bWins+tt.draws, tt.aWins, tt.bWins, tt.draws, tt.streak)
if tt.wantEmpty && narrative != "" {
t.Errorf("expected empty narrative, got %q", narrative)
}
if !tt.wantEmpty && narrative == "" {
t.Error("expected non-empty narrative")
}
})
}
}
func TestGenerateRivalriesIndex(t *testing.T) {
tmpDir := t.TempDir()
rivalries := []RivalryEntry{
{
BotA: RivalryBot{ID: "bot1", Name: "Alpha"},
BotB: RivalryBot{ID: "bot2", Name: "Beta"},
TotalMatches: 15,
Record: RivalryRecord{AWins: 8, BWins: 7, Draws: 0},
Score: 2.34,
Narrative: "Alpha and Beta have met 15 times.",
},
}
if err := generateRivalriesIndex(rivalries, tmpDir); err != nil {
t.Fatalf("generateRivalriesIndex failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "meta", "rivalries.json"))
if err != nil {
t.Fatalf("Failed to read rivalries.json: %v", err)
}
var index RivalriesIndex
if err := json.Unmarshal(content, &index); err != nil {
t.Fatalf("Failed to parse rivalries.json: %v", err)
}
if len(index.Rivalries) != 1 {
t.Errorf("Expected 1 rivalry, got %d", len(index.Rivalries))
}
if index.UpdatedAt == "" {
t.Error("UpdatedAt should not be empty")
}
if index.Rivalries[0].BotA.Name != "Alpha" {
t.Errorf("BotA name: got %q, want %q", index.Rivalries[0].BotA.Name, "Alpha")
}
}
func TestGenerateRivalriesIndex_Empty(t *testing.T) {
tmpDir := t.TempDir()
if err := generateRivalriesIndex(nil, tmpDir); err != nil {
t.Fatalf("generateRivalriesIndex with nil failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "meta", "rivalries.json"))
if err != nil {
t.Fatalf("Failed to read rivalries.json: %v", err)
}
var index RivalriesIndex
if err := json.Unmarshal(content, &index); err != nil {
t.Fatalf("Failed to parse rivalries.json: %v", err)
}
if len(index.Rivalries) != 0 {
t.Errorf("Expected 0 rivalries, got %d", len(index.Rivalries))
}
}
func TestPairKey(t *testing.T) {
if pairKey("a", "b") != "a:b" {
t.Errorf("pairKey(a,b): got %q", pairKey("a", "b"))
}
if pairKey("b", "a") != "a:b" {
t.Errorf("pairKey(b,a): got %q", pairKey("b", "a"))
}
if pairKey("b", "a") != pairKey("a", "b") {
t.Error("pairKey should be canonical")
}
}