ai-code-battle/cmd/acb-index-builder/main_test.go
jedarden 5356c8ee0a Integrate LLM client with blog generation (Phase 10)
- Add LLMBaseURL and LLMAPIKey config options for narrative generation
- Wire up LLM client to generateBlog() when LLM is configured
- Fix ParticipantData type usage in test files
- Simplify rivalry arc detection (remove alternation check)
- Fix type conversion in upset detection gap calculation
- Mark narrative engine as complete in PROGRESS.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 06:54:02 -04:00

653 lines
16 KiB
Go

package main
import (
"encoding/json"
"image"
"image/color"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadConfig(t *testing.T) {
// Set test environment variables
t.Setenv("ACB_POSTGRES_HOST", "testhost")
t.Setenv("ACB_POSTGRES_PORT", "5433")
t.Setenv("ACB_POSTGRES_DATABASE", "testdb")
t.Setenv("ACB_POSTGRES_USER", "testuser")
t.Setenv("ACB_POSTGRES_PASSWORD", "testpass")
t.Setenv("ACB_BUILD_INTERVAL", "30s")
t.Setenv("ACB_DEPLOY_INTERVAL", "3")
t.Setenv("ACB_MAX_LIFETIME", "2h")
t.Setenv("ACB_BUILD_TIMEOUT", "5m")
t.Setenv("ACB_OUTPUT_DIR", "/tmp/test-output")
cfg := LoadConfig()
if cfg.PostgresHost != "testhost" {
t.Errorf("PostgresHost: got %q, want %q", cfg.PostgresHost, "testhost")
}
if cfg.PostgresPort != 5433 {
t.Errorf("PostgresPort: got %d, want %d", cfg.PostgresPort, 5433)
}
if cfg.BuildInterval != 30*time.Second {
t.Errorf("BuildInterval: got %v, want %v", cfg.BuildInterval, 30*time.Second)
}
if cfg.DeployInterval != 3 {
t.Errorf("DeployInterval: got %d, want %d", cfg.DeployInterval, 3)
}
if cfg.MaxLifetime != 2*time.Hour {
t.Errorf("MaxLifetime: got %v, want %v", cfg.MaxLifetime, 2*time.Hour)
}
if cfg.BuildTimeout != 5*time.Minute {
t.Errorf("BuildTimeout: got %v, want %v", cfg.BuildTimeout, 5*time.Minute)
}
}
func TestLoadConfigDefaults(t *testing.T) {
// Clear all env vars
os.Clearenv()
cfg := LoadConfig()
// Check defaults
if cfg.PostgresHost != "cnpg-apexalgo-rw.cnpg.svc.cluster.local" {
t.Errorf("PostgresHost default: got %q", cfg.PostgresHost)
}
if cfg.PostgresPort != 5432 {
t.Errorf("PostgresPort default: got %d", cfg.PostgresPort)
}
if cfg.BuildInterval != 15*time.Minute {
t.Errorf("BuildInterval default: got %v", cfg.BuildInterval)
}
if cfg.DeployInterval != 6 {
t.Errorf("DeployInterval default: got %d", cfg.DeployInterval)
}
if cfg.MaxLifetime != 4*time.Hour {
t.Errorf("MaxLifetime default: got %v", cfg.MaxLifetime)
}
if cfg.BuildTimeout != 10*time.Minute {
t.Errorf("BuildTimeout default: got %v", cfg.BuildTimeout)
}
}
func TestGenerateLeaderboard(t *testing.T) {
data := &IndexData{
GeneratedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
Bots: []BotData{
{
ID: "bot1",
Name: "TestBot1",
OwnerID: "owner1",
Rating: 1650.0,
RatingDeviation: 50.0,
MatchesPlayed: 100,
MatchesWon: 75,
HealthStatus: "ACTIVE",
Evolved: false,
CreatedAt: time.Now(),
},
{
ID: "bot2",
Name: "TestBot2",
OwnerID: "owner2",
Rating: 1550.0,
RatingDeviation: 75.0,
MatchesPlayed: 50,
MatchesWon: 25,
HealthStatus: "ACTIVE",
Evolved: true,
Island: "python",
Generation: 5,
CreatedAt: time.Now(),
},
},
Matches: []MatchData{},
}
tmpDir := t.TempDir()
dataDir := filepath.Join(tmpDir, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
t.Fatalf("Failed to create data dir: %v", err)
}
if err := generateLeaderboard(data, tmpDir); err != nil {
t.Fatalf("generateLeaderboard failed: %v", err)
}
// Read and verify the generated file
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "leaderboard.json"))
if err != nil {
t.Fatalf("Failed to read leaderboard.json: %v", err)
}
var leaderboard struct {
Entries []LeaderboardEntry `json:"entries"`
}
if err := json.Unmarshal(content, &leaderboard); err != nil {
t.Fatalf("Failed to parse leaderboard.json: %v", err)
}
if len(leaderboard.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(leaderboard.Entries))
}
// First entry should be highest rated
if leaderboard.Entries[0].BotID != "bot1" {
t.Errorf("First entry bot_id: got %q, want %q", leaderboard.Entries[0].BotID, "bot1")
}
if leaderboard.Entries[0].Rating != 1650 {
t.Errorf("First entry rating: got %d, want %d", leaderboard.Entries[0].Rating, 1650)
}
}
func TestGenerateBotDirectory(t *testing.T) {
data := &IndexData{
GeneratedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC),
Bots: []BotData{
{ID: "bot1", Name: "Bot1", Rating: 1500, MatchesPlayed: 10, MatchesWon: 5},
{ID: "bot2", Name: "Bot2", Rating: 1600, MatchesPlayed: 20, MatchesWon: 10},
},
}
tmpDir := t.TempDir()
botsDir := filepath.Join(tmpDir, "data", "bots")
if err := os.MkdirAll(botsDir, 0755); err != nil {
t.Fatalf("Failed to create bots dir: %v", err)
}
if err := generateBotDirectory(data, tmpDir); err != nil {
t.Fatalf("generateBotDirectory failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(botsDir, "index.json"))
if err != nil {
t.Fatalf("Failed to read bots/index.json: %v", err)
}
var dir BotDirectory
if err := json.Unmarshal(content, &dir); err != nil {
t.Fatalf("Failed to parse bots/index.json: %v", err)
}
if len(dir.Bots) != 2 {
t.Errorf("Expected 2 bots, got %d", len(dir.Bots))
}
}
func TestGenerateMatchIndex(t *testing.T) {
now := time.Now()
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "bot1", Name: "Bot1"},
{ID: "bot2", Name: "Bot2"},
},
Matches: []MatchData{
{
ID: "match1",
WinnerID: "bot1",
TurnCount: 200,
EndCondition: "elimination",
CompletedAt: now,
Participants: []ParticipantData{
{BotID: "bot1", Score: 5, Won: true},
{BotID: "bot2", Score: 2, Won: false},
},
},
},
}
tmpDir := t.TempDir()
matchesDir := filepath.Join(tmpDir, "data", "matches")
if err := os.MkdirAll(matchesDir, 0755); err != nil {
t.Fatalf("Failed to create matches dir: %v", err)
}
botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"}
if err := generateMatchIndex(data, tmpDir, botNameMap); err != nil {
t.Fatalf("generateMatchIndex failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(matchesDir, "index.json"))
if err != nil {
t.Fatalf("Failed to read matches/index.json: %v", err)
}
var index MatchIndex
if err := json.Unmarshal(content, &index); err != nil {
t.Fatalf("Failed to parse matches/index.json: %v", err)
}
if len(index.Matches) != 1 {
t.Errorf("Expected 1 match, got %d", len(index.Matches))
}
if index.Matches[0].ID != "match1" {
t.Errorf("Match ID: got %q, want %q", index.Matches[0].ID, "match1")
}
if index.Matches[0].Turns != 200 {
t.Errorf("Match turns: got %d, want %d", index.Matches[0].Turns, 200)
}
}
func TestGeneratePlaylists(t *testing.T) {
now := time.Now()
data := &IndexData{
GeneratedAt: now,
Bots: []BotData{
{ID: "bot1", Name: "Bot1"},
{ID: "bot2", Name: "Bot2"},
},
Matches: []MatchData{
{
ID: "match1",
WinnerID: "bot1",
TurnCount: 200,
EndCondition: "elimination",
CompletedAt: now,
Participants: []ParticipantData{
{BotID: "bot1", Score: 3, Won: true},
{BotID: "bot2", Score: 2, Won: false}, // Close finish (diff = 1)
},
},
{
ID: "match2",
WinnerID: "bot2",
TurnCount: 150,
EndCondition: "dominance",
CompletedAt: now.Add(-time.Hour),
Participants: []ParticipantData{
{BotID: "bot1", Score: 0, Won: false},
{BotID: "bot2", Score: 10, Won: true}, // Not close (diff = 10)
},
},
},
}
tmpDir := t.TempDir()
playlistsDir := filepath.Join(tmpDir, "data", "playlists")
if err := os.MkdirAll(playlistsDir, 0755); err != nil {
t.Fatalf("Failed to create playlists dir: %v", err)
}
botNameMap := map[string]string{"bot1": "Bot1", "bot2": "Bot2"}
if err := generatePlaylists(data, tmpDir, botNameMap); err != nil {
t.Fatalf("generatePlaylists failed: %v", err)
}
// Check closest-finishes playlist
content, err := os.ReadFile(filepath.Join(playlistsDir, "closest-finishes.json"))
if err != nil {
t.Fatalf("Failed to read closest-finishes.json: %v", err)
}
var playlist Playlist
if err := json.Unmarshal(content, &playlist); err != nil {
t.Fatalf("Failed to parse closest-finishes.json: %v", err)
}
// Should only include match1 (close finish)
if len(playlist.Matches) != 1 {
t.Errorf("closest-finishes: expected 1 match, got %d", len(playlist.Matches))
}
}
func TestFilterMatches(t *testing.T) {
matches := []MatchData{
{ID: "m1", TurnCount: 100},
{ID: "m2", TurnCount: 200},
{ID: "m3", TurnCount: 300},
}
filtered := filterMatches(matches, func(m MatchData) bool {
return m.TurnCount >= 200
})
if len(filtered) != 2 {
t.Errorf("Expected 2 matches, got %d", len(filtered))
}
}
func TestRound1(t *testing.T) {
tests := []struct {
input float64
expected float64
}{
{75.0, 75.0},
{75.55, 75.6},
{75.54, 75.5},
{0.0, 0.0},
{99.99, 100.0},
}
for _, tt := range tests {
result := round1(tt.input)
if result != tt.expected {
t.Errorf("round1(%f) = %f, want %f", tt.input, result, tt.expected)
}
}
}
func TestComputeTopPredictors(t *testing.T) {
stats := []PredictorStats{
{PredictorID: "p1", Correct: 10, Incorrect: 2, Streak: 5, BestStreak: 8},
{PredictorID: "p2", Correct: 8, Incorrect: 3, Streak: 2, BestStreak: 5},
{PredictorID: "p3", Correct: 15, Incorrect: 5, Streak: 3, BestStreak: 10},
}
top := computeTopPredictors(stats)
// Should return all 3 if under 50
if len(top) != 3 {
t.Errorf("Expected 3 predictors, got %d", len(top))
}
}
func TestWriteJSON(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.json")
data := map[string]string{"key": "value"}
if err := writeJSON(path, data); err != nil {
t.Fatalf("writeJSON failed: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
// Verify it's valid JSON with proper formatting
var result map[string]string
if err := json.Unmarshal(content, &result); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}
if result["key"] != "value" {
t.Errorf("JSON content: got %q, want %q", result["key"], "value")
}
// Verify indentation (should contain newlines)
if len(content) < 20 {
t.Errorf("JSON seems unformatted: %q", string(content))
}
}
func TestGenerateBotCard(t *testing.T) {
cfg := DefaultCardConfig
bot := BotCard{
BotID: "bot_test123",
Name: "TestBot",
Rating: 1650,
WinRate: 75.5,
MatchesPlayed: 100,
Wins: 75,
Losses: 25,
Rank: 1,
Evolved: false,
HealthStatus: "ACTIVE",
}
img, err := generateBotCard(bot, cfg)
if err != nil {
t.Fatalf("generateBotCard failed: %v", err)
}
// Verify image dimensions
bounds := img.Bounds()
if bounds.Dx() != cfg.Width {
t.Errorf("Image width: got %d, want %d", bounds.Dx(), cfg.Width)
}
if bounds.Dy() != cfg.Height {
t.Errorf("Image height: got %d, want %d", bounds.Dy(), cfg.Height)
}
// Verify the image is not blank (should have some non-zero pixels)
hasContent := false
for y := 0; y < bounds.Dy(); y++ {
for x := 0; x < bounds.Dx(); x++ {
r, g, b, a := img.At(x, y).RGBA()
if r > 0 || g > 0 || b > 0 || a > 0 {
hasContent = true
break
}
}
if hasContent {
break
}
}
if !hasContent {
t.Error("Generated image appears to be blank")
}
}
func TestGenerateBotCardEvolved(t *testing.T) {
cfg := DefaultCardConfig
bot := BotCard{
BotID: "evolved_bot456",
Name: "EvolvedBot",
Rating: 1820,
WinRate: 82.0,
MatchesPlayed: 50,
Wins: 41,
Losses: 9,
Rank: 5,
Evolved: true,
Island: "python",
Generation: 10,
HealthStatus: "ACTIVE",
}
img, err := generateBotCard(bot, cfg)
if err != nil {
t.Fatalf("generateBotCard failed: %v", err)
}
// Verify image dimensions
bounds := img.Bounds()
if bounds.Dx() != cfg.Width {
t.Errorf("Image width: got %d, want %d", bounds.Dx(), cfg.Width)
}
if bounds.Dy() != cfg.Height {
t.Errorf("Image height: got %d, want %d", bounds.Dy(), cfg.Height)
}
}
func TestGenerateAllBotCards(t *testing.T) {
data := &IndexData{
GeneratedAt: time.Now(),
Bots: []BotData{
{
ID: "bot1",
Name: "TestBot1",
Rating: 1650.0,
MatchesPlayed: 100,
MatchesWon: 75,
HealthStatus: "ACTIVE",
Evolved: false,
},
{
ID: "bot2",
Name: "TestBot2",
Rating: 1550.0,
MatchesPlayed: 50,
MatchesWon: 25,
HealthStatus: "ACTIVE",
Evolved: true,
Island: "python",
Generation: 5,
},
},
}
tmpDir := t.TempDir()
if err := generateAllBotCards(data, tmpDir); err != nil {
t.Fatalf("generateAllBotCards failed: %v", err)
}
// Verify cards directory was created
cardsDir := filepath.Join(tmpDir, "cards")
if _, err := os.Stat(cardsDir); os.IsNotExist(err) {
t.Error("Cards directory was not created")
}
// Verify PNG files were created for each bot
for _, bot := range data.Bots {
cardPath := filepath.Join(cardsDir, bot.ID+".png")
if _, err := os.Stat(cardPath); os.IsNotExist(err) {
t.Errorf("Card file not created for bot %s", bot.ID)
}
// Verify the file is a valid PNG by checking its header
content, err := os.ReadFile(cardPath)
if err != nil {
t.Errorf("Failed to read card file for bot %s: %v", bot.ID, err)
continue
}
// PNG files start with these bytes
pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
if len(content) < len(pngHeader) {
t.Errorf("Card file too small for bot %s: %d bytes", bot.ID, len(content))
continue
}
for i, b := range pngHeader {
if content[i] != b {
t.Errorf("Card file for bot %s is not a valid PNG (header mismatch at byte %d)", bot.ID, i)
break
}
}
}
}
func TestGetColorForRating(t *testing.T) {
tests := []struct {
rating int
name string
checkR uint8
}{
{2100, "gold", 255},
{1850, "silver", 192},
{1650, "bronze", 205},
{1450, "green", 100},
{1200, "gray", 200},
}
for _, tt := range tests {
col := getColorForRating(tt.rating)
if col.R != tt.checkR {
t.Errorf("getColorForRating(%d): R = %d, want %d", tt.rating, col.R, tt.checkR)
}
}
}
func TestGetWinRateColor(t *testing.T) {
tests := []struct {
winRate float64
name string
checkG uint8
}{
{75.0, "green", 197},
{60.0, "blue", 130},
{40.0, "yellow", 179},
{20.0, "red", 68},
}
for _, tt := range tests {
col := getWinRateColor(tt.winRate)
if col.G != tt.checkG {
t.Errorf("getWinRateColor(%f): G = %d, want %d", tt.winRate, col.G, tt.checkG)
}
}
}
func TestGetRankBadgeColor(t *testing.T) {
tests := []struct {
rank int
name string
checkR uint8
}{
{1, "gold", 255},
{2, "silver", 192},
{3, "bronze", 205},
{5, "blue", 59},
{50, "gray", 100},
}
for _, tt := range tests {
col := getRankBadgeColor(tt.rank)
if col.R != tt.checkR {
t.Errorf("getRankBadgeColor(%d): R = %d, want %d", tt.rank, col.R, tt.checkR)
}
}
}
func TestGetAccentColor(t *testing.T) {
// Test evolved bot
evolvedCol := getAccentColor(true, "ACTIVE")
if evolvedCol.R != 138 || evolvedCol.G != 43 || evolvedCol.B != 226 {
t.Errorf("Evolved accent color: got R=%d,G=%d,B=%d, want purple (138,43,226)",
evolvedCol.R, evolvedCol.G, evolvedCol.B)
}
// Test inactive bot
inactiveCol := getAccentColor(false, "INACTIVE")
if inactiveCol.R != 128 || inactiveCol.G != 128 || inactiveCol.B != 128 {
t.Errorf("Inactive accent color: got R=%d,G=%d,B=%d, want gray (128,128,128)",
inactiveCol.R, inactiveCol.G, inactiveCol.B)
}
// Test active bot
activeCol := getAccentColor(false, "ACTIVE")
if activeCol.R != 59 || activeCol.G != 130 || activeCol.B != 246 {
t.Errorf("Active accent color: got R=%d,G=%d,B=%d, want blue (59,130,246)",
activeCol.R, activeCol.G, activeCol.B)
}
}
func TestSavePNG(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "test.png")
// Create a simple test image
img := generateTestImage(100, 100)
if err := savePNG(path, img); err != nil {
t.Fatalf("savePNG failed: %v", err)
}
// Verify file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("PNG file was not created")
}
// Verify file is a valid PNG
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read PNG file: %v", err)
}
pngHeader := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
for i, b := range pngHeader {
if content[i] != b {
t.Errorf("File is not a valid PNG (header mismatch at byte %d)", i)
break
}
}
}
func generateTestImage(width, height int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: 100, G: 100, B: 100, A: 255})
}
}
return img
}