Files
gastown/internal/config/roles_test.go
gastown/crew/max 544cacf36d feat(roles): add config-based role definition system (Phase 1)
Replace role beads with embedded TOML config files for role definitions.
This is Phase 1 of gt-y1uvb - adds the config infrastructure without
yet switching the daemon to use it.

New files:
- internal/config/roles.go: RoleDefinition types, LoadRoleDefinition()
  with layered override resolution (builtin → town → rig)
- internal/config/roles/*.toml: 7 embedded role definitions
- internal/config/roles_test.go: unit tests

New command:
- gt role def <role>: displays effective role configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 12:57:46 -08:00

273 lines
6.4 KiB
Go

package config
import (
"strings"
"testing"
"time"
)
func TestLoadBuiltinRoleDefinition(t *testing.T) {
tests := []struct {
name string
role string
wantScope string
wantPattern string
wantPreSync bool
}{
{
name: "mayor",
role: "mayor",
wantScope: "town",
wantPattern: "hq-mayor",
wantPreSync: false,
},
{
name: "deacon",
role: "deacon",
wantScope: "town",
wantPattern: "hq-deacon",
wantPreSync: false,
},
{
name: "witness",
role: "witness",
wantScope: "rig",
wantPattern: "gt-{rig}-witness",
wantPreSync: false,
},
{
name: "refinery",
role: "refinery",
wantScope: "rig",
wantPattern: "gt-{rig}-refinery",
wantPreSync: true,
},
{
name: "polecat",
role: "polecat",
wantScope: "rig",
wantPattern: "gt-{rig}-{name}",
wantPreSync: true,
},
{
name: "crew",
role: "crew",
wantScope: "rig",
wantPattern: "gt-{rig}-crew-{name}",
wantPreSync: true,
},
{
name: "dog",
role: "dog",
wantScope: "town",
wantPattern: "gt-dog-{name}",
wantPreSync: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
def, err := loadBuiltinRoleDefinition(tt.role)
if err != nil {
t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", tt.role, err)
}
if def.Role != tt.role {
t.Errorf("Role = %q, want %q", def.Role, tt.role)
}
if def.Scope != tt.wantScope {
t.Errorf("Scope = %q, want %q", def.Scope, tt.wantScope)
}
if def.Session.Pattern != tt.wantPattern {
t.Errorf("Session.Pattern = %q, want %q", def.Session.Pattern, tt.wantPattern)
}
if def.Session.NeedsPreSync != tt.wantPreSync {
t.Errorf("Session.NeedsPreSync = %v, want %v", def.Session.NeedsPreSync, tt.wantPreSync)
}
// Verify health config has reasonable defaults
if def.Health.PingTimeout.Duration == 0 {
t.Error("Health.PingTimeout should not be zero")
}
if def.Health.ConsecutiveFailures == 0 {
t.Error("Health.ConsecutiveFailures should not be zero")
}
})
}
}
func TestLoadBuiltinRoleDefinition_UnknownRole(t *testing.T) {
_, err := loadBuiltinRoleDefinition("nonexistent")
if err == nil {
t.Error("expected error for unknown role, got nil")
}
}
func TestLoadRoleDefinition_UnknownRole(t *testing.T) {
_, err := LoadRoleDefinition("/tmp/town", "", "nonexistent")
if err == nil {
t.Error("expected error for unknown role, got nil")
}
// Should have a clear error message, not a cryptic embed error
if !strings.Contains(err.Error(), "unknown role") {
t.Errorf("error should mention 'unknown role', got: %v", err)
}
}
func TestAllRoles(t *testing.T) {
roles := AllRoles()
if len(roles) != 7 {
t.Errorf("AllRoles() returned %d roles, want 7", len(roles))
}
expected := map[string]bool{
"mayor": true,
"deacon": true,
"dog": true,
"witness": true,
"refinery": true,
"polecat": true,
"crew": true,
}
for _, r := range roles {
if !expected[r] {
t.Errorf("unexpected role %q in AllRoles()", r)
}
}
}
func TestTownRoles(t *testing.T) {
roles := TownRoles()
if len(roles) != 3 {
t.Errorf("TownRoles() returned %d roles, want 3", len(roles))
}
for _, r := range roles {
def, err := loadBuiltinRoleDefinition(r)
if err != nil {
t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", r, err)
}
if def.Scope != "town" {
t.Errorf("role %s has scope %q, expected 'town'", r, def.Scope)
}
}
}
func TestRigRoles(t *testing.T) {
roles := RigRoles()
if len(roles) != 4 {
t.Errorf("RigRoles() returned %d roles, want 4", len(roles))
}
for _, r := range roles {
def, err := loadBuiltinRoleDefinition(r)
if err != nil {
t.Fatalf("loadBuiltinRoleDefinition(%s) error: %v", r, err)
}
if def.Scope != "rig" {
t.Errorf("role %s has scope %q, expected 'rig'", r, def.Scope)
}
}
}
func TestExpandPattern(t *testing.T) {
tests := []struct {
pattern string
town string
rig string
name string
role string
expected string
}{
{
pattern: "{town}",
town: "/home/user/gt",
expected: "/home/user/gt",
},
{
pattern: "gt-{rig}-witness",
rig: "gastown",
expected: "gt-gastown-witness",
},
{
pattern: "{town}/{rig}/crew/{name}",
town: "/home/user/gt",
rig: "gastown",
name: "max",
expected: "/home/user/gt/gastown/crew/max",
},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
got := ExpandPattern(tt.pattern, tt.town, tt.rig, tt.name, tt.role)
if got != tt.expected {
t.Errorf("ExpandPattern() = %q, want %q", got, tt.expected)
}
})
}
}
func TestDuration_UnmarshalText(t *testing.T) {
tests := []struct {
input string
expected time.Duration
}{
{"30s", 30 * time.Second},
{"5m", 5 * time.Minute},
{"1h", time.Hour},
{"1h30m", time.Hour + 30*time.Minute},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
var d Duration
if err := d.UnmarshalText([]byte(tt.input)); err != nil {
t.Fatalf("UnmarshalText() error: %v", err)
}
if d.Duration != tt.expected {
t.Errorf("Duration = %v, want %v", d.Duration, tt.expected)
}
})
}
}
func TestToLegacyRoleConfig(t *testing.T) {
def := &RoleDefinition{
Role: "witness",
Scope: "rig",
Session: RoleSessionConfig{
Pattern: "gt-{rig}-witness",
WorkDir: "{town}/{rig}/witness",
NeedsPreSync: false,
StartCommand: "exec claude",
},
Env: map[string]string{"GT_ROLE": "witness"},
Health: RoleHealthConfig{
PingTimeout: Duration{30 * time.Second},
ConsecutiveFailures: 3,
KillCooldown: Duration{5 * time.Minute},
StuckThreshold: Duration{time.Hour},
},
}
legacy := def.ToLegacyRoleConfig()
if legacy.SessionPattern != "gt-{rig}-witness" {
t.Errorf("SessionPattern = %q, want %q", legacy.SessionPattern, "gt-{rig}-witness")
}
if legacy.WorkDirPattern != "{town}/{rig}/witness" {
t.Errorf("WorkDirPattern = %q, want %q", legacy.WorkDirPattern, "{town}/{rig}/witness")
}
if legacy.NeedsPreSync != false {
t.Errorf("NeedsPreSync = %v, want false", legacy.NeedsPreSync)
}
if legacy.PingTimeout != "30s" {
t.Errorf("PingTimeout = %q, want %q", legacy.PingTimeout, "30s")
}
if legacy.ConsecutiveFailures != 3 {
t.Errorf("ConsecutiveFailures = %d, want 3", legacy.ConsecutiveFailures)
}
}