Adds MetaIndex struct and generateMetaIndex function to create data/meta/index.json listing all available meta data files (archetypes.json, rivalries.json) with descriptions. Also adds the new file to the R2 warm cache upload list. Closes: bf-66rk Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1597 lines
46 KiB
Go
1597 lines
46 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"image"
|
||
"image/color"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func generateTestImage(w, h int) *image.RGBA {
|
||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
img.Set(x, y, color.RGBA{R: 100, G: 100, B: 100, A: 255})
|
||
}
|
||
}
|
||
return img
|
||
}
|
||
|
||
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, &Config{}); 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},
|
||
},
|
||
},
|
||
{
|
||
ID: "match2",
|
||
WinnerID: "bot2",
|
||
TurnCount: 350,
|
||
EndCondition: "dominance",
|
||
CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 0, Won: false, PreMatchRating: 1800},
|
||
{BotID: "bot2", Score: 10, Won: true, PreMatchRating: 1500},
|
||
},
|
||
},
|
||
{
|
||
ID: "match3",
|
||
WinnerID: "bot1",
|
||
TurnCount: 400,
|
||
EndCondition: "turn_limit",
|
||
CompletedAt: now.Add(-2 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1700},
|
||
{BotID: "bot2", Score: 3, Won: false, PreMatchRating: 1600},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// Verify index.json was generated
|
||
indexContent, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read playlists/index.json: %v", err)
|
||
}
|
||
var index PlaylistIndex
|
||
if err := json.Unmarshal(indexContent, &index); err != nil {
|
||
t.Fatalf("Failed to parse playlists/index.json: %v", err)
|
||
}
|
||
if len(index.Playlists) == 0 {
|
||
t.Error("Expected playlists in index.json, got 0")
|
||
}
|
||
|
||
// Verify each playlist has required fields
|
||
for _, p := range index.Playlists {
|
||
if p.Slug == "" {
|
||
t.Error("Playlist summary missing slug")
|
||
}
|
||
if p.Title == "" {
|
||
t.Error("Playlist summary missing title")
|
||
}
|
||
if p.Category == "" {
|
||
t.Error("Playlist summary missing category")
|
||
}
|
||
}
|
||
|
||
// Verify closest-finishes playlist content
|
||
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)
|
||
}
|
||
if playlist.Category != "close_games" {
|
||
t.Errorf("closest-finishes category: got %q, want %q", playlist.Category, "close_games")
|
||
}
|
||
if len(playlist.Matches) != 2 {
|
||
t.Errorf("closest-finishes: expected 2 matches, got %d", len(playlist.Matches))
|
||
}
|
||
if len(playlist.Matches) > 0 && playlist.Matches[0].MatchID != "match1" {
|
||
t.Errorf("closest-finishes first (closest): got %q, want %q", playlist.Matches[0].MatchID, "match1")
|
||
}
|
||
|
||
// Verify marathon-matches playlist
|
||
marathonContent, err := os.ReadFile(filepath.Join(playlistsDir, "marathon-matches.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read marathon-matches.json: %v", err)
|
||
}
|
||
var marathon Playlist
|
||
if err := json.Unmarshal(marathonContent, &marathon); err != nil {
|
||
t.Fatalf("Failed to parse marathon-matches.json: %v", err)
|
||
}
|
||
if marathon.Category != "long_games" {
|
||
t.Errorf("marathon-matches category: got %q, want %q", marathon.Category, "long_games")
|
||
}
|
||
// Should include match2 (350) and match3 (400), sorted by turn count desc
|
||
if len(marathon.Matches) != 2 {
|
||
t.Errorf("marathon-matches: expected 2 matches, got %d", len(marathon.Matches))
|
||
}
|
||
if len(marathon.Matches) > 0 && marathon.Matches[0].MatchID != "match3" {
|
||
t.Errorf("marathon-matches first: got %q, want %q", marathon.Matches[0].MatchID, "match3")
|
||
}
|
||
|
||
// Verify biggest-upsets playlist
|
||
upsetContent, err := os.ReadFile(filepath.Join(playlistsDir, "biggest-upsets.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read biggest-upsets.json: %v", err)
|
||
}
|
||
var upsets Playlist
|
||
if err := json.Unmarshal(upsetContent, &upsets); err != nil {
|
||
t.Fatalf("Failed to parse biggest-upsets.json: %v", err)
|
||
}
|
||
// match2 has winner rating 1500 vs loser 1800 → upset of 300
|
||
if len(upsets.Matches) != 1 {
|
||
t.Errorf("biggest-upsets: expected 1 match, got %d", len(upsets.Matches))
|
||
}
|
||
}
|
||
|
||
func TestInterestScore(t *testing.T) {
|
||
now := time.Now()
|
||
// Close finish + upset + long game → high score
|
||
m := MatchData{
|
||
WinnerID: "bot2",
|
||
TurnCount: 450,
|
||
CompletedAt: now,
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800},
|
||
{BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1400},
|
||
},
|
||
}
|
||
score := interestScore(m)
|
||
if score < 5.0 {
|
||
t.Errorf("interestScore for exciting match: got %f, want >= 5.0", score)
|
||
}
|
||
|
||
// Boring match → low score
|
||
m2 := MatchData{
|
||
WinnerID: "bot1",
|
||
TurnCount: 100,
|
||
CompletedAt: now,
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 10, Won: true, PreMatchRating: 1500},
|
||
{BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500},
|
||
},
|
||
}
|
||
score2 := interestScore(m2)
|
||
if score2 >= 2.0 {
|
||
t.Errorf("interestScore for boring match: got %f, want < 2.0", score2)
|
||
}
|
||
}
|
||
|
||
func TestFormatMatchTitle(t *testing.T) {
|
||
data := &IndexData{
|
||
Bots: []BotData{
|
||
{ID: "bot1", Name: "SwarmBot"},
|
||
{ID: "bot2", Name: "HunterBot"},
|
||
},
|
||
}
|
||
m := MatchData{
|
||
ID: "match1",
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3},
|
||
{BotID: "bot2", Score: 2},
|
||
},
|
||
}
|
||
title := formatMatchTitle(m, data)
|
||
if title != "SwarmBot 3 – 2 HunterBot" {
|
||
t.Errorf("formatMatchTitle: got %q, want %q", title, "SwarmBot 3 – 2 HunterBot")
|
||
}
|
||
}
|
||
|
||
func TestIsComeback(t *testing.T) {
|
||
// Close upset (rating diff >= 80, score diff <= 3) = comeback
|
||
m := MatchData{
|
||
WinnerID: "bot2",
|
||
TurnCount: 300,
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: false, PreMatchRating: 1800},
|
||
{BotID: "bot2", Score: 2, Won: true, PreMatchRating: 1600},
|
||
},
|
||
}
|
||
if !isComeback(m) {
|
||
t.Error("Expected close upset to be a comeback")
|
||
}
|
||
|
||
// Decisive win, no upset = not a comeback
|
||
m2 := MatchData{
|
||
WinnerID: "bot1",
|
||
TurnCount: 150,
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 8, Won: true, PreMatchRating: 1700},
|
||
{BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1500},
|
||
},
|
||
}
|
||
if isComeback(m2) {
|
||
t.Error("Expected decisive non-upset to not be a comeback")
|
||
}
|
||
|
||
// No winner = not a comeback
|
||
m3 := MatchData{
|
||
WinnerID: "",
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3},
|
||
{BotID: "bot2", Score: 2},
|
||
},
|
||
}
|
||
if isComeback(m3) {
|
||
t.Error("Expected no-winner match to not be a comeback")
|
||
}
|
||
}
|
||
|
||
func TestTurnaroundMagnitude(t *testing.T) {
|
||
// Bigger upset + closer score = bigger turnaround
|
||
big := MatchData{
|
||
WinnerID: "underdog",
|
||
TurnCount: 400,
|
||
Participants: []ParticipantData{
|
||
{BotID: "favored", Score: 2, Won: false, PreMatchRating: 1900},
|
||
{BotID: "underdog", Score: 3, Won: true, PreMatchRating: 1500},
|
||
},
|
||
}
|
||
small := MatchData{
|
||
WinnerID: "slight_underdog",
|
||
TurnCount: 200,
|
||
Participants: []ParticipantData{
|
||
{BotID: "favored", Score: 1, Won: false, PreMatchRating: 1600},
|
||
{BotID: "slight_underdog", Score: 3, Won: true, PreMatchRating: 1500},
|
||
},
|
||
}
|
||
bigMag := turnaroundMagnitude(big)
|
||
smallMag := turnaroundMagnitude(small)
|
||
if bigMag <= smallMag {
|
||
t.Errorf("Expected bigger turnaround (%f) > smaller (%f)", bigMag, smallMag)
|
||
}
|
||
}
|
||
|
||
func TestIsEvolutionBreakthrough(t *testing.T) {
|
||
data := &IndexData{
|
||
Bots: []BotData{
|
||
{ID: "evo1", Name: "EvolvedBot", Evolved: true},
|
||
{ID: "human1", Name: "HumanBot", Evolved: false},
|
||
},
|
||
}
|
||
|
||
// Evolved bot beats high-rated opponent
|
||
m := MatchData{
|
||
WinnerID: "evo1",
|
||
Participants: []ParticipantData{
|
||
{BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400},
|
||
{BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650},
|
||
},
|
||
}
|
||
if !isEvolutionBreakthrough(m, data) {
|
||
t.Error("Expected evolved bot beating rated opponent to be a breakthrough")
|
||
}
|
||
|
||
// Human bot wins = not a breakthrough
|
||
m2 := MatchData{
|
||
WinnerID: "human1",
|
||
Participants: []ParticipantData{
|
||
{BotID: "evo1", Score: 1, Won: false, PreMatchRating: 1400},
|
||
{BotID: "human1", Score: 5, Won: true, PreMatchRating: 1650},
|
||
},
|
||
}
|
||
if isEvolutionBreakthrough(m2, data) {
|
||
t.Error("Expected human bot winning to not be a breakthrough")
|
||
}
|
||
|
||
// Evolved bot beats low-rated opponent = not a breakthrough
|
||
m3 := MatchData{
|
||
WinnerID: "evo1",
|
||
Participants: []ParticipantData{
|
||
{BotID: "evo1", Score: 5, Won: true, PreMatchRating: 1400},
|
||
{BotID: "human1", Score: 1, Won: false, PreMatchRating: 1200},
|
||
},
|
||
}
|
||
if isEvolutionBreakthrough(m3, data) {
|
||
t.Error("Expected evolved bot beating low-rated opponent to not be a breakthrough")
|
||
}
|
||
}
|
||
|
||
func TestIsRivalryMatch(t *testing.T) {
|
||
_ = time.Now() // ensure time import used
|
||
matches := []MatchData{
|
||
{ID: "m1", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{ID: "m2", WinnerID: "bot2", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{ID: "m3", WinnerID: "bot1", Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{ID: "m4", WinnerID: "bot3", Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}},
|
||
}
|
||
data := &IndexData{Matches: matches}
|
||
|
||
// bot1 vs bot2 has 3 matches = rivalry
|
||
m := MatchData{
|
||
WinnerID: "bot1",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}},
|
||
}
|
||
if !isRivalryMatch(m, data) {
|
||
t.Error("Expected 3-match pair to be a rivalry")
|
||
}
|
||
|
||
// bot3 vs bot4 has only 1 match = not a rivalry
|
||
m2 := MatchData{
|
||
WinnerID: "bot3",
|
||
Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}},
|
||
}
|
||
if isRivalryMatch(m2, data) {
|
||
t.Error("Expected 1-match pair to not be a rivalry")
|
||
}
|
||
}
|
||
|
||
func TestCurateWeeklyHighlights(t *testing.T) {
|
||
now := time.Now()
|
||
|
||
matches := []MatchData{
|
||
// 1. Upset: underdog wins by large rating margin
|
||
{
|
||
ID: "upset1", WinnerID: "bot2", TurnCount: 250, CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1800},
|
||
{BotID: "bot2", Score: 3, Won: true, PreMatchRating: 1400},
|
||
},
|
||
},
|
||
// 2. Elite clash: very high combined rating
|
||
{
|
||
ID: "elite1", WinnerID: "bot1", TurnCount: 150, CompletedAt: now.Add(-2 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 4, Won: true, PreMatchRating: 1800},
|
||
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1700},
|
||
},
|
||
},
|
||
// 3. Marathon: very long match
|
||
{
|
||
ID: "marathon1", WinnerID: "bot1", TurnCount: 450, CompletedAt: now.Add(-3 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1200},
|
||
{BotID: "bot2", Score: 1, Won: false, PreMatchRating: 1200},
|
||
},
|
||
},
|
||
// 4. Close finish: score diff of 1
|
||
{
|
||
ID: "close1", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-4 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1200},
|
||
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1200},
|
||
},
|
||
},
|
||
// Extra filler matches to fill out the criteria
|
||
{
|
||
ID: "filler1", WinnerID: "bot1", TurnCount: 100, CompletedAt: now.Add(-5 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 5, Won: true, PreMatchRating: 1500},
|
||
{BotID: "bot2", Score: 0, Won: false, PreMatchRating: 1500},
|
||
},
|
||
},
|
||
{
|
||
ID: "filler2", WinnerID: "bot2", TurnCount: 150, CompletedAt: now.Add(-6 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1500},
|
||
{BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1500},
|
||
},
|
||
},
|
||
// Old match — outside 7 days
|
||
{
|
||
ID: "old1", WinnerID: "bot1", TurnCount: 400, CompletedAt: now.Add(-8 * 24 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500},
|
||
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500},
|
||
},
|
||
},
|
||
// No winner
|
||
{
|
||
ID: "nowin", WinnerID: "", TurnCount: 300, CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 2},
|
||
{BotID: "bot2", Score: 2},
|
||
},
|
||
},
|
||
}
|
||
|
||
cutoff := now.AddDate(0, 0, -7)
|
||
curated := curateWeeklyHighlights(matches, cutoff)
|
||
|
||
if len(curated) == 0 {
|
||
t.Fatal("Expected curated matches, got 0")
|
||
}
|
||
if len(curated) > 20 {
|
||
t.Errorf("Expected at most 20 curated matches, got %d", len(curated))
|
||
}
|
||
|
||
seenIDs := make(map[string]bool)
|
||
for _, c := range curated {
|
||
if c.Match.ID == "" {
|
||
t.Error("Curated match has empty ID")
|
||
}
|
||
if c.Tag == "" {
|
||
t.Errorf("Curated match %s has empty tag", c.Match.ID)
|
||
}
|
||
if seenIDs[c.Match.ID] {
|
||
t.Errorf("Duplicate match ID in curated results: %s", c.Match.ID)
|
||
}
|
||
seenIDs[c.Match.ID] = true
|
||
}
|
||
|
||
if seenIDs["old1"] {
|
||
t.Error("Old match should not appear in weekly highlights")
|
||
}
|
||
if seenIDs["nowin"] {
|
||
t.Error("No-winner match should not appear in weekly highlights")
|
||
}
|
||
|
||
// Verify at least one tag per criteria type
|
||
tagTypes := make(map[string]bool)
|
||
for _, c := range curated {
|
||
if strings.Contains(c.Tag, "Closest finish") {
|
||
tagTypes["closest"] = true
|
||
}
|
||
if strings.Contains(c.Tag, "Marathon battle") {
|
||
tagTypes["marathon"] = true
|
||
}
|
||
if strings.Contains(c.Tag, "Elite clash") {
|
||
tagTypes["elite"] = true
|
||
}
|
||
if strings.Contains(c.Tag, "Upset victory") {
|
||
tagTypes["upset"] = true
|
||
}
|
||
}
|
||
if !tagTypes["closest"] {
|
||
t.Error("Expected at least one 'Closest finish' tag")
|
||
}
|
||
if !tagTypes["marathon"] {
|
||
t.Error("Expected at least one 'Marathon battle' tag")
|
||
}
|
||
if !tagTypes["elite"] {
|
||
t.Error("Expected at least one 'Elite clash' tag")
|
||
}
|
||
if !tagTypes["upset"] {
|
||
t.Error("Expected at least one 'Upset victory' tag")
|
||
}
|
||
}
|
||
|
||
func TestBestOfWeekPlaylistHasCurationTags(t *testing.T) {
|
||
now := time.Now()
|
||
data := &IndexData{
|
||
GeneratedAt: now,
|
||
Bots: []BotData{
|
||
{ID: "bot1", Name: "Bot1"},
|
||
{ID: "bot2", Name: "Bot2"},
|
||
},
|
||
Matches: []MatchData{
|
||
{
|
||
ID: "weekly_close", WinnerID: "bot1", TurnCount: 200, CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1500},
|
||
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1500},
|
||
},
|
||
},
|
||
{
|
||
ID: "weekly_marathon", WinnerID: "bot2", TurnCount: 450, CompletedAt: now.Add(-2 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 2, Won: false, PreMatchRating: 1700},
|
||
{BotID: "bot2", Score: 4, Won: true, PreMatchRating: 1600},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
content, err := os.ReadFile(filepath.Join(playlistsDir, "best-of-week.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read best-of-week.json: %v", err)
|
||
}
|
||
var playlist Playlist
|
||
if err := json.Unmarshal(content, &playlist); err != nil {
|
||
t.Fatalf("Failed to parse best-of-week.json: %v", err)
|
||
}
|
||
|
||
if playlist.Category != "weekly" {
|
||
t.Errorf("best-of-week category: got %q, want %q", playlist.Category, "weekly")
|
||
}
|
||
|
||
tagCount := 0
|
||
for _, m := range playlist.Matches {
|
||
if m.CurationTag != "" {
|
||
tagCount++
|
||
}
|
||
}
|
||
if tagCount == 0 {
|
||
t.Error("Expected best-of-week matches to have curation tags, got 0")
|
||
}
|
||
}
|
||
|
||
func TestGeneratePlaylistsWithNewTypes(t *testing.T) {
|
||
now := time.Now()
|
||
data := &IndexData{
|
||
GeneratedAt: now,
|
||
Bots: []BotData{
|
||
{ID: "human1", Name: "HumanBot", Evolved: false},
|
||
{ID: "evo1", Name: "EvoBot", Evolved: true},
|
||
},
|
||
Matches: []MatchData{
|
||
// Comeback: close upset
|
||
{
|
||
ID: "comeback1", WinnerID: "human1", TurnCount: 400, CompletedAt: now,
|
||
Participants: []ParticipantData{
|
||
{BotID: "human1", Score: 3, Won: true, PreMatchRating: 1500},
|
||
{BotID: "evo1", Score: 2, Won: false, PreMatchRating: 1700},
|
||
},
|
||
},
|
||
// Evolution breakthrough: evolved bot beats rated opponent
|
||
{
|
||
ID: "evo1", WinnerID: "evo1", TurnCount: 300, CompletedAt: now,
|
||
Participants: []ParticipantData{
|
||
{BotID: "evo1", Score: 4, Won: true, PreMatchRating: 1400},
|
||
{BotID: "human1", Score: 2, Won: false, PreMatchRating: 1650},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
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{"human1": "HumanBot", "evo1": "EvoBot"}
|
||
if err := generatePlaylists(data, tmpDir, botNameMap); err != nil {
|
||
t.Fatalf("generatePlaylists failed: %v", err)
|
||
}
|
||
|
||
// Verify best-comebacks.json
|
||
cb, err := os.ReadFile(filepath.Join(playlistsDir, "best-comebacks.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read best-comebacks.json: %v", err)
|
||
}
|
||
var cbPlaylist Playlist
|
||
if err := json.Unmarshal(cb, &cbPlaylist); err != nil {
|
||
t.Fatalf("Failed to parse best-comebacks.json: %v", err)
|
||
}
|
||
if cbPlaylist.Category != "comebacks" {
|
||
t.Errorf("comebacks category: got %q", cbPlaylist.Category)
|
||
}
|
||
|
||
// Verify evolution-breakthroughs.json
|
||
eb, err := os.ReadFile(filepath.Join(playlistsDir, "evolution-breakthroughs.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read evolution-breakthroughs.json: %v", err)
|
||
}
|
||
var ebPlaylist Playlist
|
||
if err := json.Unmarshal(eb, &ebPlaylist); err != nil {
|
||
t.Fatalf("Failed to parse evolution-breakthroughs.json: %v", err)
|
||
}
|
||
if len(ebPlaylist.Matches) < 1 {
|
||
t.Errorf("Expected at least 1 evolution breakthrough, got %d", len(ebPlaylist.Matches))
|
||
}
|
||
|
||
// Verify index.json includes new playlist types
|
||
idx, err := os.ReadFile(filepath.Join(playlistsDir, "index.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read index.json: %v", err)
|
||
}
|
||
var index PlaylistIndex
|
||
if err := json.Unmarshal(idx, &index); err != nil {
|
||
t.Fatalf("Failed to parse index.json: %v", err)
|
||
}
|
||
slugs := make(map[string]bool)
|
||
for _, p := range index.Playlists {
|
||
slugs[p.Slug] = true
|
||
}
|
||
for _, required := range []string{"best-comebacks", "evolution-breakthroughs", "rivalry-classics"} {
|
||
if !slugs[required] {
|
||
t.Errorf("Missing playlist %q in index", required)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSortSlice(t *testing.T) {
|
||
matches := []MatchData{
|
||
{ID: "m1", TurnCount: 100},
|
||
{ID: "m2", TurnCount: 300},
|
||
{ID: "m3", TurnCount: 200},
|
||
}
|
||
sortSlice(matches, func(i, j int) bool {
|
||
return matches[i].TurnCount > matches[j].TurnCount
|
||
})
|
||
if matches[0].ID != "m2" || matches[1].ID != "m3" || matches[2].ID != "m1" {
|
||
t.Errorf("sortSlice: unexpected order: %v", 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
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Fast playlist helper tests ────────────────────────────────────────────
|
||
|
||
func TestBuildFirstMatchPerBot(t *testing.T) {
|
||
now := time.Now()
|
||
matches := []MatchData{
|
||
{ID: "m1", WinnerID: "bot1", CompletedAt: now.Add(-3 * time.Hour),
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{ID: "m2", WinnerID: "bot2", CompletedAt: now.Add(-2 * time.Hour),
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{ID: "m3", WinnerID: "bot3", CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}},
|
||
}
|
||
|
||
firstMap := buildFirstMatchPerBot(matches)
|
||
|
||
if firstMap["bot1"] != "m1" {
|
||
t.Errorf("bot1 first match: got %q, want m1", firstMap["bot1"])
|
||
}
|
||
if firstMap["bot2"] != "m1" {
|
||
t.Errorf("bot2 first match: got %q, want m1", firstMap["bot2"])
|
||
}
|
||
if firstMap["bot3"] != "m3" {
|
||
t.Errorf("bot3 first match: got %q, want m3", firstMap["bot3"])
|
||
}
|
||
if firstMap["bot4"] != "m3" {
|
||
t.Errorf("bot4 first match: got %q, want m3", firstMap["bot4"])
|
||
}
|
||
}
|
||
|
||
func TestBuildFirstMatchPerBot_SkipsIncomplete(t *testing.T) {
|
||
now := time.Now()
|
||
matches := []MatchData{
|
||
{ID: "m_incomplete", WinnerID: "", CompletedAt: now,
|
||
Participants: []ParticipantData{{BotID: "bot1"}}},
|
||
{ID: "m_complete", WinnerID: "bot1", CompletedAt: now.Add(time.Hour),
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
}
|
||
|
||
firstMap := buildFirstMatchPerBot(matches)
|
||
|
||
if firstMap["bot1"] != "m_complete" {
|
||
t.Errorf("bot1 should only have m_complete (incomplete skipped), got %q", firstMap["bot1"])
|
||
}
|
||
}
|
||
|
||
func TestBuildFirstMatchPerBot_Empty(t *testing.T) {
|
||
firstMap := buildFirstMatchPerBot(nil)
|
||
if len(firstMap) != 0 {
|
||
t.Errorf("expected empty map for nil input, got %d entries", len(firstMap))
|
||
}
|
||
}
|
||
|
||
func TestIsNewBotDebutFast(t *testing.T) {
|
||
firstMap := map[string]string{
|
||
"bot1": "m1",
|
||
"bot2": "m2",
|
||
}
|
||
|
||
// bot1's debut is m1
|
||
m1 := MatchData{ID: "m1", WinnerID: "bot1",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}
|
||
if !isNewBotDebutFast(m1, firstMap) {
|
||
t.Error("m1 should be a debut (bot1's first match)")
|
||
}
|
||
|
||
// m3 is neither bot's first match
|
||
m3 := MatchData{ID: "m3", WinnerID: "bot1",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}
|
||
if isNewBotDebutFast(m3, firstMap) {
|
||
t.Error("m3 should not be a debut")
|
||
}
|
||
|
||
// No winner = not a debut
|
||
m4 := MatchData{ID: "m1", WinnerID: "",
|
||
Participants: []ParticipantData{{BotID: "bot1"}}}
|
||
if isNewBotDebutFast(m4, firstMap) {
|
||
t.Error("match with no winner should not be a debut")
|
||
}
|
||
}
|
||
|
||
func TestBuildPairFrequency(t *testing.T) {
|
||
matches := []MatchData{
|
||
{Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{Participants: []ParticipantData{{BotID: "bot2"}, {BotID: "bot1"}}},
|
||
{Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}},
|
||
{Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}},
|
||
// 3-player match should be skipped
|
||
{Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}, {BotID: "bot5"}}},
|
||
}
|
||
|
||
freq := buildPairFrequency(matches)
|
||
|
||
if freq["bot1:bot2"] != 3 {
|
||
t.Errorf("bot1:bot2 frequency: got %d, want 3", freq["bot1:bot2"])
|
||
}
|
||
if freq["bot3:bot4"] != 1 {
|
||
t.Errorf("bot3:bot4 frequency: got %d, want 1", freq["bot3:bot4"])
|
||
}
|
||
if _, ok := freq["bot1:bot5"]; ok {
|
||
t.Error("3-player match should not create a pair entry")
|
||
}
|
||
}
|
||
|
||
func TestBuildPairFrequency_Empty(t *testing.T) {
|
||
freq := buildPairFrequency(nil)
|
||
if len(freq) != 0 {
|
||
t.Errorf("expected empty map for nil input, got %d entries", len(freq))
|
||
}
|
||
}
|
||
|
||
func TestIsRivalryMatchFast(t *testing.T) {
|
||
freq := map[string]int{
|
||
"bot1:bot2": 5,
|
||
"bot3:bot4": 2,
|
||
}
|
||
|
||
// 5 matches = rivalry
|
||
m1 := MatchData{WinnerID: "bot1",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}
|
||
if !isRivalryMatchFast(m1, freq) {
|
||
t.Error("bot1 vs bot2 with 5 matches should be a rivalry")
|
||
}
|
||
|
||
// 2 matches = not a rivalry
|
||
m2 := MatchData{WinnerID: "bot3",
|
||
Participants: []ParticipantData{{BotID: "bot3"}, {BotID: "bot4"}}}
|
||
if isRivalryMatchFast(m2, freq) {
|
||
t.Error("bot3 vs bot4 with 2 matches should not be a rivalry")
|
||
}
|
||
|
||
// No winner = not a rivalry
|
||
m3 := MatchData{WinnerID: "",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}}}
|
||
if isRivalryMatchFast(m3, freq) {
|
||
t.Error("match with no winner should not be a rivalry")
|
||
}
|
||
|
||
// 3+ players = not checked
|
||
m4 := MatchData{WinnerID: "bot1",
|
||
Participants: []ParticipantData{{BotID: "bot1"}, {BotID: "bot2"}, {BotID: "bot3"}}}
|
||
if isRivalryMatchFast(m4, freq) {
|
||
t.Error("3-player match should not be a rivalry")
|
||
}
|
||
}
|
||
|
||
func TestGeneratePlaylistsWithFastLookups(t *testing.T) {
|
||
now := time.Now()
|
||
data := &IndexData{
|
||
GeneratedAt: now,
|
||
Bots: []BotData{
|
||
{ID: "bot1", Name: "Bot1"},
|
||
{ID: "bot2", Name: "Bot2"},
|
||
{ID: "bot3", Name: "Bot3"},
|
||
},
|
||
Matches: []MatchData{
|
||
// New bot debut for bot3
|
||
{ID: "debut1", WinnerID: "bot3", TurnCount: 200, CompletedAt: now,
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 2, Won: false, PreMatchRating: 1500},
|
||
{BotID: "bot3", Score: 3, Won: true, PreMatchRating: 1400},
|
||
}},
|
||
// Rivalry match (bot1 vs bot2, 3rd meeting)
|
||
{ID: "rival1", WinnerID: "bot1", TurnCount: 300, CompletedAt: now.Add(-time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 4, Won: true, PreMatchRating: 1600},
|
||
{BotID: "bot2", Score: 3, Won: false, PreMatchRating: 1550},
|
||
}},
|
||
{ID: "rival2", WinnerID: "bot2", TurnCount: 250, CompletedAt: now.Add(-2 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 1, Won: false, PreMatchRating: 1580},
|
||
{BotID: "bot2", Score: 5, Won: true, PreMatchRating: 1560},
|
||
}},
|
||
{ID: "rival3", WinnerID: "bot1", TurnCount: 350, CompletedAt: now.Add(-3 * time.Hour),
|
||
Participants: []ParticipantData{
|
||
{BotID: "bot1", Score: 3, Won: true, PreMatchRating: 1590},
|
||
{BotID: "bot2", Score: 2, Won: false, PreMatchRating: 1570},
|
||
}},
|
||
},
|
||
}
|
||
|
||
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", "bot3": "Bot3"}
|
||
if err := generatePlaylists(data, tmpDir, botNameMap); err != nil {
|
||
t.Fatalf("generatePlaylists failed: %v", err)
|
||
}
|
||
|
||
// Verify new-bot-debuts includes bot3's debut
|
||
debutContent, err := os.ReadFile(filepath.Join(playlistsDir, "new-bot-debuts.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read new-bot-debuts.json: %v", err)
|
||
}
|
||
var debutPlaylist Playlist
|
||
if err := json.Unmarshal(debutContent, &debutPlaylist); err != nil {
|
||
t.Fatalf("Failed to parse new-bot-debuts.json: %v", err)
|
||
}
|
||
foundDebut := false
|
||
for _, m := range debutPlaylist.Matches {
|
||
if m.MatchID == "debut1" {
|
||
foundDebut = true
|
||
break
|
||
}
|
||
}
|
||
if !foundDebut {
|
||
t.Errorf("new-bot-debuts should include debut1 (bot3's first match), got %d matches", len(debutPlaylist.Matches))
|
||
}
|
||
|
||
// Verify rivalry-classics includes bot1 vs bot2 matches
|
||
rivalryContent, err := os.ReadFile(filepath.Join(playlistsDir, "rivalry-classics.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read rivalry-classics.json: %v", err)
|
||
}
|
||
var rivalryPlaylist Playlist
|
||
if err := json.Unmarshal(rivalryContent, &rivalryPlaylist); err != nil {
|
||
t.Fatalf("Failed to parse rivalry-classics.json: %v", err)
|
||
}
|
||
if len(rivalryPlaylist.Matches) < 3 {
|
||
t.Errorf("rivalry-classics should have 3 matches for bot1:bot2 (count=3), got %d", len(rivalryPlaylist.Matches))
|
||
}
|
||
}
|
||
|
||
func TestGenerateMapsIndex(t *testing.T) {
|
||
wallsJSON := `[{"row":10,"col":10},{"row":10,"col":11}]`
|
||
coresJSON := `[{"position":{"row":5,"col":5},"owner":0},{"position":{"row":55,"col":55},"owner":1}]`
|
||
energyJSON := `[{"row":20,"col":25}]`
|
||
mapJSON := []byte(`{"walls":` + wallsJSON + `,"cores":` + coresJSON + `,"energy_nodes":` + energyJSON + `}`)
|
||
|
||
createdAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
data := &IndexData{
|
||
GeneratedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
|
||
Maps: []MapData{
|
||
{
|
||
MapID: "map_abc123",
|
||
PlayerCount: 2,
|
||
Status: "active",
|
||
Engagement: 0.85,
|
||
WallDensity: 0.15,
|
||
EnergyCount: 1,
|
||
GridWidth: 60,
|
||
GridHeight: 60,
|
||
CreatedAt: createdAt,
|
||
RawJSON: mapJSON,
|
||
},
|
||
{
|
||
MapID: "map_def456",
|
||
PlayerCount: 4,
|
||
Status: "probation",
|
||
Engagement: 0.40,
|
||
WallDensity: 0.20,
|
||
EnergyCount: 0,
|
||
GridWidth: 80,
|
||
GridHeight: 80,
|
||
CreatedAt: createdAt,
|
||
RawJSON: json.RawMessage(`{}`),
|
||
},
|
||
},
|
||
}
|
||
|
||
tmpDir := t.TempDir()
|
||
|
||
if err := generateMapsIndex(data, tmpDir); err != nil {
|
||
t.Fatalf("generateMapsIndex failed: %v", err)
|
||
}
|
||
|
||
// Verify maps/index.json
|
||
indexContent, err := os.ReadFile(filepath.Join(tmpDir, "maps", "index.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read maps/index.json: %v", err)
|
||
}
|
||
var index MapIndexFile
|
||
if err := json.Unmarshal(indexContent, &index); err != nil {
|
||
t.Fatalf("Failed to parse maps/index.json: %v", err)
|
||
}
|
||
if len(index.Maps) != 2 {
|
||
t.Errorf("Expected 2 maps in index, got %d", len(index.Maps))
|
||
}
|
||
if index.UpdatedAt == "" {
|
||
t.Error("UpdatedAt should not be empty")
|
||
}
|
||
if index.Maps[0].MapID != "map_abc123" {
|
||
t.Errorf("First map_id: got %q, want %q", index.Maps[0].MapID, "map_abc123")
|
||
}
|
||
if len(index.ByPlayerCount["2"]) != 1 {
|
||
t.Errorf("by_player_count[\"2\"]: expected 1, got %d", len(index.ByPlayerCount["2"]))
|
||
}
|
||
if len(index.ByPlayerCount["4"]) != 1 {
|
||
t.Errorf("by_player_count[\"4\"]: expected 1, got %d", len(index.ByPlayerCount["4"]))
|
||
}
|
||
|
||
// Verify maps/map_abc123.json has geometry
|
||
detailContent, err := os.ReadFile(filepath.Join(tmpDir, "maps", "map_abc123.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read maps/map_abc123.json: %v", err)
|
||
}
|
||
var detail MapDetail
|
||
if err := json.Unmarshal(detailContent, &detail); err != nil {
|
||
t.Fatalf("Failed to parse maps/map_abc123.json: %v", err)
|
||
}
|
||
if detail.MapID != "map_abc123" {
|
||
t.Errorf("map_id: got %q, want %q", detail.MapID, "map_abc123")
|
||
}
|
||
if len(detail.Walls) != 2 {
|
||
t.Errorf("Expected 2 walls, got %d", len(detail.Walls))
|
||
}
|
||
if len(detail.Cores) != 2 {
|
||
t.Errorf("Expected 2 cores, got %d", len(detail.Cores))
|
||
}
|
||
if len(detail.EnergyNodes) != 1 {
|
||
t.Errorf("Expected 1 energy node, got %d", len(detail.EnergyNodes))
|
||
}
|
||
if detail.Walls[0].Row != 10 || detail.Walls[0].Col != 10 {
|
||
t.Errorf("First wall: got {%d,%d}, want {10,10}", detail.Walls[0].Row, detail.Walls[0].Col)
|
||
}
|
||
if detail.Cores[0].Owner != 0 || detail.Cores[0].Position.Row != 5 {
|
||
t.Errorf("First core: got owner=%d row=%d, want owner=0 row=5", detail.Cores[0].Owner, detail.Cores[0].Position.Row)
|
||
}
|
||
|
||
// Verify maps/map_def456.json has empty slices (no geometry in RawJSON)
|
||
detail2Content, err := os.ReadFile(filepath.Join(tmpDir, "maps", "map_def456.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read maps/map_def456.json: %v", err)
|
||
}
|
||
var detail2 MapDetail
|
||
if err := json.Unmarshal(detail2Content, &detail2); err != nil {
|
||
t.Fatalf("Failed to parse maps/map_def456.json: %v", err)
|
||
}
|
||
if len(detail2.Walls) != 0 {
|
||
t.Errorf("Expected 0 walls for map_def456, got %d", len(detail2.Walls))
|
||
}
|
||
if len(detail2.Cores) != 0 {
|
||
t.Errorf("Expected 0 cores for map_def456, got %d", len(detail2.Cores))
|
||
}
|
||
}
|
||
|
||
func TestGenerateMetaIndex(t *testing.T) {
|
||
tmpDir := t.TempDir()
|
||
|
||
if err := generateMetaIndex(tmpDir); err != nil {
|
||
t.Fatalf("generateMetaIndex failed: %v", err)
|
||
}
|
||
|
||
content, err := os.ReadFile(filepath.Join(tmpDir, "data", "meta", "index.json"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to read meta/index.json: %v", err)
|
||
}
|
||
|
||
var index MetaIndex
|
||
if err := json.Unmarshal(content, &index); err != nil {
|
||
t.Fatalf("Failed to parse meta/index.json: %v", err)
|
||
}
|
||
|
||
if len(index.Files) != 2 {
|
||
t.Errorf("Expected 2 files in meta index, got %d", len(index.Files))
|
||
}
|
||
if index.UpdatedAt == "" {
|
||
t.Error("UpdatedAt should not be empty")
|
||
}
|
||
|
||
// Verify archetypes.json entry
|
||
foundArchetypes := false
|
||
foundRivalries := false
|
||
for _, f := range index.Files {
|
||
if f.Name == "archetypes.json" {
|
||
foundArchetypes = true
|
||
if f.Description == "" {
|
||
t.Error("archetypes.json should have a description")
|
||
}
|
||
}
|
||
if f.Name == "rivalries.json" {
|
||
foundRivalries = true
|
||
if f.Description == "" {
|
||
t.Error("rivalries.json should have a description")
|
||
}
|
||
}
|
||
}
|
||
if !foundArchetypes {
|
||
t.Error("Expected archetypes.json entry in meta index")
|
||
}
|
||
if !foundRivalries {
|
||
t.Error("Expected rivalries.json entry in meta index")
|
||
}
|
||
}
|