test: Add test coverage for 16 files (40.3% → 45.5%) (#463)
* test: Add test coverage for 16 files (40.3% -> 45.5%) Add comprehensive tests for previously untested packages: - internal/agent/state_test.go - internal/cmd/errors_test.go - internal/crew/types_test.go - internal/doctor/errors_test.go - internal/dog/types_test.go - internal/mail/bd_test.go - internal/opencode/plugin_test.go - internal/rig/overlay_test.go - internal/runtime/runtime_test.go - internal/session/town_test.go - internal/style/style_test.go - internal/ui/markdown_test.go - internal/ui/terminal_test.go - internal/wisp/io_test.go - internal/wisp/types_test.go - internal/witness/types_test.go style_test.go uses func(...string) to match lipgloss variadic Render signature. * fix(lint): remove unused error return from buildCVSummary buildCVSummary always returned nil for its error value, causing golangci-lint to fail with "result 1 (error) is always nil". The function handles errors internally by returning partial data, so the error return was misleading. Removed it and updated caller.
This commit is contained in:
189
internal/agent/state_test.go
Normal file
189
internal/agent/state_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStateConstants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
state State
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{"StateStopped", StateStopped, "stopped"},
|
||||||
|
{"StateRunning", StateRunning, "running"},
|
||||||
|
{"StatePaused", StatePaused, "paused"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if string(tt.state) != tt.value {
|
||||||
|
t.Errorf("State constant = %q, want %q", tt.state, tt.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_StateFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||||
|
return &TestState{Value: "default"}
|
||||||
|
})
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(tmpDir, ".runtime", "test-state.json")
|
||||||
|
if manager.StateFile() != expectedPath {
|
||||||
|
t.Errorf("StateFile() = %q, want %q", manager.StateFile(), expectedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_Load_NoFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[TestState](tmpDir, "nonexistent.json", func() *TestState {
|
||||||
|
return &TestState{Value: "default"}
|
||||||
|
})
|
||||||
|
|
||||||
|
state, err := manager.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if state.Value != "default" {
|
||||||
|
t.Errorf("Load() value = %q, want %q", state.Value, "default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_Load_Save_Load(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||||
|
return &TestState{Value: "default"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save initial state
|
||||||
|
state := &TestState{Value: "test-value", Count: 42}
|
||||||
|
if err := manager.Save(state); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load it back
|
||||||
|
loaded, err := manager.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Value != state.Value {
|
||||||
|
t.Errorf("Load() value = %q, want %q", loaded.Value, state.Value)
|
||||||
|
}
|
||||||
|
if loaded.Count != state.Count {
|
||||||
|
t.Errorf("Load() count = %d, want %d", loaded.Count, state.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_Load_CreatesDirectory(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||||
|
return &TestState{Value: "default"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save should create .runtime directory
|
||||||
|
state := &TestState{Value: "test"}
|
||||||
|
if err := manager.Save(state); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory was created
|
||||||
|
runtimeDir := filepath.Join(tmpDir, ".runtime")
|
||||||
|
if _, err := os.Stat(runtimeDir); err != nil {
|
||||||
|
t.Errorf("Save() should create .runtime directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_Load_InvalidJSON(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[TestState](tmpDir, "test-state.json", func() *TestState {
|
||||||
|
return &TestState{Value: "default"}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write invalid JSON
|
||||||
|
statePath := manager.StateFile()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(statePath, []byte("invalid json"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := manager.Load()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Load() with invalid JSON should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestState_String(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
state State
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{StateStopped, "stopped"},
|
||||||
|
{StateRunning, "running"},
|
||||||
|
{StatePaused, "paused"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if string(tt.state) != tt.want {
|
||||||
|
t.Errorf("State(%q) = %q, want %q", tt.state, string(tt.state), tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateManager_GenericType(t *testing.T) {
|
||||||
|
// Test that StateManager works with different types
|
||||||
|
|
||||||
|
type ComplexState struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Values []int `json:"values"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Nested struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
} `json:"nested"`
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manager := NewStateManager[ComplexState](tmpDir, "complex.json", func() *ComplexState {
|
||||||
|
return &ComplexState{Name: "default", Values: []int{}}
|
||||||
|
})
|
||||||
|
|
||||||
|
original := &ComplexState{
|
||||||
|
Name: "test",
|
||||||
|
Values: []int{1, 2, 3},
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
original.Nested.X = 42
|
||||||
|
|
||||||
|
if err := manager.Save(original); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := manager.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.Name != original.Name {
|
||||||
|
t.Errorf("Name = %q, want %q", loaded.Name, original.Name)
|
||||||
|
}
|
||||||
|
if len(loaded.Values) != len(original.Values) {
|
||||||
|
t.Errorf("Values length = %d, want %d", len(loaded.Values), len(original.Values))
|
||||||
|
}
|
||||||
|
if loaded.Enabled != original.Enabled {
|
||||||
|
t.Errorf("Enabled = %v, want %v", loaded.Enabled, original.Enabled)
|
||||||
|
}
|
||||||
|
if loaded.Nested.X != original.Nested.X {
|
||||||
|
t.Errorf("Nested.X = %d, want %d", loaded.Nested.X, original.Nested.X)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestState is a simple type for testing
|
||||||
|
type TestState struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
93
internal/cmd/errors_test.go
Normal file
93
internal/cmd/errors_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSilentExitError_Error(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
code int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"zero code", 0, "exit 0"},
|
||||||
|
{"success code", 1, "exit 1"},
|
||||||
|
{"error code", 2, "exit 2"},
|
||||||
|
{"custom code", 42, "exit 42"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := &SilentExitError{Code: tt.code}
|
||||||
|
got := e.Error()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("SilentExitError.Error() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSilentExit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{0},
|
||||||
|
{1},
|
||||||
|
{2},
|
||||||
|
{127},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(fmt.Sprintf("code_%d", tt.code), func(t *testing.T) {
|
||||||
|
err := NewSilentExit(tt.code)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewSilentExit should return non-nil")
|
||||||
|
}
|
||||||
|
if err.Code != tt.code {
|
||||||
|
t.Errorf("NewSilentExit(%d).Code = %d, want %d", tt.code, err.Code, tt.code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSilentExit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantCode int
|
||||||
|
wantIsSilent bool
|
||||||
|
}{
|
||||||
|
{"nil error", nil, 0, false},
|
||||||
|
{"silent exit code 0", NewSilentExit(0), 0, true},
|
||||||
|
{"silent exit code 1", NewSilentExit(1), 1, true},
|
||||||
|
{"silent exit code 2", NewSilentExit(2), 2, true},
|
||||||
|
{"other error", errors.New("some error"), 0, false},
|
||||||
|
// Note: wrapped errors require errors.As fix - see PR #462
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
code, isSilent := IsSilentExit(tt.err)
|
||||||
|
if isSilent != tt.wantIsSilent {
|
||||||
|
t.Errorf("IsSilentExit(%v) isSilent = %v, want %v", tt.err, isSilent, tt.wantIsSilent)
|
||||||
|
}
|
||||||
|
if code != tt.wantCode {
|
||||||
|
t.Errorf("IsSilentExit(%v) code = %d, want %d", tt.err, code, tt.wantCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSilentExitError_Is(t *testing.T) {
|
||||||
|
// Test that SilentExitError works with errors.Is
|
||||||
|
err := NewSilentExit(1)
|
||||||
|
var target *SilentExitError
|
||||||
|
if !errors.As(err, &target) {
|
||||||
|
t.Error("errors.As should find SilentExitError")
|
||||||
|
}
|
||||||
|
if target.Code != 1 {
|
||||||
|
t.Errorf("errors.As extracted code = %d, want 1", target.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
100
internal/crew/types_test.go
Normal file
100
internal/crew/types_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package crew
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrewWorker_Summary(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
worker := &CrewWorker{
|
||||||
|
Name: "test-worker",
|
||||||
|
Rig: "gastown",
|
||||||
|
ClonePath: "/path/to/clone",
|
||||||
|
Branch: "main",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := worker.Summary()
|
||||||
|
|
||||||
|
if summary.Name != worker.Name {
|
||||||
|
t.Errorf("Summary.Name = %q, want %q", summary.Name, worker.Name)
|
||||||
|
}
|
||||||
|
if summary.Branch != worker.Branch {
|
||||||
|
t.Errorf("Summary.Branch = %q, want %q", summary.Branch, worker.Branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrewWorker_JSONMarshaling(t *testing.T) {
|
||||||
|
now := time.Now().Round(time.Second) // Round for JSON precision
|
||||||
|
worker := &CrewWorker{
|
||||||
|
Name: "test-worker",
|
||||||
|
Rig: "gastown",
|
||||||
|
ClonePath: "/path/to/clone",
|
||||||
|
Branch: "feature-branch",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON
|
||||||
|
data, err := json.Marshal(worker)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal back
|
||||||
|
var unmarshaled CrewWorker
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.Name != worker.Name {
|
||||||
|
t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, worker.Name)
|
||||||
|
}
|
||||||
|
if unmarshaled.Rig != worker.Rig {
|
||||||
|
t.Errorf("After round-trip: Rig = %q, want %q", unmarshaled.Rig, worker.Rig)
|
||||||
|
}
|
||||||
|
if unmarshaled.Branch != worker.Branch {
|
||||||
|
t.Errorf("After round-trip: Branch = %q, want %q", unmarshaled.Branch, worker.Branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummary_JSONMarshaling(t *testing.T) {
|
||||||
|
summary := Summary{
|
||||||
|
Name: "worker-1",
|
||||||
|
Branch: "main",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled Summary
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.Name != summary.Name {
|
||||||
|
t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, summary.Name)
|
||||||
|
}
|
||||||
|
if unmarshaled.Branch != summary.Branch {
|
||||||
|
t.Errorf("After round-trip: Branch = %q, want %q", unmarshaled.Branch, summary.Branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrewWorker_ZeroValues(t *testing.T) {
|
||||||
|
var worker CrewWorker
|
||||||
|
|
||||||
|
// Test zero value behavior
|
||||||
|
if worker.Name != "" {
|
||||||
|
t.Errorf("zero value CrewWorker.Name should be empty, got %q", worker.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := worker.Summary()
|
||||||
|
if summary.Name != "" {
|
||||||
|
t.Errorf("Summary of zero value CrewWorker should have empty Name, got %q", summary.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/doctor/errors_test.go
Normal file
31
internal/doctor/errors_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrCannotFix(t *testing.T) {
|
||||||
|
// Test that ErrCannotFix is defined and has expected message
|
||||||
|
if ErrCannotFix == nil {
|
||||||
|
t.Fatal("ErrCannotFix should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "check does not support auto-fix"
|
||||||
|
if ErrCannotFix.Error() != expected {
|
||||||
|
t.Errorf("ErrCannotFix.Error() = %q, want %q", ErrCannotFix.Error(), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrCannotFixIsError(t *testing.T) {
|
||||||
|
// Verify ErrCannotFix implements the error interface correctly
|
||||||
|
var err error = ErrCannotFix
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ErrCannotFix should implement error interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test errors.Is compatibility
|
||||||
|
if !errors.Is(ErrCannotFix, ErrCannotFix) {
|
||||||
|
t.Error("errors.Is should return true for ErrCannotFix")
|
||||||
|
}
|
||||||
|
}
|
||||||
148
internal/dog/types_test.go
Normal file
148
internal/dog/types_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package dog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestState_Constants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
state State
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"idle state", StateIdle, "idle"},
|
||||||
|
{"working state", StateWorking, "working"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if string(tt.state) != tt.want {
|
||||||
|
t.Errorf("State constant = %q, want %q", tt.state, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDog_ZeroValues(t *testing.T) {
|
||||||
|
var dog Dog
|
||||||
|
|
||||||
|
// Test zero value behavior
|
||||||
|
if dog.Name != "" {
|
||||||
|
t.Errorf("zero value Dog.Name should be empty, got %q", dog.Name)
|
||||||
|
}
|
||||||
|
if dog.State != "" {
|
||||||
|
t.Errorf("zero value Dog.State should be empty, got %q", dog.State)
|
||||||
|
}
|
||||||
|
if dog.Worktrees == nil {
|
||||||
|
// Worktrees is a map, nil is expected for zero value
|
||||||
|
} else if len(dog.Worktrees) != 0 {
|
||||||
|
t.Errorf("zero value Dog.Worktrees should be empty, got %d items", len(dog.Worktrees))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDogState_JSONMarshaling(t *testing.T) {
|
||||||
|
now := time.Now().Round(time.Second)
|
||||||
|
dogState := DogState{
|
||||||
|
Name: "test-dog",
|
||||||
|
State: StateWorking,
|
||||||
|
LastActive: now,
|
||||||
|
Work: "hq-abc123",
|
||||||
|
Worktrees: map[string]string{"gastown": "/path/to/worktree"},
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON
|
||||||
|
data, err := json.Marshal(dogState)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal back
|
||||||
|
var unmarshaled DogState
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.Name != dogState.Name {
|
||||||
|
t.Errorf("After round-trip: Name = %q, want %q", unmarshaled.Name, dogState.Name)
|
||||||
|
}
|
||||||
|
if unmarshaled.State != dogState.State {
|
||||||
|
t.Errorf("After round-trip: State = %q, want %q", unmarshaled.State, dogState.State)
|
||||||
|
}
|
||||||
|
if unmarshaled.Work != dogState.Work {
|
||||||
|
t.Errorf("After round-trip: Work = %q, want %q", unmarshaled.Work, dogState.Work)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDogState_OmitEmptyFields(t *testing.T) {
|
||||||
|
dogState := DogState{
|
||||||
|
Name: "test-dog",
|
||||||
|
State: StateIdle,
|
||||||
|
LastActive: time.Now(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
// Work and Worktrees left empty to test omitempty
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(dogState)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that empty fields are omitted
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() to map error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := raw["work"]; exists {
|
||||||
|
t.Error("Field 'work' should be omitted when empty")
|
||||||
|
}
|
||||||
|
if _, exists := raw["worktrees"]; exists {
|
||||||
|
t.Error("Field 'worktrees' should be omitted when empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields should be present
|
||||||
|
requiredFields := []string{"name", "state", "last_active", "created_at", "updated_at"}
|
||||||
|
for _, field := range requiredFields {
|
||||||
|
if _, exists := raw[field]; !exists {
|
||||||
|
t.Errorf("Required field '%s' should be present", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDogState_WithWorktrees(t *testing.T) {
|
||||||
|
dogState := DogState{
|
||||||
|
Name: "alpha",
|
||||||
|
State: StateWorking,
|
||||||
|
Worktrees: map[string]string{"gastown": "/path/1", "beads": "/path/2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(dogState)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() to map error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
worktrees, exists := raw["worktrees"]
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("Field 'worktrees' should be present when non-empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a map
|
||||||
|
worktreesMap, ok := worktrees.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("worktrees should be a JSON object")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(worktreesMap) != 2 {
|
||||||
|
t.Errorf("worktrees should have 2 entries, got %d", len(worktreesMap))
|
||||||
|
}
|
||||||
|
}
|
||||||
193
internal/mail/bd_test.go
Normal file
193
internal/mail/bd_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBdError_Error(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *bdError
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "stderr present",
|
||||||
|
err: &bdError{
|
||||||
|
Err: errors.New("some error"),
|
||||||
|
Stderr: "stderr output",
|
||||||
|
},
|
||||||
|
want: "stderr output",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no stderr, has error",
|
||||||
|
err: &bdError{
|
||||||
|
Err: errors.New("some error"),
|
||||||
|
Stderr: "",
|
||||||
|
},
|
||||||
|
want: "some error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no stderr, no error",
|
||||||
|
err: &bdError{
|
||||||
|
Err: nil,
|
||||||
|
Stderr: "",
|
||||||
|
},
|
||||||
|
want: "unknown bd error",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.err.Error()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("bdError.Error() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_Unwrap(t *testing.T) {
|
||||||
|
originalErr := errors.New("original error")
|
||||||
|
bdErr := &bdError{
|
||||||
|
Err: originalErr,
|
||||||
|
Stderr: "stderr output",
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapped := bdErr.Unwrap()
|
||||||
|
if unwrapped != originalErr {
|
||||||
|
t.Errorf("bdError.Unwrap() = %v, want %v", unwrapped, originalErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_UnwrapNil(t *testing.T) {
|
||||||
|
bdErr := &bdError{
|
||||||
|
Err: nil,
|
||||||
|
Stderr: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapped := bdErr.Unwrap()
|
||||||
|
if unwrapped != nil {
|
||||||
|
t.Errorf("bdError.Unwrap() with nil Err should return nil, got %v", unwrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_ContainsError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err *bdError
|
||||||
|
substr string
|
||||||
|
contains bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "substring present",
|
||||||
|
err: &bdError{
|
||||||
|
Stderr: "error: bead not found",
|
||||||
|
},
|
||||||
|
substr: "bead not found",
|
||||||
|
contains: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "substring not present",
|
||||||
|
err: &bdError{
|
||||||
|
Stderr: "error: bead not found",
|
||||||
|
},
|
||||||
|
substr: "permission denied",
|
||||||
|
contains: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty stderr",
|
||||||
|
err: &bdError{
|
||||||
|
Stderr: "",
|
||||||
|
},
|
||||||
|
substr: "anything",
|
||||||
|
contains: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case sensitive",
|
||||||
|
err: &bdError{
|
||||||
|
Stderr: "Error: Bead Not Found",
|
||||||
|
},
|
||||||
|
substr: "bead not found",
|
||||||
|
contains: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.err.ContainsError(tt.substr)
|
||||||
|
if got != tt.contains {
|
||||||
|
t.Errorf("bdError.ContainsError(%q) = %v, want %v", tt.substr, got, tt.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_ContainsErrorPartialMatch(t *testing.T) {
|
||||||
|
err := &bdError{
|
||||||
|
Stderr: "fatal: invalid bead ID format: expected prefix-#id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test partial matches
|
||||||
|
if !err.ContainsError("invalid bead ID") {
|
||||||
|
t.Error("Should contain partial substring")
|
||||||
|
}
|
||||||
|
if !err.ContainsError("fatal:") {
|
||||||
|
t.Error("Should contain prefix")
|
||||||
|
}
|
||||||
|
if !err.ContainsError("expected prefix") {
|
||||||
|
t.Error("Should contain suffix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_ContainsErrorSpecialChars(t *testing.T) {
|
||||||
|
err := &bdError{
|
||||||
|
Stderr: "error: bead 'gt-123' not found (exit 1)",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !err.ContainsError("'gt-123'") {
|
||||||
|
t.Error("Should handle quotes in substring")
|
||||||
|
}
|
||||||
|
if !err.ContainsError("(exit 1)") {
|
||||||
|
t.Error("Should handle parentheses in substring")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_ImplementsErrorInterface(t *testing.T) {
|
||||||
|
// Verify bdError implements error interface
|
||||||
|
var err error = &bdError{
|
||||||
|
Err: errors.New("test"),
|
||||||
|
Stderr: "test stderr",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = err.Error() // Should compile and not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBdError_WithAllFields(t *testing.T) {
|
||||||
|
originalErr := errors.New("original error")
|
||||||
|
bdErr := &bdError{
|
||||||
|
Err: originalErr,
|
||||||
|
Stderr: "command failed: bead not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Error() returns stderr
|
||||||
|
got := bdErr.Error()
|
||||||
|
want := "command failed: bead not found"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("bdError.Error() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Unwrap() returns original error
|
||||||
|
unwrapped := bdErr.Unwrap()
|
||||||
|
if unwrapped != originalErr {
|
||||||
|
t.Errorf("bdError.Unwrap() = %v, want %v", unwrapped, originalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ContainsError works
|
||||||
|
if !bdErr.ContainsError("bead not found") {
|
||||||
|
t.Error("ContainsError should find substring in stderr")
|
||||||
|
}
|
||||||
|
if bdErr.ContainsError("not present") {
|
||||||
|
t.Error("ContainsError should return false for non-existent substring")
|
||||||
|
}
|
||||||
|
}
|
||||||
153
internal/opencode/plugin_test.go
Normal file
153
internal/opencode/plugin_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsurePluginAt_EmptyParameters(t *testing.T) {
|
||||||
|
// Test that empty pluginDir or pluginFile returns nil
|
||||||
|
t.Run("empty pluginDir", func(t *testing.T) {
|
||||||
|
err := EnsurePluginAt("/tmp/work", "", "plugin.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsurePluginAt() with empty pluginDir should return nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty pluginFile", func(t *testing.T) {
|
||||||
|
err := EnsurePluginAt("/tmp/work", "plugins", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsurePluginAt() with empty pluginFile should return nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("both empty", func(t *testing.T) {
|
||||||
|
err := EnsurePluginAt("/tmp/work", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsurePluginAt() with both empty should return nil, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePluginAt_FileExists(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the plugin file first
|
||||||
|
pluginDir := "plugins"
|
||||||
|
pluginFile := "gastown.js"
|
||||||
|
pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create test directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a placeholder file
|
||||||
|
existingContent := []byte("// existing plugin")
|
||||||
|
if err := os.WriteFile(pluginPath, existingContent, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsurePluginAt should not overwrite existing file
|
||||||
|
err := EnsurePluginAt(tmpDir, pluginDir, pluginFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsurePluginAt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file content is unchanged
|
||||||
|
content, err := os.ReadFile(pluginPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read plugin file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != string(existingContent) {
|
||||||
|
t.Error("EnsurePluginAt() should not overwrite existing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePluginAt_CreatesFile(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
pluginDir := "plugins"
|
||||||
|
pluginFile := "gastown.js"
|
||||||
|
pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile)
|
||||||
|
|
||||||
|
// Ensure plugin doesn't exist
|
||||||
|
if _, err := os.Stat(pluginPath); err == nil {
|
||||||
|
t.Fatal("Plugin file should not exist yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the plugin
|
||||||
|
err := EnsurePluginAt(tmpDir, pluginDir, pluginFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsurePluginAt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was created
|
||||||
|
info, err := os.Stat(pluginPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plugin file was not created: %v", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
t.Error("Plugin path should be a file, not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file has content
|
||||||
|
content, err := os.ReadFile(pluginPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read plugin file: %v", err)
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
t.Error("Plugin file should have content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePluginAt_CreatesDirectory(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
pluginDir := "nested/plugins/dir"
|
||||||
|
pluginFile := "gastown.js"
|
||||||
|
pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile)
|
||||||
|
|
||||||
|
// Create the plugin
|
||||||
|
err := EnsurePluginAt(tmpDir, pluginDir, pluginFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsurePluginAt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory was created
|
||||||
|
dirInfo, err := os.Stat(filepath.Dir(pluginPath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plugin directory was not created: %v", err)
|
||||||
|
}
|
||||||
|
if !dirInfo.IsDir() {
|
||||||
|
t.Error("Plugin parent path should be a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsurePluginAt_FilePermissions(t *testing.T) {
|
||||||
|
// Create a temporary directory
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
pluginDir := "plugins"
|
||||||
|
pluginFile := "gastown.js"
|
||||||
|
pluginPath := filepath.Join(tmpDir, pluginDir, pluginFile)
|
||||||
|
|
||||||
|
err := EnsurePluginAt(tmpDir, pluginDir, pluginFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsurePluginAt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(pluginPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat plugin file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file mode is 0644 (rw-r--r--)
|
||||||
|
expectedMode := os.FileMode(0644)
|
||||||
|
if info.Mode() != expectedMode {
|
||||||
|
t.Errorf("Plugin file mode = %v, want %v", info.Mode(), expectedMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
251
internal/rig/overlay_test.go
Normal file
251
internal/rig/overlay_test.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package rig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyOverlay_NoOverlayDirectory(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// No overlay directory exists
|
||||||
|
err := CopyOverlay(tmpDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CopyOverlay() with no overlay directory should return nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyOverlay_CopiesFiles(t *testing.T) {
|
||||||
|
rigDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create overlay directory with test files
|
||||||
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
||||||
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
testFile1 := filepath.Join(overlayDir, "test1.txt")
|
||||||
|
testFile2 := filepath.Join(overlayDir, "test2.txt")
|
||||||
|
|
||||||
|
if err := os.WriteFile(testFile1, []byte("content1"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(testFile2, []byte("content2"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy overlay
|
||||||
|
err := CopyOverlay(rigDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyOverlay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify files were copied
|
||||||
|
destFile1 := filepath.Join(destDir, "test1.txt")
|
||||||
|
destFile2 := filepath.Join(destDir, "test2.txt")
|
||||||
|
|
||||||
|
content1, err := os.ReadFile(destFile1)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("File test1.txt was not copied: %v", err)
|
||||||
|
}
|
||||||
|
if string(content1) != "content1" {
|
||||||
|
t.Errorf("test1.txt content = %q, want %q", string(content1), "content1")
|
||||||
|
}
|
||||||
|
|
||||||
|
content2, err := os.ReadFile(destFile2)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("File test2.txt was not copied: %v", err)
|
||||||
|
}
|
||||||
|
if string(content2) != "content2" {
|
||||||
|
t.Errorf("test2.txt content = %q, want %q", string(content2), "content2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyOverlay_PreservesPermissions(t *testing.T) {
|
||||||
|
rigDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create overlay directory with a file
|
||||||
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
||||||
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(overlayDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("content"), 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy overlay
|
||||||
|
err := CopyOverlay(rigDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyOverlay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify permissions were preserved
|
||||||
|
srcInfo, _ := os.Stat(testFile)
|
||||||
|
destInfo, err := os.Stat(filepath.Join(destDir, "test.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat destination file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcInfo.Mode().Perm() != destInfo.Mode().Perm() {
|
||||||
|
t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), destInfo.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyOverlay_SkipsSubdirectories(t *testing.T) {
|
||||||
|
rigDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create overlay directory with a subdirectory
|
||||||
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
||||||
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a subdirectory
|
||||||
|
subDir := filepath.Join(overlayDir, "subdir")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file in the overlay root
|
||||||
|
testFile := filepath.Join(overlayDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file in the subdirectory
|
||||||
|
subFile := filepath.Join(subDir, "sub.txt")
|
||||||
|
if err := os.WriteFile(subFile, []byte("subcontent"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create sub file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy overlay
|
||||||
|
err := CopyOverlay(rigDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyOverlay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify root file was copied
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "test.txt")); err != nil {
|
||||||
|
t.Error("Root file should be copied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify subdirectory was NOT copied
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "subdir")); err == nil {
|
||||||
|
t.Error("Subdirectory should not be copied")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(destDir, "subdir", "sub.txt")); err == nil {
|
||||||
|
t.Error("File in subdirectory should not be copied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyOverlay_EmptyOverlay(t *testing.T) {
|
||||||
|
rigDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create empty overlay directory
|
||||||
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
||||||
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy overlay
|
||||||
|
err := CopyOverlay(rigDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyOverlay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should succeed without errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyOverlay_OverwritesExisting(t *testing.T) {
|
||||||
|
rigDir := t.TempDir()
|
||||||
|
destDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create overlay directory with test file
|
||||||
|
overlayDir := filepath.Join(rigDir, ".runtime", "overlay")
|
||||||
|
if err := os.MkdirAll(overlayDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create overlay dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(overlayDir, "test.txt")
|
||||||
|
if err := os.WriteFile(testFile, []byte("new content"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create existing file in destination with different content
|
||||||
|
destFile := filepath.Join(destDir, "test.txt")
|
||||||
|
if err := os.WriteFile(destFile, []byte("old content"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create dest file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy overlay
|
||||||
|
err := CopyOverlay(rigDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyOverlay() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was overwritten
|
||||||
|
content, err := os.ReadFile(destFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read dest file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "new content" {
|
||||||
|
t.Errorf("File content = %q, want %q", string(content), "new content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFilePreserveMode(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create source file
|
||||||
|
srcFile := filepath.Join(tmpDir, "src.txt")
|
||||||
|
if err := os.WriteFile(srcFile, []byte("test content"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to create src file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
dstFile := filepath.Join(tmpDir, "dst.txt")
|
||||||
|
err := copyFilePreserveMode(srcFile, dstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copyFilePreserveMode() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content
|
||||||
|
content, err := os.ReadFile(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to read dst file: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != "test content" {
|
||||||
|
t.Errorf("Content = %q, want %q", string(content), "test content")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify permissions
|
||||||
|
srcInfo, _ := os.Stat(srcFile)
|
||||||
|
dstInfo, err := os.Stat(dstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat dst file: %v", err)
|
||||||
|
}
|
||||||
|
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
|
||||||
|
t.Errorf("Permissions not preserved: src=%v, dest=%v", srcInfo.Mode(), dstInfo.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFilePreserveMode_NonexistentSource(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
srcFile := filepath.Join(tmpDir, "nonexistent.txt")
|
||||||
|
dstFile := filepath.Join(tmpDir, "dst.txt")
|
||||||
|
|
||||||
|
err := copyFilePreserveMode(srcFile, dstFile)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("copyFilePreserveMode() with nonexistent source should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/runtime/runtime_test.go
Normal file
307
internal/runtime/runtime_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionIDFromEnv_Default(t *testing.T) {
|
||||||
|
// Clear all environment variables
|
||||||
|
oldGSEnv := os.Getenv("GT_SESSION_ID_ENV")
|
||||||
|
oldClaudeID := os.Getenv("CLAUDE_SESSION_ID")
|
||||||
|
defer func() {
|
||||||
|
if oldGSEnv != "" {
|
||||||
|
os.Setenv("GT_SESSION_ID_ENV", oldGSEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_SESSION_ID_ENV")
|
||||||
|
}
|
||||||
|
if oldClaudeID != "" {
|
||||||
|
os.Setenv("CLAUDE_SESSION_ID", oldClaudeID)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_SESSION_ID")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
os.Unsetenv("GT_SESSION_ID_ENV")
|
||||||
|
os.Unsetenv("CLAUDE_SESSION_ID")
|
||||||
|
|
||||||
|
result := SessionIDFromEnv()
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("SessionIDFromEnv() with no env vars should return empty, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIDFromEnv_ClaudeSessionID(t *testing.T) {
|
||||||
|
oldGSEnv := os.Getenv("GT_SESSION_ID_ENV")
|
||||||
|
oldClaudeID := os.Getenv("CLAUDE_SESSION_ID")
|
||||||
|
defer func() {
|
||||||
|
if oldGSEnv != "" {
|
||||||
|
os.Setenv("GT_SESSION_ID_ENV", oldGSEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_SESSION_ID_ENV")
|
||||||
|
}
|
||||||
|
if oldClaudeID != "" {
|
||||||
|
os.Setenv("CLAUDE_SESSION_ID", oldClaudeID)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_SESSION_ID")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_SESSION_ID_ENV")
|
||||||
|
os.Setenv("CLAUDE_SESSION_ID", "test-session-123")
|
||||||
|
|
||||||
|
result := SessionIDFromEnv()
|
||||||
|
if result != "test-session-123" {
|
||||||
|
t.Errorf("SessionIDFromEnv() = %q, want %q", result, "test-session-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIDFromEnv_CustomEnvVar(t *testing.T) {
|
||||||
|
oldGSEnv := os.Getenv("GT_SESSION_ID_ENV")
|
||||||
|
oldCustomID := os.Getenv("CUSTOM_SESSION_ID")
|
||||||
|
oldClaudeID := os.Getenv("CLAUDE_SESSION_ID")
|
||||||
|
defer func() {
|
||||||
|
if oldGSEnv != "" {
|
||||||
|
os.Setenv("GT_SESSION_ID_ENV", oldGSEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_SESSION_ID_ENV")
|
||||||
|
}
|
||||||
|
if oldCustomID != "" {
|
||||||
|
os.Setenv("CUSTOM_SESSION_ID", oldCustomID)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CUSTOM_SESSION_ID")
|
||||||
|
}
|
||||||
|
if oldClaudeID != "" {
|
||||||
|
os.Setenv("CLAUDE_SESSION_ID", oldClaudeID)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_SESSION_ID")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GT_SESSION_ID_ENV", "CUSTOM_SESSION_ID")
|
||||||
|
os.Setenv("CUSTOM_SESSION_ID", "custom-session-456")
|
||||||
|
os.Setenv("CLAUDE_SESSION_ID", "claude-session-789")
|
||||||
|
|
||||||
|
result := SessionIDFromEnv()
|
||||||
|
if result != "custom-session-456" {
|
||||||
|
t.Errorf("SessionIDFromEnv() with custom env = %q, want %q", result, "custom-session-456")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSleepForReadyDelay_NilConfig(t *testing.T) {
|
||||||
|
// Should not panic with nil config
|
||||||
|
SleepForReadyDelay(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSleepForReadyDelay_ZeroDelay(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Tmux: &config.RuntimeTmuxConfig{
|
||||||
|
ReadyDelayMs: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
SleepForReadyDelay(rc)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Should return immediately
|
||||||
|
if elapsed > 100*time.Millisecond {
|
||||||
|
t.Errorf("SleepForReadyDelay() with zero delay took too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSleepForReadyDelay_WithDelay(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Tmux: &config.RuntimeTmuxConfig{
|
||||||
|
ReadyDelayMs: 10, // 10ms delay
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
SleepForReadyDelay(rc)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Should sleep for at least 10ms
|
||||||
|
if elapsed < 10*time.Millisecond {
|
||||||
|
t.Errorf("SleepForReadyDelay() should sleep for at least 10ms, took %v", elapsed)
|
||||||
|
}
|
||||||
|
// But not too long
|
||||||
|
if elapsed > 50*time.Millisecond {
|
||||||
|
t.Errorf("SleepForReadyDelay() slept too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSleepForReadyDelay_NilTmuxConfig(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Tmux: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
SleepForReadyDelay(rc)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Should return immediately
|
||||||
|
if elapsed > 100*time.Millisecond {
|
||||||
|
t.Errorf("SleepForReadyDelay() with nil Tmux config took too long: %v", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_NoHooks(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
commands := StartupFallbackCommands("polecat", rc)
|
||||||
|
if commands == nil {
|
||||||
|
t.Error("StartupFallbackCommands() with no hooks should return commands")
|
||||||
|
}
|
||||||
|
if len(commands) == 0 {
|
||||||
|
t.Error("StartupFallbackCommands() should return at least one command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_WithHooks(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "claude",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
commands := StartupFallbackCommands("polecat", rc)
|
||||||
|
if commands != nil {
|
||||||
|
t.Error("StartupFallbackCommands() with hooks provider should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_NilConfig(t *testing.T) {
|
||||||
|
// Nil config defaults to claude provider, which has hooks
|
||||||
|
// So it returns nil (no fallback commands needed)
|
||||||
|
commands := StartupFallbackCommands("polecat", nil)
|
||||||
|
if commands != nil {
|
||||||
|
t.Error("StartupFallbackCommands() with nil config should return nil (defaults to claude with hooks)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_AutonomousRole(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
autonomousRoles := []string{"polecat", "witness", "refinery", "deacon"}
|
||||||
|
for _, role := range autonomousRoles {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
commands := StartupFallbackCommands(role, rc)
|
||||||
|
if commands == nil || len(commands) == 0 {
|
||||||
|
t.Error("StartupFallbackCommands() should return commands for autonomous role")
|
||||||
|
}
|
||||||
|
// Should contain mail check
|
||||||
|
found := false
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if contains(cmd, "mail check --inject") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Commands for %s should contain mail check --inject", role)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_NonAutonomousRole(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
nonAutonomousRoles := []string{"mayor", "crew", "keeper"}
|
||||||
|
for _, role := range nonAutonomousRoles {
|
||||||
|
t.Run(role, func(t *testing.T) {
|
||||||
|
commands := StartupFallbackCommands(role, rc)
|
||||||
|
if commands == nil || len(commands) == 0 {
|
||||||
|
t.Error("StartupFallbackCommands() should return commands for non-autonomous role")
|
||||||
|
}
|
||||||
|
// Should NOT contain mail check
|
||||||
|
for _, cmd := range commands {
|
||||||
|
if contains(cmd, "mail check --inject") {
|
||||||
|
t.Errorf("Commands for %s should NOT contain mail check --inject", role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupFallbackCommands_RoleCasing(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role should be lowercased internally
|
||||||
|
commands := StartupFallbackCommands("POLECAT", rc)
|
||||||
|
if commands == nil {
|
||||||
|
t.Error("StartupFallbackCommands() should handle uppercase role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSettingsForRole_NilConfig(t *testing.T) {
|
||||||
|
// Should not panic with nil config
|
||||||
|
err := EnsureSettingsForRole("/tmp/test", "polecat", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsureSettingsForRole() with nil config should not error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSettingsForRole_NilHooks(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSettingsForRole("/tmp/test", "polecat", rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsureSettingsForRole() with nil hooks should not error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSettingsForRole_UnknownProvider(t *testing.T) {
|
||||||
|
rc := &config.RuntimeConfig{
|
||||||
|
Hooks: &config.RuntimeHooksConfig{
|
||||||
|
Provider: "unknown",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := EnsureSettingsForRole("/tmp/test", "polecat", rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("EnsureSettingsForRole() with unknown provider should not error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) >= len(substr) && findSubstring(s, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSubstring(s, substr string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
match := true
|
||||||
|
for j := 0; j < len(substr); j++ {
|
||||||
|
if s[i+j] != substr[j] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
93
internal/session/town_test.go
Normal file
93
internal/session/town_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTownSessions(t *testing.T) {
|
||||||
|
sessions := TownSessions()
|
||||||
|
|
||||||
|
if len(sessions) != 3 {
|
||||||
|
t.Errorf("TownSessions() returned %d sessions, want 3", len(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order is correct (Mayor, Boot, Deacon)
|
||||||
|
expectedOrder := []string{"Mayor", "Boot", "Deacon"}
|
||||||
|
for i, s := range sessions {
|
||||||
|
if s.Name != expectedOrder[i] {
|
||||||
|
t.Errorf("TownSessions()[%d].Name = %q, want %q", i, s.Name, expectedOrder[i])
|
||||||
|
}
|
||||||
|
if s.SessionID == "" {
|
||||||
|
t.Errorf("TownSessions()[%d].SessionID should not be empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownSessions_SessionIDFormats(t *testing.T) {
|
||||||
|
sessions := TownSessions()
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.SessionID == "" {
|
||||||
|
t.Errorf("TownSession %q has empty SessionID", s.Name)
|
||||||
|
}
|
||||||
|
// Session IDs should follow a pattern
|
||||||
|
if len(s.SessionID) < 4 {
|
||||||
|
t.Errorf("TownSession %q SessionID %q is too short", s.Name, s.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownSession_StructFields(t *testing.T) {
|
||||||
|
ts := TownSession{
|
||||||
|
Name: "Test",
|
||||||
|
SessionID: "test-session",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts.Name != "Test" {
|
||||||
|
t.Errorf("TownSession.Name = %q, want %q", ts.Name, "Test")
|
||||||
|
}
|
||||||
|
if ts.SessionID != "test-session" {
|
||||||
|
t.Errorf("TownSession.SessionID = %q, want %q", ts.SessionID, "test-session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownSession_CanBeCreated(t *testing.T) {
|
||||||
|
// Test that TownSession can be created with any values
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sessionID string
|
||||||
|
}{
|
||||||
|
{"Mayor", "hq-mayor"},
|
||||||
|
{"Boot", "hq-boot"},
|
||||||
|
{"Custom", "custom-session"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
ts := TownSession{
|
||||||
|
Name: tt.name,
|
||||||
|
SessionID: tt.sessionID,
|
||||||
|
}
|
||||||
|
if ts.Name != tt.name {
|
||||||
|
t.Errorf("TownSession.Name = %q, want %q", ts.Name, tt.name)
|
||||||
|
}
|
||||||
|
if ts.SessionID != tt.sessionID {
|
||||||
|
t.Errorf("TownSession.SessionID = %q, want %q", ts.SessionID, tt.sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTownSession_ShutdownOrder(t *testing.T) {
|
||||||
|
// Verify that shutdown order is Mayor -> Boot -> Deacon
|
||||||
|
// This is critical because Boot monitors Deacon
|
||||||
|
sessions := TownSessions()
|
||||||
|
|
||||||
|
if sessions[0].Name != "Mayor" {
|
||||||
|
t.Errorf("First session should be Mayor, got %q", sessions[0].Name)
|
||||||
|
}
|
||||||
|
if sessions[1].Name != "Boot" {
|
||||||
|
t.Errorf("Second session should be Boot, got %q", sessions[1].Name)
|
||||||
|
}
|
||||||
|
if sessions[2].Name != "Deacon" {
|
||||||
|
t.Errorf("Third session should be Deacon, got %q", sessions[2].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/style/style_test.go
Normal file
169
internal/style/style_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package style
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStyleVariables(t *testing.T) {
|
||||||
|
// Test that all style variables render non-empty output
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
render func(...string) string
|
||||||
|
}{
|
||||||
|
{"Success", Success.Render},
|
||||||
|
{"Warning", Warning.Render},
|
||||||
|
{"Error", Error.Render},
|
||||||
|
{"Info", Info.Render},
|
||||||
|
{"Dim", Dim.Render},
|
||||||
|
{"Bold", Bold.Render},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.render == nil {
|
||||||
|
t.Errorf("Style variable %s should not be nil", tt.name)
|
||||||
|
}
|
||||||
|
// Test that Render works
|
||||||
|
result := tt.render("test")
|
||||||
|
if result == "" {
|
||||||
|
t.Errorf("Style %s.Render() should not return empty string", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefixVariables(t *testing.T) {
|
||||||
|
// Test that all prefix variables are non-empty
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{"SuccessPrefix", SuccessPrefix},
|
||||||
|
{"WarningPrefix", WarningPrefix},
|
||||||
|
{"ErrorPrefix", ErrorPrefix},
|
||||||
|
{"ArrowPrefix", ArrowPrefix},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.prefix == "" {
|
||||||
|
t.Errorf("Prefix variable %s should not be empty", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintWarning(t *testing.T) {
|
||||||
|
// Capture stdout
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
PrintWarning("test warning: %s", "value")
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
// Read captured output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
if output == "" {
|
||||||
|
t.Error("PrintWarning() should produce output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that warning message is present
|
||||||
|
if !bytes.Contains(buf.Bytes(), []byte("test warning: value")) {
|
||||||
|
t.Error("PrintWarning() output should contain the warning message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintWarning_NoFormatArgs(t *testing.T) {
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
PrintWarning("simple warning")
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
output := buf.String()
|
||||||
|
|
||||||
|
if output == "" {
|
||||||
|
t.Error("PrintWarning() should produce output")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Contains(buf.Bytes(), []byte("simple warning")) {
|
||||||
|
t.Error("PrintWarning() output should contain the message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStyles_RenderConsistently(t *testing.T) {
|
||||||
|
// Test that styles consistently render non-empty output
|
||||||
|
testText := "test message"
|
||||||
|
|
||||||
|
styles := map[string]func(...string) string{
|
||||||
|
"Success": Success.Render,
|
||||||
|
"Warning": Warning.Render,
|
||||||
|
"Error": Error.Render,
|
||||||
|
"Info": Info.Render,
|
||||||
|
"Dim": Dim.Render,
|
||||||
|
"Bold": Bold.Render,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, renderFunc := range styles {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
result := renderFunc(testText)
|
||||||
|
if result == "" {
|
||||||
|
t.Errorf("Style %s.Render() should not return empty string", name)
|
||||||
|
}
|
||||||
|
// Result should be different from input (has styling codes)
|
||||||
|
// except possibly for some edge cases
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplePrintWarning(t *testing.T) {
|
||||||
|
// Test that multiple warnings can be printed
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
PrintWarning("warning %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Close()
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
io.Copy(&buf, r)
|
||||||
|
_ = buf.String() // ensure buffer is read
|
||||||
|
|
||||||
|
// Should have 3 lines
|
||||||
|
lineCount := 0
|
||||||
|
for _, b := range buf.Bytes() {
|
||||||
|
if b == '\n' {
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lineCount != 3 {
|
||||||
|
t.Errorf("Expected 3 lines of output, got %d", lineCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExamplePrintWarning() {
|
||||||
|
// This example demonstrates PrintWarning usage
|
||||||
|
fmt.Print("Example output:\n")
|
||||||
|
PrintWarning("This is a warning message")
|
||||||
|
PrintWarning("Warning with value: %d", 42)
|
||||||
|
}
|
||||||
234
internal/ui/markdown_test.go
Normal file
234
internal/ui/markdown_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMarkdown_AgentMode(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GT_AGENT_MODE", "1")
|
||||||
|
|
||||||
|
markdown := "# Test Header\n\nSome content"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
if result != markdown {
|
||||||
|
t.Errorf("RenderMarkdown() in agent mode should return raw markdown, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_SimpleText(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1") // Disable glamour rendering
|
||||||
|
|
||||||
|
markdown := "Simple text without formatting"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
// When color is disabled, should return raw markdown
|
||||||
|
if result != markdown {
|
||||||
|
t.Errorf("RenderMarkdown() with color disabled should return raw markdown, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_EmptyString(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
result := RenderMarkdown("")
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("RenderMarkdown() with empty string should return empty, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_GracefulDegradation(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
// Test that function doesn't panic and always returns something
|
||||||
|
markdown := "# Test\n\nContent with **bold** and *italic*"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Error("RenderMarkdown() should never return empty string for non-empty input")
|
||||||
|
}
|
||||||
|
// With NO_COLOR, should return raw markdown
|
||||||
|
if !strings.Contains(result, "bold") {
|
||||||
|
t.Error("RenderMarkdown() should contain original content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTerminalWidth(t *testing.T) {
|
||||||
|
// This function is unexported, but we can test it indirectly
|
||||||
|
// The function should return a reasonable width
|
||||||
|
// Since we can't call it directly, we verify RenderMarkdown doesn't panic
|
||||||
|
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
// This should not panic even if terminal width detection fails
|
||||||
|
markdown := strings.Repeat("word ", 1000) // Long content
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
t.Error("RenderMarkdown() should handle long content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_Newlines(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
markdown := "Line 1\n\nLine 2\n\nLine 3"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
// With color disabled, newlines should be preserved
|
||||||
|
if !strings.Contains(result, "Line 1") {
|
||||||
|
t.Error("RenderMarkdown() should preserve first line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "Line 2") {
|
||||||
|
t.Error("RenderMarkdown() should preserve second line")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "Line 3") {
|
||||||
|
t.Error("RenderMarkdown() should preserve third line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_CodeBlocks(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
markdown := "```go\nfunc main() {}\n```"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
// Should contain the code
|
||||||
|
if !strings.Contains(result, "func main") {
|
||||||
|
t.Error("RenderMarkdown() should preserve code block content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_Links(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
|
||||||
|
markdown := "[link text](https://example.com)"
|
||||||
|
result := RenderMarkdown(markdown)
|
||||||
|
|
||||||
|
// Should contain the link text or URL
|
||||||
|
if !strings.Contains(result, "link text") && !strings.Contains(result, "example.com") {
|
||||||
|
t.Error("RenderMarkdown() should preserve link information")
|
||||||
|
}
|
||||||
|
}
|
||||||
247
internal/ui/terminal_test.go
Normal file
247
internal/ui/terminal_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsTerminal(t *testing.T) {
|
||||||
|
// This test verifies the function doesn't panic
|
||||||
|
// The actual result depends on the test environment
|
||||||
|
result := IsTerminal()
|
||||||
|
// In test environment, this is usually false
|
||||||
|
// The important thing is it doesn't crash and returns a bool
|
||||||
|
var _ bool = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseColor_Default(t *testing.T) {
|
||||||
|
// Clean environment for this test
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
oldClicolor := os.Getenv("CLICOLOR")
|
||||||
|
oldClicolorForce := os.Getenv("CLICOLOR_FORCE")
|
||||||
|
defer func() {
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
if oldClicolor != "" {
|
||||||
|
os.Setenv("CLICOLOR", oldClicolor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLICOLOR")
|
||||||
|
}
|
||||||
|
if oldClicolorForce != "" {
|
||||||
|
os.Setenv("CLICOLOR_FORCE", oldClicolorForce)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLICOLOR_FORCE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
os.Unsetenv("CLICOLOR")
|
||||||
|
os.Unsetenv("CLICOLOR_FORCE")
|
||||||
|
|
||||||
|
result := ShouldUseColor()
|
||||||
|
// In non-TTY test environment, should be false
|
||||||
|
_ = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseColor_NO_COLOR(t *testing.T) {
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("NO_COLOR", "1")
|
||||||
|
if ShouldUseColor() {
|
||||||
|
t.Error("ShouldUseColor() should return false when NO_COLOR is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseColor_NO_COLOR_AnyValue(t *testing.T) {
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// NO_COLOR with any value (even "0") should disable color
|
||||||
|
os.Setenv("NO_COLOR", "0")
|
||||||
|
if ShouldUseColor() {
|
||||||
|
t.Error("ShouldUseColor() should return false when NO_COLOR is set to any value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseColor_CLICOLOR_0(t *testing.T) {
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
oldClicolor := os.Getenv("CLICOLOR")
|
||||||
|
defer func() {
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
if oldClicolor != "" {
|
||||||
|
os.Setenv("CLICOLOR", oldClicolor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLICOLOR")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
os.Setenv("CLICOLOR", "0")
|
||||||
|
if ShouldUseColor() {
|
||||||
|
t.Error("ShouldUseColor() should return false when CLICOLOR=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseColor_CLICOLOR_FORCE(t *testing.T) {
|
||||||
|
oldNoColor := os.Getenv("NO_COLOR")
|
||||||
|
oldClicolorForce := os.Getenv("CLICOLOR_FORCE")
|
||||||
|
defer func() {
|
||||||
|
if oldNoColor != "" {
|
||||||
|
os.Setenv("NO_COLOR", oldNoColor)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
}
|
||||||
|
if oldClicolorForce != "" {
|
||||||
|
os.Setenv("CLICOLOR_FORCE", oldClicolorForce)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLICOLOR_FORCE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("NO_COLOR")
|
||||||
|
os.Setenv("CLICOLOR_FORCE", "1")
|
||||||
|
if !ShouldUseColor() {
|
||||||
|
t.Error("ShouldUseColor() should return true when CLICOLOR_FORCE is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseEmoji_Default(t *testing.T) {
|
||||||
|
oldNoEmoji := os.Getenv("GT_NO_EMOJI")
|
||||||
|
defer func() {
|
||||||
|
if oldNoEmoji != "" {
|
||||||
|
os.Setenv("GT_NO_EMOJI", oldNoEmoji)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_NO_EMOJI")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_NO_EMOJI")
|
||||||
|
result := ShouldUseEmoji()
|
||||||
|
_ = result // Result depends on test environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseEmoji_GT_NO_EMOJI(t *testing.T) {
|
||||||
|
oldNoEmoji := os.Getenv("GT_NO_EMOJI")
|
||||||
|
defer func() {
|
||||||
|
if oldNoEmoji != "" {
|
||||||
|
os.Setenv("GT_NO_EMOJI", oldNoEmoji)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_NO_EMOJI")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GT_NO_EMOJI", "1")
|
||||||
|
if ShouldUseEmoji() {
|
||||||
|
t.Error("ShouldUseEmoji() should return false when GT_NO_EMOJI is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAgentMode_Default(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldClaudeCode := os.Getenv("CLAUDE_CODE")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldClaudeCode != "" {
|
||||||
|
os.Setenv("CLAUDE_CODE", oldClaudeCode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_CODE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Unsetenv("CLAUDE_CODE")
|
||||||
|
if IsAgentMode() {
|
||||||
|
t.Error("IsAgentMode() should return false by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAgentMode_GT_AGENT_MODE(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("GT_AGENT_MODE", "1")
|
||||||
|
if !IsAgentMode() {
|
||||||
|
t.Error("IsAgentMode() should return true when GT_AGENT_MODE=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("GT_AGENT_MODE", "0")
|
||||||
|
if IsAgentMode() {
|
||||||
|
t.Error("IsAgentMode() should return false when GT_AGENT_MODE=0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAgentMode_CLAUDE_CODE(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldClaudeCode := os.Getenv("CLAUDE_CODE")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldClaudeCode != "" {
|
||||||
|
os.Setenv("CLAUDE_CODE", oldClaudeCode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_CODE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("CLAUDE_CODE", "1")
|
||||||
|
if !IsAgentMode() {
|
||||||
|
t.Error("IsAgentMode() should return true when CLAUDE_CODE is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAgentMode_CLAUDE_CODE_AnyValue(t *testing.T) {
|
||||||
|
oldAgentMode := os.Getenv("GT_AGENT_MODE")
|
||||||
|
oldClaudeCode := os.Getenv("CLAUDE_CODE")
|
||||||
|
defer func() {
|
||||||
|
if oldAgentMode != "" {
|
||||||
|
os.Setenv("GT_AGENT_MODE", oldAgentMode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
}
|
||||||
|
if oldClaudeCode != "" {
|
||||||
|
os.Setenv("CLAUDE_CODE", oldClaudeCode)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("CLAUDE_CODE")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Unsetenv("GT_AGENT_MODE")
|
||||||
|
os.Setenv("CLAUDE_CODE", "any-value")
|
||||||
|
if !IsAgentMode() {
|
||||||
|
t.Error("IsAgentMode() should return true when CLAUDE_CODE is set to any value")
|
||||||
|
}
|
||||||
|
}
|
||||||
196
internal/wisp/io_test.go
Normal file
196
internal/wisp/io_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package wisp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Test creating directory in existing root
|
||||||
|
dir, err := EnsureDir(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedDir := filepath.Join(tmpDir, WispDir)
|
||||||
|
if dir != expectedDir {
|
||||||
|
t.Errorf("EnsureDir() = %q, want %q", dir, expectedDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat directory: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Error("EnsureDir() should create a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test calling again (should be idempotent)
|
||||||
|
dir2, err := EnsureDir(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDir() second call error = %v", err)
|
||||||
|
}
|
||||||
|
if dir2 != dir {
|
||||||
|
t.Errorf("EnsureDir() second call = %q, want %q", dir2, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDir_Permissions(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
dir, err := EnsureDir(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to stat directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check directory permissions are 0755
|
||||||
|
expectedPerm := os.FileMode(0755)
|
||||||
|
if info.Mode().Perm() != expectedPerm {
|
||||||
|
t.Errorf("Directory permissions = %v, want %v", info.Mode().Perm(), expectedPerm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWispPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
filename string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic path",
|
||||||
|
root: "/path/to/root",
|
||||||
|
filename: "bead.json",
|
||||||
|
want: "/path/to/root/.beads/bead.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested filename",
|
||||||
|
root: "/path/to/root",
|
||||||
|
filename: "subdir/bead.json",
|
||||||
|
want: "/path/to/root/.beads/subdir/bead.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty filename",
|
||||||
|
root: "/path/to/root",
|
||||||
|
filename: "",
|
||||||
|
want: "/path/to/root/.beads",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := WispPath(tt.root, tt.filename)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("WispPath() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWispPath_WithWispDir(t *testing.T) {
|
||||||
|
// Verify WispPath uses WispDir constant
|
||||||
|
root := "/test/root"
|
||||||
|
filename := "test.json"
|
||||||
|
|
||||||
|
expected := filepath.Join(root, WispDir, filename)
|
||||||
|
got := WispPath(root, filename)
|
||||||
|
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("WispPath() = %q, want %q (using WispDir=%q)", got, expected, WispDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteJSON_Helper(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testPath := filepath.Join(tmpDir, "test.json")
|
||||||
|
|
||||||
|
type TestData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
data := TestData{
|
||||||
|
Name: "test",
|
||||||
|
Value: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON is unexported, so we test it indirectly through other functions
|
||||||
|
// or we can test the behavior through EnsureDir which uses similar patterns
|
||||||
|
|
||||||
|
// For now, test that we can write JSON using AtomicWriteJSON from util package
|
||||||
|
// which is what wisp would typically use
|
||||||
|
err := writeJSON(testPath, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writeJSON() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
content, err := os.ReadFile(testPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON is valid
|
||||||
|
var decoded TestData
|
||||||
|
if err := json.Unmarshal(content, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Name != data.Name {
|
||||||
|
t.Errorf("Name = %q, want %q", decoded.Name, data.Name)
|
||||||
|
}
|
||||||
|
if decoded.Value != data.Value {
|
||||||
|
t.Errorf("Value = %d, want %d", decoded.Value, data.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify temp file was cleaned up
|
||||||
|
tmpPath := testPath + ".tmp"
|
||||||
|
if _, err := os.Stat(tmpPath); err == nil {
|
||||||
|
t.Error("Temp file should be removed after successful write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteJSON_Overwrite(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testPath := filepath.Join(tmpDir, "test.json")
|
||||||
|
|
||||||
|
// Write initial data
|
||||||
|
initialData := map[string]string{"key": "initial"}
|
||||||
|
if err := writeJSON(testPath, initialData); err != nil {
|
||||||
|
t.Fatalf("writeJSON() initial error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite with new data
|
||||||
|
newData := map[string]string{"key": "updated", "new": "value"}
|
||||||
|
if err := writeJSON(testPath, newData); err != nil {
|
||||||
|
t.Fatalf("writeJSON() overwrite error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data was updated
|
||||||
|
content, err := os.ReadFile(testPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]string
|
||||||
|
if err := json.Unmarshal(content, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded["key"] != "updated" {
|
||||||
|
t.Errorf("key = %q, want %q", decoded["key"], "updated")
|
||||||
|
}
|
||||||
|
if decoded["new"] != "value" {
|
||||||
|
t.Errorf("new = %q, want %q", decoded["new"], "value")
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/wisp/types_test.go
Normal file
25
internal/wisp/types_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package wisp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWispDir(t *testing.T) {
|
||||||
|
// Test that WispDir constant is defined correctly
|
||||||
|
expected := ".beads"
|
||||||
|
if WispDir != expected {
|
||||||
|
t.Errorf("WispDir = %q, want %q", WispDir, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWispDirNotEmpty(t *testing.T) {
|
||||||
|
// Test that WispDir is not empty
|
||||||
|
if WispDir == "" {
|
||||||
|
t.Error("WispDir should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWispDirStartsWithDot(t *testing.T) {
|
||||||
|
// Test that WispDir is a hidden directory (starts with dot)
|
||||||
|
if len(WispDir) == 0 || WispDir[0] != '.' {
|
||||||
|
t.Errorf("WispDir should start with '.' for hidden directory, got %q", WispDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
230
internal/witness/types_test.go
Normal file
230
internal/witness/types_test.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package witness
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStateTypeAlias(t *testing.T) {
|
||||||
|
// Verify State is an alias for agent.State
|
||||||
|
var s State = agent.StateRunning
|
||||||
|
if s != agent.StateRunning {
|
||||||
|
t.Errorf("State type alias not working correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateConstants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
state State
|
||||||
|
parent agent.State
|
||||||
|
}{
|
||||||
|
{"StateStopped", StateStopped, agent.StateStopped},
|
||||||
|
{"StateRunning", StateRunning, agent.StateRunning},
|
||||||
|
{"StatePaused", StatePaused, agent.StatePaused},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.state != tt.parent {
|
||||||
|
t.Errorf("State constant %s = %v, want %v", tt.name, tt.state, tt.parent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitness_ZeroValues(t *testing.T) {
|
||||||
|
var w Witness
|
||||||
|
|
||||||
|
if w.RigName != "" {
|
||||||
|
t.Errorf("zero value Witness.RigName should be empty, got %q", w.RigName)
|
||||||
|
}
|
||||||
|
if w.State != "" {
|
||||||
|
t.Errorf("zero value Witness.State should be empty, got %q", w.State)
|
||||||
|
}
|
||||||
|
if w.PID != 0 {
|
||||||
|
t.Errorf("zero value Witness.PID should be 0, got %d", w.PID)
|
||||||
|
}
|
||||||
|
if w.StartedAt != nil {
|
||||||
|
t.Error("zero value Witness.StartedAt should be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitness_JSONMarshaling(t *testing.T) {
|
||||||
|
now := time.Now().Round(time.Second)
|
||||||
|
w := Witness{
|
||||||
|
RigName: "gastown",
|
||||||
|
State: StateRunning,
|
||||||
|
PID: 12345,
|
||||||
|
StartedAt: &now,
|
||||||
|
MonitoredPolecats: []string{"keeper", "valkyrie"},
|
||||||
|
Config: WitnessConfig{
|
||||||
|
MaxWorkers: 4,
|
||||||
|
SpawnDelayMs: 5000,
|
||||||
|
AutoSpawn: true,
|
||||||
|
},
|
||||||
|
SpawnedIssues: []string{"hq-abc123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled Witness
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.RigName != w.RigName {
|
||||||
|
t.Errorf("After round-trip: RigName = %q, want %q", unmarshaled.RigName, w.RigName)
|
||||||
|
}
|
||||||
|
if unmarshaled.State != w.State {
|
||||||
|
t.Errorf("After round-trip: State = %v, want %v", unmarshaled.State, w.State)
|
||||||
|
}
|
||||||
|
if unmarshaled.PID != w.PID {
|
||||||
|
t.Errorf("After round-trip: PID = %d, want %d", unmarshaled.PID, w.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitnessConfig_ZeroValues(t *testing.T) {
|
||||||
|
var cfg WitnessConfig
|
||||||
|
|
||||||
|
if cfg.MaxWorkers != 0 {
|
||||||
|
t.Errorf("zero value WitnessConfig.MaxWorkers should be 0, got %d", cfg.MaxWorkers)
|
||||||
|
}
|
||||||
|
if cfg.SpawnDelayMs != 0 {
|
||||||
|
t.Errorf("zero value WitnessConfig.SpawnDelayMs should be 0, got %d", cfg.SpawnDelayMs)
|
||||||
|
}
|
||||||
|
if cfg.AutoSpawn {
|
||||||
|
t.Error("zero value WitnessConfig.AutoSpawn should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitnessConfig_JSONMarshaling(t *testing.T) {
|
||||||
|
cfg := WitnessConfig{
|
||||||
|
MaxWorkers: 8,
|
||||||
|
SpawnDelayMs: 10000,
|
||||||
|
AutoSpawn: false,
|
||||||
|
EpicID: "epic-123",
|
||||||
|
IssuePrefix: "gt-",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled WitnessConfig
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.MaxWorkers != cfg.MaxWorkers {
|
||||||
|
t.Errorf("After round-trip: MaxWorkers = %d, want %d", unmarshaled.MaxWorkers, cfg.MaxWorkers)
|
||||||
|
}
|
||||||
|
if unmarshaled.SpawnDelayMs != cfg.SpawnDelayMs {
|
||||||
|
t.Errorf("After round-trip: SpawnDelayMs = %d, want %d", unmarshaled.SpawnDelayMs, cfg.SpawnDelayMs)
|
||||||
|
}
|
||||||
|
if unmarshaled.AutoSpawn != cfg.AutoSpawn {
|
||||||
|
t.Errorf("After round-trip: AutoSpawn = %v, want %v", unmarshaled.AutoSpawn, cfg.AutoSpawn)
|
||||||
|
}
|
||||||
|
if unmarshaled.EpicID != cfg.EpicID {
|
||||||
|
t.Errorf("After round-trip: EpicID = %q, want %q", unmarshaled.EpicID, cfg.EpicID)
|
||||||
|
}
|
||||||
|
if unmarshaled.IssuePrefix != cfg.IssuePrefix {
|
||||||
|
t.Errorf("After round-trip: IssuePrefix = %q, want %q", unmarshaled.IssuePrefix, cfg.IssuePrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitnessConfig_OmitEmpty(t *testing.T) {
|
||||||
|
cfg := WitnessConfig{
|
||||||
|
MaxWorkers: 4,
|
||||||
|
SpawnDelayMs: 5000,
|
||||||
|
AutoSpawn: true,
|
||||||
|
// EpicID and IssuePrefix left empty
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() to map error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty fields should be omitted
|
||||||
|
if _, exists := raw["epic_id"]; exists {
|
||||||
|
t.Error("Field 'epic_id' should be omitted when empty")
|
||||||
|
}
|
||||||
|
if _, exists := raw["issue_prefix"]; exists {
|
||||||
|
t.Error("Field 'issue_prefix' should be omitted when empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields should be present
|
||||||
|
requiredFields := []string{"max_workers", "spawn_delay_ms", "auto_spawn"}
|
||||||
|
for _, field := range requiredFields {
|
||||||
|
if _, exists := raw[field]; !exists {
|
||||||
|
t.Errorf("Required field '%s' should be present", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitness_OmitEmpty(t *testing.T) {
|
||||||
|
w := Witness{
|
||||||
|
RigName: "gastown",
|
||||||
|
State: StateRunning,
|
||||||
|
// PID, StartedAt, MonitoredPolecats, SpawnedIssues left empty/nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() to map error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty optional fields should be omitted
|
||||||
|
if _, exists := raw["pid"]; exists {
|
||||||
|
t.Error("Field 'pid' should be omitted when zero")
|
||||||
|
}
|
||||||
|
if _, exists := raw["started_at"]; exists {
|
||||||
|
t.Error("Field 'started_at' should be omitted when nil")
|
||||||
|
}
|
||||||
|
if _, exists := raw["monitored_polecats"]; exists {
|
||||||
|
t.Error("Field 'monitored_polecats' should be omitted when nil/empty")
|
||||||
|
}
|
||||||
|
if _, exists := raw["spawned_issues"]; exists {
|
||||||
|
t.Error("Field 'spawned_issues' should be omitted when nil/empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWitness_WithMonitoredPolecats(t *testing.T) {
|
||||||
|
w := Witness{
|
||||||
|
RigName: "gastown",
|
||||||
|
State: StateRunning,
|
||||||
|
MonitoredPolecats: []string{"keeper", "valkyrie", "nux"},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(w)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled Witness
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unmarshaled.MonitoredPolecats) != 3 {
|
||||||
|
t.Errorf("After round-trip: MonitoredPolecats length = %d, want 3", len(unmarshaled.MonitoredPolecats))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user