Each rig now gets a deterministic theme based on its name instead of always defaulting to mad-max. Uses a prime multiplier hash (×31) for good distribution across themes. Same rig name always gets the same theme. Users can still override with `gt namepool set`. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
11 KiB
Go
465 lines
11 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)
|
|
}
|
|
}
|