Tab/space alignment consistency from running gofmt on all packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
513 lines
15 KiB
Go
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")
|
|
}
|
|
}
|