Files
gastown/internal/polecat/namepool_test.go
Advaya Krishna 3afd1a1dcd fix(polecat): exclude reserved infrastructure agent names from allocator (#837)
The polecat name allocator was assigning reserved infrastructure agent
names like 'witness' to polecats. Added ReservedInfraAgentNames map
containing witness, mayor, deacon, and refinery. Modified getNames()
to filter these from all themes and custom name lists.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:30:53 -08:00

528 lines
13 KiB
Go

package polecat
import (
"os"
"path/filepath"
"testing"
)
func TestNamePool_Allocate(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// First allocation should be first themed name (furiosa)
name, err := pool.Allocate()
if err != nil {
t.Fatalf("Allocate error: %v", err)
}
if name != "furiosa" {
t.Errorf("expected furiosa, got %s", name)
}
// Second allocation should be nux
name, err = pool.Allocate()
if err != nil {
t.Fatalf("Allocate error: %v", err)
}
if name != "nux" {
t.Errorf("expected nux, got %s", name)
}
}
func TestNamePool_Release(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// Allocate first two
name1, _ := pool.Allocate()
name2, _ := pool.Allocate()
if name1 != "furiosa" || name2 != "nux" {
t.Fatalf("unexpected allocations: %s, %s", name1, name2)
}
// Release first one
pool.Release("furiosa")
// Next allocation should reuse furiosa
name, _ := pool.Allocate()
if name != "furiosa" {
t.Errorf("expected furiosa to be reused, got %s", name)
}
}
func TestNamePool_PrefersOrder(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// Allocate first 5
for i := 0; i < 5; i++ {
pool.Allocate()
}
// Release slit and furiosa
pool.Release("slit")
pool.Release("furiosa")
// Next allocation should be furiosa (first in theme order)
name, _ := pool.Allocate()
if name != "furiosa" {
t.Errorf("expected furiosa (first in order), got %s", name)
}
// Next should be slit
name, _ = pool.Allocate()
if name != "slit" {
t.Errorf("expected slit, got %s", name)
}
}
func TestNamePool_Overflow(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "gastown", "mad-max", nil, 5)
// Exhaust the small pool
for i := 0; i < 5; i++ {
pool.Allocate()
}
// Next allocation should be overflow format
name, err := pool.Allocate()
if err != nil {
t.Fatalf("Allocate error: %v", err)
}
expected := "gastown-6"
if name != expected {
t.Errorf("expected overflow name %s, got %s", expected, name)
}
// Next overflow
name, _ = pool.Allocate()
if name != "gastown-7" {
t.Errorf("expected gastown-7, got %s", name)
}
}
func TestNamePool_OverflowNotReusable(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "gastown", "mad-max", nil, 3)
// Exhaust the pool
for i := 0; i < 3; i++ {
pool.Allocate()
}
// Get overflow name
overflow1, _ := pool.Allocate()
if overflow1 != "gastown-4" {
t.Fatalf("expected gastown-4, got %s", overflow1)
}
// Release it - should not be reused
pool.Release(overflow1)
// Next allocation should be gastown-5, not gastown-4
name, _ := pool.Allocate()
if name != "gastown-5" {
t.Errorf("expected gastown-5 (overflow increments), got %s", name)
}
}
func TestNamePool_SaveLoad(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Use config to set MaxSize from the start (affects OverflowNext initialization)
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, 3)
// Exhaust the pool to trigger overflow, which increments OverflowNext
pool.Allocate() // furiosa
pool.Allocate() // nux
pool.Allocate() // slit
overflowName, _ := pool.Allocate() // testrig-4 (overflow)
if overflowName != "testrig-4" {
t.Errorf("expected testrig-4 for first overflow, got %s", overflowName)
}
// Save state
if err := pool.Save(); err != nil {
t.Fatalf("Save error: %v", err)
}
// Create new pool and load
pool2 := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, 3)
if err := pool2.Load(); err != nil {
t.Fatalf("Load error: %v", err)
}
// ZFC: InUse is NOT persisted - it's transient state derived from filesystem.
// After Load(), InUse should be empty (0 active).
if pool2.ActiveCount() != 0 {
t.Errorf("expected 0 active after Load (ZFC: InUse is transient), got %d", pool2.ActiveCount())
}
// OverflowNext SHOULD persist - it's the one piece of state that can't be derived.
// Next overflow should be testrig-5, not testrig-4.
pool2.Allocate() // furiosa (InUse empty, so starts from beginning)
pool2.Allocate() // nux
pool2.Allocate() // slit
overflowName2, _ := pool2.Allocate() // Should be testrig-5
if overflowName2 != "testrig-5" {
t.Errorf("expected testrig-5 (OverflowNext persisted), got %s", overflowName2)
}
}
func TestNamePool_Reconcile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// Simulate existing polecats from filesystem
existing := []string{"slit", "valkyrie", "some-other-name"}
pool.Reconcile(existing)
if pool.ActiveCount() != 2 {
t.Errorf("expected 2 active after reconcile, got %d", pool.ActiveCount())
}
// Should allocate furiosa first (not slit or valkyrie)
name, _ := pool.Allocate()
if name != "furiosa" {
t.Errorf("expected furiosa, got %s", name)
}
}
func TestNamePool_IsPoolName(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
tests := []struct {
name string
expected bool
}{
{"furiosa", true},
{"nux", true},
{"max", true},
{"gastown-51", false}, // overflow format
{"random-name", false},
{"polecat-01", false}, // old format
}
for _, tc := range tests {
result := pool.IsPoolName(tc.name)
if result != tc.expected {
t.Errorf("IsPoolName(%q) = %v, expected %v", tc.name, result, tc.expected)
}
}
}
func TestNamePool_ActiveNames(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
pool.Allocate() // furiosa
pool.Allocate() // nux
pool.Allocate() // slit
pool.Release("nux")
names := pool.ActiveNames()
if len(names) != 2 {
t.Errorf("expected 2 active names, got %d", len(names))
}
// Names are sorted
if names[0] != "furiosa" || names[1] != "slit" {
t.Errorf("expected [furiosa, slit], got %v", names)
}
}
func TestNamePool_MarkInUse(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// Mark some slots as in use
pool.MarkInUse("dementus")
pool.MarkInUse("valkyrie")
// Allocate should skip those
name, _ := pool.Allocate()
if name != "furiosa" {
t.Errorf("expected furiosa, got %s", name)
}
// Verify count
if pool.ActiveCount() != 3 { // furiosa, dementus, valkyrie
t.Errorf("expected 3 active, got %d", pool.ActiveCount())
}
}
func TestNamePool_StateFilePath(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePool(tmpDir, "testrig")
pool.Allocate()
if err := pool.Save(); err != nil {
t.Fatalf("Save error: %v", err)
}
// Verify file was created in expected location
expectedPath := filepath.Join(tmpDir, ".runtime", "namepool-state.json")
if _, err := os.Stat(expectedPath); err != nil {
t.Errorf("state file not found at expected path: %v", err)
}
}
func TestNamePool_Themes(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Test minerals theme
pool := NewNamePoolWithConfig(tmpDir, "testrig", "minerals", nil, 50)
name, err := pool.Allocate()
if err != nil {
t.Fatalf("Allocate error: %v", err)
}
if name != "obsidian" {
t.Errorf("expected obsidian (first mineral), got %s", name)
}
// Test theme switching
if err := pool.SetTheme("wasteland"); err != nil {
t.Fatalf("SetTheme error: %v", err)
}
// obsidian should be released (not in wasteland theme)
name, _ = pool.Allocate()
if name != "rust" {
t.Errorf("expected rust (first wasteland name), got %s", name)
}
}
func TestNamePool_CustomNames(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
custom := []string{"alpha", "beta", "gamma", "delta"}
pool := NewNamePoolWithConfig(tmpDir, "testrig", "", custom, 4)
name, _ := pool.Allocate()
if name != "alpha" {
t.Errorf("expected alpha, got %s", name)
}
name, _ = pool.Allocate()
if name != "beta" {
t.Errorf("expected beta, got %s", name)
}
}
func TestListThemes(t *testing.T) {
themes := ListThemes()
if len(themes) != 3 {
t.Errorf("expected 3 themes, got %d", len(themes))
}
// Check that all expected themes are present
expected := map[string]bool{"mad-max": true, "minerals": true, "wasteland": true}
for _, theme := range themes {
if !expected[theme] {
t.Errorf("unexpected theme: %s", theme)
}
}
}
func TestGetThemeNames(t *testing.T) {
names, err := GetThemeNames("mad-max")
if err != nil {
t.Fatalf("GetThemeNames error: %v", err)
}
if len(names) != 50 {
t.Errorf("expected 50 mad-max names, got %d", len(names))
}
if names[0] != "furiosa" {
t.Errorf("expected first name to be furiosa, got %s", names[0])
}
// Test invalid theme
_, err = GetThemeNames("invalid-theme")
if err == nil {
t.Error("expected error for invalid theme")
}
}
func TestNamePool_Reset(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
pool := NewNamePoolWithConfig(tmpDir, "testrig", "mad-max", nil, DefaultPoolSize)
// Allocate several names
for i := 0; i < 10; i++ {
pool.Allocate()
}
if pool.ActiveCount() != 10 {
t.Errorf("expected 10 active, got %d", pool.ActiveCount())
}
// Reset
pool.Reset()
if pool.ActiveCount() != 0 {
t.Errorf("expected 0 active after reset, got %d", pool.ActiveCount())
}
// Should allocate furiosa again
name, _ := pool.Allocate()
if name != "furiosa" {
t.Errorf("expected furiosa after reset, got %s", name)
}
}
func TestThemeForRig(t *testing.T) {
// Different rigs should get different themes (with high probability)
themes := make(map[string]bool)
for _, rigName := range []string{"gastown", "beads", "myproject", "webapp"} {
themes[ThemeForRig(rigName)] = true
}
// Should have at least 2 different themes across 4 rigs
if len(themes) < 2 {
t.Errorf("expected variety in themes, got only %d unique theme(s)", len(themes))
}
}
func TestThemeForRigDeterministic(t *testing.T) {
// Same rig name should always get same theme
theme1 := ThemeForRig("myrig")
theme2 := ThemeForRig("myrig")
if theme1 != theme2 {
t.Errorf("theme not deterministic: got %q and %q", theme1, theme2)
}
}
func TestNamePool_ReservedNamesExcluded(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Test all themes to ensure reserved names are excluded
for themeName := range BuiltinThemes {
pool := NewNamePoolWithConfig(tmpDir, "testrig", themeName, nil, 100)
// Allocate all available names (up to 100)
allocated := make(map[string]bool)
for i := 0; i < 100; i++ {
name, err := pool.Allocate()
if err != nil {
t.Fatalf("Allocate error: %v", err)
}
allocated[name] = true
}
// Verify no reserved names were allocated
for reserved := range ReservedInfraAgentNames {
if allocated[reserved] {
t.Errorf("theme %q allocated reserved name %q", themeName, reserved)
}
}
pool.Reset()
}
}
func TestNamePool_ReservedNamesInCustomNames(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "namepool-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
// Custom names that include reserved names should have them filtered out
custom := []string{"alpha", "witness", "beta", "mayor", "gamma"}
pool := NewNamePoolWithConfig(tmpDir, "testrig", "", custom, 10)
// Allocate all names
allocated := make(map[string]bool)
for i := 0; i < 5; i++ {
name, _ := pool.Allocate()
allocated[name] = true
}
// Should only get alpha, beta, gamma (3 non-reserved names)
// Then overflow names for the remaining allocations
if allocated["witness"] {
t.Error("allocated reserved name 'witness' from custom names")
}
if allocated["mayor"] {
t.Error("allocated reserved name 'mayor' from custom names")
}
if !allocated["alpha"] || !allocated["beta"] || !allocated["gamma"] {
t.Errorf("expected alpha, beta, gamma to be allocated, got %v", allocated)
}
}