The plan previously specified ZoneShrinkStep=2, but the engine uses
ZoneShrinkStep=1 (per commit 0577fcd). The value of 1 was found to
improve combat density because it matches bot movement speed (1 tile/turn).
A value of 2 caused the zone to shrink faster than bots could move,
killing them before combat could occur.
Updated zone parameters table and rationale in §3.7.1.
Closes: bf-3mrj
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
430 lines
11 KiB
Go
430 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// MockS3Client implements S3ClientInterface for testing
|
|
type MockS3Client struct {
|
|
Objects map[string]MockObject
|
|
UploadCalls []UploadCall
|
|
DeleteCalls []string
|
|
CopyCalls []CopyCall
|
|
ShouldFailOn string // Set to make specific operations fail
|
|
}
|
|
|
|
type MockObject struct {
|
|
Content []byte
|
|
LastModified time.Time
|
|
}
|
|
|
|
type UploadCall struct {
|
|
Key string
|
|
Data []byte
|
|
}
|
|
|
|
type CopyCall struct {
|
|
SourceKey string
|
|
DestKey string
|
|
}
|
|
|
|
func NewMockS3Client() *MockS3Client {
|
|
return &MockS3Client{
|
|
Objects: make(map[string]MockObject),
|
|
UploadCalls: []UploadCall{},
|
|
DeleteCalls: []string{},
|
|
CopyCalls: []CopyCall{},
|
|
}
|
|
}
|
|
|
|
func (m *MockS3Client) listObjects(ctx context.Context, prefix string) ([]R2Object, error) {
|
|
if m.ShouldFailOn == "list" {
|
|
return nil, context.DeadlineExceeded
|
|
}
|
|
|
|
var objects []R2Object
|
|
for key, obj := range m.Objects {
|
|
if prefix == "" || len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
|
objects = append(objects, R2Object{
|
|
Key: key,
|
|
Size: int64(len(obj.Content)),
|
|
LastModified: obj.LastModified,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(objects, func(i, j int) bool {
|
|
return objects[i].LastModified.Before(objects[j].LastModified)
|
|
})
|
|
return objects, nil
|
|
}
|
|
|
|
func (m *MockS3Client) deleteObject(ctx context.Context, key string) error {
|
|
if m.ShouldFailOn == "delete" {
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
m.DeleteCalls = append(m.DeleteCalls, key)
|
|
delete(m.Objects, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *MockS3Client) objectExists(ctx context.Context, key string) (bool, error) {
|
|
if m.ShouldFailOn == "exists" {
|
|
return false, context.DeadlineExceeded
|
|
}
|
|
|
|
_, exists := m.Objects[key]
|
|
return exists, nil
|
|
}
|
|
|
|
func (m *MockS3Client) uploadFile(ctx context.Context, key string, body io.Reader, contentType string) error {
|
|
if m.ShouldFailOn == "upload" {
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
data, err := io.ReadAll(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
m.UploadCalls = append(m.UploadCalls, UploadCall{Key: key, Data: data})
|
|
m.Objects[key] = MockObject{
|
|
Content: data,
|
|
LastModified: time.Now(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockS3Client) copyObject(ctx context.Context, sourceBucket, sourceKey, destKey string) error {
|
|
if m.ShouldFailOn == "copy" {
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
m.CopyCalls = append(m.CopyCalls, CopyCall{SourceKey: sourceKey, DestKey: destKey})
|
|
|
|
// Simulate copy by reading from source and writing to dest
|
|
if obj, exists := m.Objects[sourceKey]; exists {
|
|
m.Objects[destKey] = MockObject{
|
|
Content: obj.Content,
|
|
LastModified: time.Now(),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockS3Client) downloadObject(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
if m.ShouldFailOn == "download" {
|
|
return nil, context.DeadlineExceeded
|
|
}
|
|
|
|
obj, exists := m.Objects[key]
|
|
if !exists {
|
|
return nil, context.DeadlineExceeded
|
|
}
|
|
|
|
return io.NopCloser(bytes.NewReader(obj.Content)), nil
|
|
}
|
|
|
|
// Test GetS3ContentType
|
|
func TestGetS3ContentType(t *testing.T) {
|
|
tests := []struct {
|
|
filename string
|
|
expected string
|
|
}{
|
|
{"replay.json.gz", "application/gzip"},
|
|
{"data.json", "application/json"},
|
|
{"card.png", "image/png"},
|
|
{"file.unknown", "application/octet-stream"},
|
|
{"", "application/octet-stream"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := getS3ContentType(tt.filename)
|
|
if result != tt.expected {
|
|
t.Errorf("getS3ContentType(%q) = %q, want %q", tt.filename, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test ExtractMatchIDFromKey
|
|
func TestExtractMatchIDFromKey(t *testing.T) {
|
|
tests := []struct {
|
|
key string
|
|
expected string
|
|
}{
|
|
{"replays/abc123.json.gz", "abc123"},
|
|
{"replays/match-456-def.json.gz", "match-456-def"},
|
|
{"replays/test.json.gz", "test"},
|
|
{"replays/", ""},
|
|
{"invalid", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := extractMatchIDFromKey(tt.key)
|
|
if result != tt.expected {
|
|
t.Errorf("extractMatchIDFromKey(%q) = %q, want %q", tt.key, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test MockS3Client operations
|
|
func TestMockS3ClientUpload(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := NewMockS3Client()
|
|
|
|
content := []byte("test content")
|
|
err := client.uploadFile(ctx, "test.txt", bytes.NewReader(content), "text/plain")
|
|
if err != nil {
|
|
t.Fatalf("uploadFile failed: %v", err)
|
|
}
|
|
|
|
if len(client.UploadCalls) != 1 {
|
|
t.Errorf("expected 1 upload call, got %d", len(client.UploadCalls))
|
|
}
|
|
|
|
exists, err := client.objectExists(ctx, "test.txt")
|
|
if err != nil {
|
|
t.Fatalf("objectExists failed: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("expected object to exist")
|
|
}
|
|
}
|
|
|
|
func TestMockS3ClientDelete(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := NewMockS3Client()
|
|
|
|
// Add an object
|
|
client.Objects["test.txt"] = MockObject{
|
|
Content: []byte("test"),
|
|
LastModified: time.Now(),
|
|
}
|
|
|
|
// Delete it
|
|
err := client.deleteObject(ctx, "test.txt")
|
|
if err != nil {
|
|
t.Fatalf("deleteObject failed: %v", err)
|
|
}
|
|
|
|
// Verify it's gone
|
|
exists, _ := client.objectExists(ctx, "test.txt")
|
|
if exists {
|
|
t.Error("expected object to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestMockS3ClientList(t *testing.T) {
|
|
ctx := context.Background()
|
|
client := NewMockS3Client()
|
|
|
|
// Add some objects
|
|
now := time.Now()
|
|
client.Objects["replays/match1.json.gz"] = MockObject{
|
|
Content: []byte("match1"),
|
|
LastModified: now.Add(-2 * time.Hour),
|
|
}
|
|
client.Objects["replays/match2.json.gz"] = MockObject{
|
|
Content: []byte("match2"),
|
|
LastModified: now.Add(-1 * time.Hour),
|
|
}
|
|
client.Objects["cards/bot1.png"] = MockObject{
|
|
Content: []byte("card1"),
|
|
LastModified: now,
|
|
}
|
|
|
|
// List replay objects
|
|
objects, err := client.listObjects(ctx, "replays/")
|
|
if err != nil {
|
|
t.Fatalf("listObjects failed: %v", err)
|
|
}
|
|
|
|
if len(objects) != 2 {
|
|
t.Errorf("expected 2 objects, got %d", len(objects))
|
|
}
|
|
|
|
// Verify ordering (oldest first)
|
|
if len(objects) >= 2 && objects[0].LastModified.After(objects[1].LastModified) {
|
|
t.Error("expected objects sorted oldest first")
|
|
}
|
|
}
|
|
|
|
// Test bundleWarmReplays with mock B2 client
|
|
func TestBundleWarmReplays(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockClient := NewMockS3Client()
|
|
|
|
// Add mock replays to B2
|
|
mockClient.Objects["replays/match1.json.gz"] = MockObject{
|
|
Content: []byte(`{"turn": 1, "events": []}`),
|
|
LastModified: time.Now(),
|
|
}
|
|
mockClient.Objects["replays/match2.json.gz"] = MockObject{
|
|
Content: []byte(`{"turn": 1, "events": []}`),
|
|
LastModified: time.Now(),
|
|
}
|
|
|
|
// Create temporary output directory
|
|
tmpDir, err := os.MkdirTemp("", "bundle-test-")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cfg := &Config{OutputDir: tmpDir}
|
|
matchIDs := []string{"match1", "match2"}
|
|
|
|
// Bundle replays
|
|
err = bundleWarmReplays(ctx, cfg, mockClient, matchIDs)
|
|
if err != nil {
|
|
t.Fatalf("bundleWarmReplays failed: %v", err)
|
|
}
|
|
|
|
// Verify files were created
|
|
for _, matchID := range matchIDs {
|
|
expectedPath := filepath.Join(tmpDir, "data", "replays", matchID+".json.gz")
|
|
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
|
t.Errorf("expected replay file not created: %s", expectedPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test bundleWarmThumbnails with mock B2 client
|
|
func TestBundleWarmThumbnails(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockClient := NewMockS3Client()
|
|
|
|
// Add mock thumbnails to B2
|
|
mockClient.Objects["thumbnails/match1.png"] = MockObject{
|
|
Content: []byte("fake-png-data"),
|
|
LastModified: time.Now(),
|
|
}
|
|
|
|
// Create temporary output directory
|
|
tmpDir, err := os.MkdirTemp("", "bundle-test-")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cfg := &Config{OutputDir: tmpDir}
|
|
matchIDs := []string{"match1"}
|
|
|
|
// Bundle thumbnails
|
|
err = bundleWarmThumbnails(ctx, cfg, mockClient, matchIDs)
|
|
if err != nil {
|
|
t.Fatalf("bundleWarmThumbnails failed: %v", err)
|
|
}
|
|
|
|
// Verify file was created
|
|
expectedPath := filepath.Join(tmpDir, "data", "thumbnails", "match1.png")
|
|
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
|
t.Errorf("expected thumbnail file not created: %s", expectedPath)
|
|
}
|
|
}
|
|
|
|
// Test bundleWarmCards with mock B2 client
|
|
func TestBundleWarmCards(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockClient := NewMockS3Client()
|
|
|
|
// Add mock cards to B2
|
|
mockClient.Objects["cards/bot1.png"] = MockObject{
|
|
Content: []byte("fake-png-data"),
|
|
LastModified: time.Now(),
|
|
}
|
|
|
|
// Create temporary output directory
|
|
tmpDir, err := os.MkdirTemp("", "bundle-test-")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cfg := &Config{OutputDir: tmpDir}
|
|
botIDs := []string{"bot1"}
|
|
|
|
// Bundle cards
|
|
err = bundleWarmCards(ctx, cfg, mockClient, botIDs)
|
|
if err != nil {
|
|
t.Fatalf("bundleWarmCards failed: %v", err)
|
|
}
|
|
|
|
// Verify file was created
|
|
expectedPath := filepath.Join(tmpDir, "data", "cards", "bot1.png")
|
|
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
|
t.Errorf("expected card file not created: %s", expectedPath)
|
|
}
|
|
}
|
|
|
|
// Test bundleEvolutionLive with mock B2 client
|
|
func TestBundleEvolutionLive(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockClient := NewMockS3Client()
|
|
|
|
// Add mock live.json to B2
|
|
liveData := `{"updated_at": "2026-05-26T00:00:00Z", "lineage": []}`
|
|
mockClient.Objects["evolution/live.json"] = MockObject{
|
|
Content: []byte(liveData),
|
|
LastModified: time.Now(),
|
|
}
|
|
|
|
// Create temporary output directory
|
|
tmpDir, err := os.MkdirTemp("", "bundle-test-")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cfg := &Config{OutputDir: tmpDir}
|
|
|
|
// Bundle evolution live.json
|
|
err = bundleEvolutionLive(ctx, cfg, mockClient)
|
|
if err != nil {
|
|
t.Fatalf("bundleEvolutionLive failed: %v", err)
|
|
}
|
|
|
|
// Verify file was created
|
|
expectedPath := filepath.Join(tmpDir, "data", "evolution", "live.json")
|
|
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
|
t.Errorf("expected live.json file not created: %s", expectedPath)
|
|
}
|
|
|
|
// Verify content
|
|
content, err := os.ReadFile(expectedPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read live.json: %v", err)
|
|
}
|
|
if string(content) != liveData {
|
|
t.Errorf("live.json content mismatch, got %s", string(content))
|
|
}
|
|
}
|
|
|
|
// Test bundleWarmReplays with missing B2 objects (graceful handling)
|
|
func TestBundleWarmReplaysMissingObjects(t *testing.T) {
|
|
ctx := context.Background()
|
|
mockClient := NewMockS3Client() // Empty - no objects
|
|
|
|
// Create temporary output directory
|
|
tmpDir, err := os.MkdirTemp("", "bundle-test-")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
cfg := &Config{OutputDir: tmpDir}
|
|
matchIDs := []string{"nonexistent"}
|
|
|
|
// Should not error when objects are missing
|
|
err = bundleWarmReplays(ctx, cfg, mockClient, matchIDs)
|
|
if err != nil {
|
|
t.Errorf("bundleWarmReplays should not error on missing objects, got: %v", err)
|
|
}
|
|
}
|