Add comprehensive tests for internal/dog package (#737)

This commit is contained in:
Louis Vranderick
2026-01-21 22:31:57 -05:00
committed by GitHub
parent 0cdcd0a20b
commit 7e5c3dd695
2 changed files with 1555 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
package dog
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/gastown/internal/config"
)
// =============================================================================
// Integration Test Helpers
// =============================================================================
// skipIfNoGit skips the test if git is not available.
func skipIfNoGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available, skipping integration test")
}
}
// testTownWithGitRigs creates a complete test town with git repositories.
// Sets up bare repos and mayor worktrees to simulate real Gas Town structure.
func testTownWithGitRigs(t *testing.T) (*Manager, string) {
t.Helper()
skipIfNoGit(t)
tmpDir := t.TempDir()
// Create rigs config
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: map[string]config.RigEntry{
"testrig": {GitURL: "local://testrig"},
},
}
// Set up rig structure with bare repo
rigPath := filepath.Join(tmpDir, "testrig")
bareRepoPath := filepath.Join(rigPath, ".repo.git")
// Initialize bare repo
if err := os.MkdirAll(bareRepoPath, 0755); err != nil {
t.Fatalf("Failed to create bare repo dir: %v", err)
}
// git init --bare
cmd := exec.Command("git", "init", "--bare", bareRepoPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to init bare repo: %v\n%s", err, out)
}
// Create mayor/rig worktree with initial commit
mayorPath := filepath.Join(rigPath, "mayor", "rig")
if err := os.MkdirAll(mayorPath, 0755); err != nil {
t.Fatalf("Failed to create mayor dir: %v", err)
}
// Initialize mayor/rig as a regular repo
cmd = exec.Command("git", "init", mayorPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to init mayor repo: %v\n%s", err, out)
}
// Configure git user for commits
cmd = exec.Command("git", "-C", mayorPath, "config", "user.email", "test@test.com")
cmd.Run()
cmd = exec.Command("git", "-C", mayorPath, "config", "user.name", "Test")
cmd.Run()
// Create initial commit in mayor/rig
readmePath := filepath.Join(mayorPath, "README.md")
if err := os.WriteFile(readmePath, []byte("# Test Rig\n"), 0644); err != nil {
t.Fatalf("Failed to write README: %v", err)
}
cmd = exec.Command("git", "-C", mayorPath, "add", ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to git add: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", mayorPath, "commit", "-m", "Initial commit")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to git commit: %v\n%s", err, out)
}
// Set up bare repo with proper remote configuration
// Add mayor/rig as a remote to bare repo and fetch
cmd = exec.Command("git", "-C", bareRepoPath, "remote", "add", "origin", mayorPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to add remote: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", bareRepoPath, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to fetch: %v\n%s", err, out)
}
// Create refs/remotes/origin/main so worktrees can use origin/main
cmd = exec.Command("git", "-C", bareRepoPath, "branch", "main", "FETCH_HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to create main branch: %v\n%s", err, out)
}
cmd = exec.Command("git", "-C", bareRepoPath, "update-ref", "refs/remotes/origin/main", "FETCH_HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("Failed to update origin/main ref: %v\n%s", err, out)
}
m := NewManager(tmpDir, rigsConfig)
return m, tmpDir
}
// =============================================================================
// Add (Spawn) Integration Tests
// =============================================================================
func TestManager_Add_Integration_CreatesWorktrees(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
dog, err := m.Add("alpha")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Verify dog was created
if dog.Name != "alpha" {
t.Errorf("Dog.Name = %q, want 'alpha'", dog.Name)
}
if dog.State != StateIdle {
t.Errorf("Dog.State = %q, want StateIdle", dog.State)
}
// Verify dog directory exists
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "alpha")
if _, err := os.Stat(dogPath); os.IsNotExist(err) {
t.Error("Dog directory was not created")
}
// Verify state file exists
statePath := filepath.Join(dogPath, ".dog.json")
if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Error("State file was not created")
}
// Verify worktree was created for testrig
if worktreePath, ok := dog.Worktrees["testrig"]; ok {
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Errorf("Worktree path %s does not exist", worktreePath)
}
// Verify it's a valid git worktree
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("Worktree is not a valid git repo: %v", err)
}
} else {
t.Error("Worktrees map missing 'testrig' entry")
}
// Verify timestamps are set
if dog.CreatedAt.IsZero() {
t.Error("CreatedAt was not set")
}
if dog.LastActive.IsZero() {
t.Error("LastActive was not set")
}
}
func TestManager_Add_Integration_SetsUpBranch(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("bravo")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Verify branch was created with correct naming pattern
worktreePath := dog.Worktrees["testrig"]
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to get branch name: %v", err)
}
branch := string(out)
// Branch should be dog/<name>-<rig>-<timestamp>
if len(branch) < 10 || branch[:4] != "dog/" {
t.Errorf("Branch name %q doesn't match expected pattern dog/<name>-<rig>-<timestamp>", branch)
}
}
func TestManager_Add_Integration_CanAddMultipleDogs(t *testing.T) {
m, _ := testTownWithGitRigs(t)
names := []string{"alpha", "beta", "gamma"}
dogs := make([]*Dog, 0, len(names))
for _, name := range names {
dog, err := m.Add(name)
if err != nil {
t.Fatalf("Add(%q) error = %v", name, err)
}
dogs = append(dogs, dog)
}
// Verify all dogs exist
listed, err := m.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(listed) != len(names) {
t.Errorf("List() returned %d dogs, want %d", len(listed), len(names))
}
// Verify each dog has unique worktree paths
paths := make(map[string]string)
for _, dog := range dogs {
for rig, path := range dog.Worktrees {
key := rig + ":" + path
if existing, ok := paths[key]; ok {
t.Errorf("Duplicate worktree path: %s used by both %s and %s", path, existing, dog.Name)
}
paths[key] = dog.Name
}
}
}
// =============================================================================
// Remove (Kill) Integration Tests
// =============================================================================
func TestManager_Remove_Integration_CleansUpWorktrees(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
// First add a dog
dog, err := m.Add("doomed")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "doomed")
worktreePath := dog.Worktrees["testrig"]
// Verify dog and worktree exist
if _, err := os.Stat(dogPath); os.IsNotExist(err) {
t.Fatal("Dog directory should exist before Remove")
}
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
t.Fatal("Worktree should exist before Remove")
}
// Remove the dog
if err := m.Remove("doomed"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
// Verify dog directory was cleaned up
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should not exist after Remove")
}
// Verify worktree was removed (directory gone or not a git repo)
if _, err := os.Stat(worktreePath); err == nil {
// Directory still exists - check if it's still a git repo
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err == nil {
t.Error("Worktree should be removed or invalid after Remove")
}
}
}
func TestManager_Remove_Integration_DoesNotAffectOtherDogs(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// Add two dogs
dog1, err := m.Add("survivor")
if err != nil {
t.Fatalf("Add(survivor) error = %v", err)
}
_, err = m.Add("victim")
if err != nil {
t.Fatalf("Add(victim) error = %v", err)
}
// Remove victim
if err := m.Remove("victim"); err != nil {
t.Fatalf("Remove(victim) error = %v", err)
}
// Verify survivor still works
survivor, err := m.Get("survivor")
if err != nil {
t.Fatalf("Get(survivor) error = %v after removing victim", err)
}
if survivor.Name != "survivor" {
t.Errorf("Survivor name changed to %q", survivor.Name)
}
// Verify survivor's worktree still works
worktreePath := dog1.Worktrees["testrig"]
cmd := exec.Command("git", "-C", worktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("Survivor's worktree is broken after removing another dog: %v", err)
}
}
// =============================================================================
// Full Lifecycle Integration Tests
// =============================================================================
func TestManager_Integration_FullLifecycle(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// 1. Add (spawn)
dog, err := m.Add("lifecycle")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
if dog.State != StateIdle {
t.Errorf("Initial state = %q, want StateIdle", dog.State)
}
// 2. Assign work (working state)
if err := m.AssignWork("lifecycle", "task-123"); err != nil {
t.Fatalf("AssignWork() error = %v", err)
}
dog, _ = m.Get("lifecycle")
if dog.State != StateWorking {
t.Errorf("After AssignWork: state = %q, want StateWorking", dog.State)
}
if dog.Work != "task-123" {
t.Errorf("After AssignWork: work = %q, want 'task-123'", dog.Work)
}
// 3. Clear work (back to idle)
if err := m.ClearWork("lifecycle"); err != nil {
t.Fatalf("ClearWork() error = %v", err)
}
dog, _ = m.Get("lifecycle")
if dog.State != StateIdle {
t.Errorf("After ClearWork: state = %q, want StateIdle", dog.State)
}
// 4. Remove (kill)
if err := m.Remove("lifecycle"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
_, err = m.Get("lifecycle")
if err != ErrDogNotFound {
t.Errorf("After Remove: Get() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_Integration_ConcurrentStateChanges(t *testing.T) {
m, _ := testTownWithGitRigs(t)
_, err := m.Add("concurrent")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Rapid state changes should not corrupt state
for i := 0; i < 10; i++ {
if err := m.AssignWork("concurrent", "task"); err != nil {
t.Fatalf("AssignWork iteration %d error = %v", i, err)
}
if err := m.ClearWork("concurrent"); err != nil {
t.Fatalf("ClearWork iteration %d error = %v", i, err)
}
}
// Final state should be consistent
dog, err := m.Get("concurrent")
if err != nil {
t.Fatalf("Final Get() error = %v", err)
}
if dog.State != StateIdle {
t.Errorf("Final state = %q, want StateIdle", dog.State)
}
if dog.Work != "" {
t.Errorf("Final work = %q, want empty", dog.Work)
}
}
// =============================================================================
// Refresh Integration Tests
// =============================================================================
func TestManager_Refresh_Integration_RecreatesWorktrees(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("refresh-test")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
oldWorktreePath := dog.Worktrees["testrig"]
oldBranch := ""
// Get old branch name
cmd := exec.Command("git", "-C", oldWorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if out, err := cmd.Output(); err == nil {
oldBranch = string(out)
}
// Small delay to ensure timestamp-based branch names differ
time.Sleep(10 * time.Millisecond)
// Refresh
if err := m.Refresh("refresh-test"); err != nil {
t.Fatalf("Refresh() error = %v", err)
}
// Get updated dog
dog, err = m.Get("refresh-test")
if err != nil {
t.Fatalf("Get() after Refresh error = %v", err)
}
newWorktreePath := dog.Worktrees["testrig"]
// Verify new worktree exists
if _, err := os.Stat(newWorktreePath); os.IsNotExist(err) {
t.Error("New worktree should exist after Refresh")
}
// Verify it's a valid git worktree
cmd = exec.Command("git", "-C", newWorktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("New worktree is not a valid git repo: %v", err)
}
// Branch should be different (new timestamp)
cmd = exec.Command("git", "-C", newWorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if out, err := cmd.Output(); err == nil {
newBranch := string(out)
if newBranch == oldBranch {
t.Log("Note: Branch names are the same (timestamps may have collided)")
}
}
}
// =============================================================================
// RefreshRig Integration Tests
// =============================================================================
func TestManager_RefreshRig_Integration_RecreatesSingleWorktree(t *testing.T) {
m, _ := testTownWithGitRigs(t)
dog, err := m.Add("refreshrig-test")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
oldWorktreePath := dog.Worktrees["testrig"]
// Small delay to ensure timestamp-based branch names differ
time.Sleep(10 * time.Millisecond)
// RefreshRig just for testrig
if err := m.RefreshRig("refreshrig-test", "testrig"); err != nil {
t.Fatalf("RefreshRig() error = %v", err)
}
// Get updated dog
dog, err = m.Get("refreshrig-test")
if err != nil {
t.Fatalf("Get() after RefreshRig error = %v", err)
}
newWorktreePath := dog.Worktrees["testrig"]
// Verify new worktree exists and is valid
if _, err := os.Stat(newWorktreePath); os.IsNotExist(err) {
t.Error("New worktree should exist after RefreshRig")
}
cmd := exec.Command("git", "-C", newWorktreePath, "status")
if err := cmd.Run(); err != nil {
t.Errorf("New worktree is not a valid git repo: %v", err)
}
// Verify old worktree path is either gone or no longer valid
if oldWorktreePath != newWorktreePath {
// Paths differ - old should be cleaned up
if _, err := os.Stat(oldWorktreePath); err == nil {
cmd = exec.Command("git", "-C", oldWorktreePath, "status")
if cmd.Run() == nil {
t.Log("Note: Old worktree still exists (may be same path with new branch)")
}
}
}
}
// =============================================================================
// CleanupStaleBranches Integration Tests
// =============================================================================
func TestManager_CleanupStaleBranches_Integration_NoStaleBranches(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// Add a dog (creates branches)
_, err := m.Add("active-dog")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Cleanup should find no stale branches (dog is still active)
deleted, err := m.CleanupStaleBranches()
if err != nil {
t.Fatalf("CleanupStaleBranches() error = %v", err)
}
if deleted != 0 {
t.Errorf("CleanupStaleBranches() deleted %d branches, want 0 (no stale)", deleted)
}
}
func TestManager_CleanupStaleBranches_Integration_EmptyKennel(t *testing.T) {
m, _ := testTownWithGitRigs(t)
// No dogs added - should be a no-op
deleted, err := m.CleanupStaleBranches()
if err != nil {
t.Fatalf("CleanupStaleBranches() error = %v", err)
}
if deleted != 0 {
t.Errorf("CleanupStaleBranches() deleted %d branches with empty kennel, want 0", deleted)
}
}
// =============================================================================
// Error Recovery Integration Tests
// =============================================================================
func TestManager_Integration_RecoveryFromPartialState(t *testing.T) {
m, tmpDir := testTownWithGitRigs(t)
// Add a dog normally
_, err := m.Add("partial")
if err != nil {
t.Fatalf("Add() error = %v", err)
}
// Manually corrupt the worktree (simulate crash during creation)
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "partial")
worktreePath := filepath.Join(dogPath, "testrig")
// Delete the worktree directory but keep state file
if err := os.RemoveAll(worktreePath); err != nil {
t.Fatalf("Failed to remove worktree: %v", err)
}
// Dog should still be retrievable
_, err = m.Get("partial")
if err != nil {
t.Fatalf("Get() error after corruption = %v", err)
}
// State management should still work
if err := m.SetState("partial", StateWorking); err != nil {
t.Errorf("SetState() error after corruption = %v", err)
}
// Remove should succeed and clean up remaining state
if err := m.Remove("partial"); err != nil {
t.Fatalf("Remove() error after corruption = %v", err)
}
// Verify complete cleanup
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should be fully removed")
}
}

View File

@@ -0,0 +1,980 @@
package dog
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/gastown/internal/config"
)
// =============================================================================
// Test Fixtures
// =============================================================================
// testManager creates a Manager with a temporary town root for testing.
func testManager(t *testing.T) (*Manager, string) {
t.Helper()
tmpDir := t.TempDir()
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: map[string]config.RigEntry{
"gastown": {GitURL: "git@github.com:test/gastown.git"},
"beads": {GitURL: "git@github.com:test/beads.git"},
},
}
m := NewManager(tmpDir, rigsConfig)
return m, tmpDir
}
// testManagerNoRigs creates a Manager with no rigs configured.
func testManagerNoRigs(t *testing.T) (*Manager, string) {
t.Helper()
tmpDir := t.TempDir()
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: map[string]config.RigEntry{},
}
m := NewManager(tmpDir, rigsConfig)
return m, tmpDir
}
// setupDogWithState creates a dog directory with a state file for testing.
// This bypasses Add() to test functions that don't require git worktrees.
func setupDogWithState(t *testing.T, m *Manager, name string, state *DogState) {
t.Helper()
dogPath := m.dogDir(name)
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
t.Fatalf("Failed to marshal state: %v", err)
}
statePath := m.stateFilePath(name)
if err := os.WriteFile(statePath, data, 0644); err != nil {
t.Fatalf("Failed to write state file: %v", err)
}
}
// =============================================================================
// Manager Creation Tests
// =============================================================================
func TestNewManager_PathConstruction(t *testing.T) {
rigsConfig := &config.RigsConfig{
Version: 1,
Rigs: map[string]config.RigEntry{
"testrig": {GitURL: "git@github.com:test/rig.git"},
},
}
tests := []struct {
name string
townRoot string
wantKennelPath string
}{
{
name: "standard path",
townRoot: "/home/user/gt",
wantKennelPath: "/home/user/gt/deacon/dogs",
},
{
name: "path with trailing slash",
townRoot: "/tmp/town/",
wantKennelPath: "/tmp/town/deacon/dogs",
},
{
name: "nested path",
townRoot: "/a/b/c/d/e",
wantKennelPath: "/a/b/c/d/e/deacon/dogs",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := NewManager(tt.townRoot, rigsConfig)
if m.townRoot != tt.townRoot {
t.Errorf("townRoot = %q, want %q", m.townRoot, tt.townRoot)
}
if m.kennelPath != tt.wantKennelPath {
t.Errorf("kennelPath = %q, want %q", m.kennelPath, tt.wantKennelPath)
}
if m.rigsConfig != rigsConfig {
t.Error("rigsConfig not properly stored")
}
})
}
}
// =============================================================================
// Dog Directory and Path Tests
// =============================================================================
func TestManager_dogDir(t *testing.T) {
m, _ := testManager(t)
tests := []struct {
name string
dogName string
wantPath string
}{
{"simple name", "alpha", filepath.Join(m.kennelPath, "alpha")},
{"hyphenated name", "my-dog", filepath.Join(m.kennelPath, "my-dog")},
{"numeric name", "dog123", filepath.Join(m.kennelPath, "dog123")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.dogDir(tt.dogName)
if got != tt.wantPath {
t.Errorf("dogDir(%q) = %q, want %q", tt.dogName, got, tt.wantPath)
}
})
}
}
func TestManager_stateFilePath(t *testing.T) {
m, _ := testManager(t)
dogName := "testdog"
want := filepath.Join(m.kennelPath, dogName, ".dog.json")
got := m.stateFilePath(dogName)
if got != want {
t.Errorf("stateFilePath(%q) = %q, want %q", dogName, got, want)
}
}
func TestManager_exists(t *testing.T) {
m, tmpDir := testManager(t)
// Create a dog directory manually
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "existing-dog")
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
tests := []struct {
name string
dogName string
want bool
}{
{"existing dog", "existing-dog", true},
{"non-existing dog", "ghost-dog", false},
// Note: empty name returns true because dogDir("") == kennelPath which exists
// This is an edge case that callers should avoid
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.exists(tt.dogName)
if got != tt.want {
t.Errorf("exists(%q) = %v, want %v", tt.dogName, got, tt.want)
}
})
}
}
// =============================================================================
// State Load/Save Tests
// =============================================================================
func TestManager_saveState_loadState_roundtrip(t *testing.T) {
m, tmpDir := testManager(t)
// Create dog directory
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "testdog")
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
now := time.Now().Round(time.Second)
originalState := &DogState{
Name: "testdog",
State: StateWorking,
LastActive: now,
Work: "hq-abc123",
Worktrees: map[string]string{
"gastown": "/path/to/gastown",
"beads": "/path/to/beads",
},
CreatedAt: now,
UpdatedAt: now,
}
// Save state
if err := m.saveState("testdog", originalState); err != nil {
t.Fatalf("saveState() error = %v", err)
}
// Verify file exists
statePath := m.stateFilePath("testdog")
if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Fatal("State file was not created")
}
// Load state back
loadedState, err := m.loadState("testdog")
if err != nil {
t.Fatalf("loadState() error = %v", err)
}
// Verify all fields
if loadedState.Name != originalState.Name {
t.Errorf("Name = %q, want %q", loadedState.Name, originalState.Name)
}
if loadedState.State != originalState.State {
t.Errorf("State = %q, want %q", loadedState.State, originalState.State)
}
if loadedState.Work != originalState.Work {
t.Errorf("Work = %q, want %q", loadedState.Work, originalState.Work)
}
if len(loadedState.Worktrees) != len(originalState.Worktrees) {
t.Errorf("Worktrees len = %d, want %d", len(loadedState.Worktrees), len(originalState.Worktrees))
}
}
func TestManager_loadState_nonExistent(t *testing.T) {
m, _ := testManager(t)
_, err := m.loadState("nonexistent")
if err == nil {
t.Error("loadState() expected error for non-existent dog")
}
}
func TestManager_loadState_invalidJSON(t *testing.T) {
m, tmpDir := testManager(t)
// Create dog directory with invalid JSON
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "baddog")
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
statePath := filepath.Join(dogPath, ".dog.json")
if err := os.WriteFile(statePath, []byte("invalid json {{{"), 0644); err != nil {
t.Fatalf("Failed to write invalid state: %v", err)
}
_, err := m.loadState("baddog")
if err == nil {
t.Error("loadState() expected error for invalid JSON")
}
}
// =============================================================================
// Get Dog Tests
// =============================================================================
func TestManager_Get_success(t *testing.T) {
m, _ := testManager(t)
now := time.Now().Round(time.Second)
state := &DogState{
Name: "alpha",
State: StateWorking,
LastActive: now,
Work: "test-work",
Worktrees: map[string]string{
"gastown": "/path/gastown",
},
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
dog, err := m.Get("alpha")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if dog.Name != "alpha" {
t.Errorf("Name = %q, want %q", dog.Name, "alpha")
}
if dog.State != StateWorking {
t.Errorf("State = %q, want %q", dog.State, StateWorking)
}
if dog.Work != "test-work" {
t.Errorf("Work = %q, want %q", dog.Work, "test-work")
}
if dog.Worktrees["gastown"] != "/path/gastown" {
t.Errorf("Worktrees[gastown] = %q, want %q", dog.Worktrees["gastown"], "/path/gastown")
}
}
func TestManager_Get_notFound(t *testing.T) {
m, _ := testManager(t)
_, err := m.Get("nonexistent")
if err != ErrDogNotFound {
t.Errorf("Get() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_Get_dirExistsButNoStateFile(t *testing.T) {
m, tmpDir := testManager(t)
// Create dog directory but no .dog.json (e.g., boot watchdog)
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "boot")
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
_, err := m.Get("boot")
if err != ErrDogNotFound {
t.Errorf("Get() error = %v, want ErrDogNotFound for dir without state file", err)
}
}
// =============================================================================
// List Dogs Tests
// =============================================================================
func TestManager_List_empty(t *testing.T) {
m, _ := testManager(t)
dogs, err := m.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(dogs) != 0 {
t.Errorf("List() returned %d dogs, want 0", len(dogs))
}
}
func TestManager_List_multipleDogs(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
// Create multiple dogs
for _, name := range []string{"alpha", "beta", "gamma"} {
state := &DogState{
Name: name,
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, name, state)
}
dogs, err := m.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(dogs) != 3 {
t.Errorf("List() returned %d dogs, want 3", len(dogs))
}
// Verify all dogs are present
names := make(map[string]bool)
for _, dog := range dogs {
names[dog.Name] = true
}
for _, expected := range []string{"alpha", "beta", "gamma"} {
if !names[expected] {
t.Errorf("List() missing dog %q", expected)
}
}
}
func TestManager_List_skipsInvalidDogs(t *testing.T) {
m, tmpDir := testManager(t)
now := time.Now()
// Create one valid dog
state := &DogState{
Name: "valid",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "valid", state)
// Create directory without state file (should be skipped)
invalidPath := filepath.Join(tmpDir, "deacon", "dogs", "invalid")
if err := os.MkdirAll(invalidPath, 0755); err != nil {
t.Fatalf("Failed to create invalid dir: %v", err)
}
dogs, err := m.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(dogs) != 1 {
t.Errorf("List() returned %d dogs, want 1", len(dogs))
}
if dogs[0].Name != "valid" {
t.Errorf("List() returned dog %q, want 'valid'", dogs[0].Name)
}
}
// =============================================================================
// SetState Tests
// =============================================================================
func TestManager_SetState_success(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Change state to working
if err := m.SetState("alpha", StateWorking); err != nil {
t.Fatalf("SetState() error = %v", err)
}
// Verify the state was updated
dog, err := m.Get("alpha")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if dog.State != StateWorking {
t.Errorf("State = %q, want %q", dog.State, StateWorking)
}
}
func TestManager_SetState_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.SetState("nonexistent", StateWorking)
if err != ErrDogNotFound {
t.Errorf("SetState() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_SetState_updatesTimestamp(t *testing.T) {
m, _ := testManager(t)
oldTime := time.Now().Add(-1 * time.Hour)
state := &DogState{
Name: "alpha",
State: StateIdle,
LastActive: oldTime,
CreatedAt: oldTime,
UpdatedAt: oldTime,
}
setupDogWithState(t, m, "alpha", state)
beforeUpdate := time.Now()
time.Sleep(10 * time.Millisecond) // Ensure time difference
if err := m.SetState("alpha", StateWorking); err != nil {
t.Fatalf("SetState() error = %v", err)
}
dog, _ := m.Get("alpha")
if !dog.LastActive.After(beforeUpdate) {
t.Errorf("LastActive was not updated")
}
}
// =============================================================================
// AssignWork Tests
// =============================================================================
func TestManager_AssignWork_success(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Assign work
if err := m.AssignWork("alpha", "hq-xyz789"); err != nil {
t.Fatalf("AssignWork() error = %v", err)
}
// Verify work assignment
dog, err := m.Get("alpha")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if dog.State != StateWorking {
t.Errorf("State = %q, want %q after AssignWork", dog.State, StateWorking)
}
if dog.Work != "hq-xyz789" {
t.Errorf("Work = %q, want %q", dog.Work, "hq-xyz789")
}
}
func TestManager_AssignWork_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.AssignWork("nonexistent", "some-work")
if err != ErrDogNotFound {
t.Errorf("AssignWork() error = %v, want ErrDogNotFound", err)
}
}
// =============================================================================
// ClearWork Tests
// =============================================================================
func TestManager_ClearWork_success(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateWorking,
Work: "existing-work",
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Clear work
if err := m.ClearWork("alpha"); err != nil {
t.Fatalf("ClearWork() error = %v", err)
}
// Verify work was cleared
dog, err := m.Get("alpha")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if dog.State != StateIdle {
t.Errorf("State = %q, want %q after ClearWork", dog.State, StateIdle)
}
if dog.Work != "" {
t.Errorf("Work = %q, want empty after ClearWork", dog.Work)
}
}
func TestManager_ClearWork_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.ClearWork("nonexistent")
if err != ErrDogNotFound {
t.Errorf("ClearWork() error = %v, want ErrDogNotFound", err)
}
}
// =============================================================================
// GetIdleDog Tests
// =============================================================================
func TestManager_GetIdleDog_noDogsReturnsNil(t *testing.T) {
m, _ := testManager(t)
dog, err := m.GetIdleDog()
if err != nil {
t.Fatalf("GetIdleDog() error = %v", err)
}
if dog != nil {
t.Errorf("GetIdleDog() = %v, want nil when no dogs", dog)
}
}
func TestManager_GetIdleDog_allWorkingReturnsNil(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
for _, name := range []string{"alpha", "beta"} {
state := &DogState{
Name: name,
State: StateWorking,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, name, state)
}
dog, err := m.GetIdleDog()
if err != nil {
t.Fatalf("GetIdleDog() error = %v", err)
}
if dog != nil {
t.Errorf("GetIdleDog() = %v, want nil when all dogs working", dog)
}
}
func TestManager_GetIdleDog_findsIdleDog(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
// Create working dog
workingState := &DogState{
Name: "worker",
State: StateWorking,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "worker", workingState)
// Create idle dog
idleState := &DogState{
Name: "idler",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "idler", idleState)
dog, err := m.GetIdleDog()
if err != nil {
t.Fatalf("GetIdleDog() error = %v", err)
}
if dog == nil {
t.Fatal("GetIdleDog() returned nil, want idle dog")
}
if dog.Name != "idler" {
t.Errorf("GetIdleDog().Name = %q, want 'idler'", dog.Name)
}
}
// =============================================================================
// IdleCount and WorkingCount Tests
// =============================================================================
func TestManager_IdleCount(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
// 2 idle, 1 working
for i, name := range []string{"idle1", "idle2", "working1"} {
state := StateIdle
if i == 2 {
state = StateWorking
}
dogState := &DogState{
Name: name,
State: state,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, name, dogState)
}
count, err := m.IdleCount()
if err != nil {
t.Fatalf("IdleCount() error = %v", err)
}
if count != 2 {
t.Errorf("IdleCount() = %d, want 2", count)
}
}
func TestManager_WorkingCount(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
// 1 idle, 2 working
for i, name := range []string{"idle1", "working1", "working2"} {
state := StateIdle
if i > 0 {
state = StateWorking
}
dogState := &DogState{
Name: name,
State: state,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, name, dogState)
}
count, err := m.WorkingCount()
if err != nil {
t.Fatalf("WorkingCount() error = %v", err)
}
if count != 2 {
t.Errorf("WorkingCount() = %d, want 2", count)
}
}
// =============================================================================
// Add Dog Tests (Spawn Behavior)
// =============================================================================
func TestManager_Add_noRigsReturnsError(t *testing.T) {
m, _ := testManagerNoRigs(t)
_, err := m.Add("alpha")
if err != ErrNoRigs {
t.Errorf("Add() error = %v, want ErrNoRigs", err)
}
}
func TestManager_Add_duplicateReturnsError(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "existing",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "existing", state)
_, err := m.Add("existing")
if err != ErrDogExists {
t.Errorf("Add() error = %v, want ErrDogExists", err)
}
}
// Note: Full Add() testing requires git repos. This tests error conditions only.
// Integration tests with real git repos should be in a separate _integration_test.go file.
// =============================================================================
// Remove Dog Tests (Kill Behavior)
// =============================================================================
func TestManager_Remove_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.Remove("nonexistent")
if err != ErrDogNotFound {
t.Errorf("Remove() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_Remove_cleansUpDirectory(t *testing.T) {
m, tmpDir := testManager(t)
// Create a dog with minimal state (no worktrees to clean up)
now := time.Now()
state := &DogState{
Name: "doomed",
State: StateIdle,
LastActive: now,
Worktrees: map[string]string{}, // Empty - no git cleanup needed
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "doomed", state)
// Verify dog exists
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "doomed")
if _, err := os.Stat(dogPath); os.IsNotExist(err) {
t.Fatal("Dog directory should exist before Remove")
}
// Remove the dog
if err := m.Remove("doomed"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
// Verify directory was cleaned up
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should not exist after Remove")
}
}
func TestManager_Remove_handlesMissingStateFile(t *testing.T) {
m, tmpDir := testManager(t)
// Create dog directory but no state file
dogPath := filepath.Join(tmpDir, "deacon", "dogs", "orphan")
if err := os.MkdirAll(dogPath, 0755); err != nil {
t.Fatalf("Failed to create dog dir: %v", err)
}
// Remove should still clean up the directory even without state file
if err := m.Remove("orphan"); err != nil {
t.Fatalf("Remove() error = %v", err)
}
// Verify directory was cleaned up
if _, err := os.Stat(dogPath); !os.IsNotExist(err) {
t.Error("Dog directory should not exist after Remove")
}
}
// =============================================================================
// Refresh Tests (requires git, minimal unit testing)
// =============================================================================
func TestManager_Refresh_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.Refresh("nonexistent")
if err != ErrDogNotFound {
t.Errorf("Refresh() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_RefreshRig_notFound(t *testing.T) {
m, _ := testManager(t)
err := m.RefreshRig("nonexistent", "gastown")
if err != ErrDogNotFound {
t.Errorf("RefreshRig() error = %v, want ErrDogNotFound", err)
}
}
func TestManager_RefreshRig_unknownRig(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
err := m.RefreshRig("alpha", "unknownrig")
if err == nil {
t.Error("RefreshRig() expected error for unknown rig")
}
}
// =============================================================================
// State Transition Tests (Behavioral)
// =============================================================================
func TestManager_StateTransition_IdleToWorking(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateIdle,
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Assign work should transition to Working
if err := m.AssignWork("alpha", "task-1"); err != nil {
t.Fatalf("AssignWork() error = %v", err)
}
dog, _ := m.Get("alpha")
if dog.State != StateWorking {
t.Errorf("After AssignWork: State = %q, want Working", dog.State)
}
}
func TestManager_StateTransition_WorkingToIdle(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateWorking,
Work: "task-1",
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Clear work should transition to Idle
if err := m.ClearWork("alpha"); err != nil {
t.Fatalf("ClearWork() error = %v", err)
}
dog, _ := m.Get("alpha")
if dog.State != StateIdle {
t.Errorf("After ClearWork: State = %q, want Idle", dog.State)
}
if dog.Work != "" {
t.Errorf("After ClearWork: Work = %q, want empty", dog.Work)
}
}
func TestManager_StateTransition_WorkReassignment(t *testing.T) {
m, _ := testManager(t)
now := time.Now()
state := &DogState{
Name: "alpha",
State: StateWorking,
Work: "task-1",
LastActive: now,
CreatedAt: now,
UpdatedAt: now,
}
setupDogWithState(t, m, "alpha", state)
// Can reassign work while already working
if err := m.AssignWork("alpha", "task-2"); err != nil {
t.Fatalf("AssignWork() error = %v", err)
}
dog, _ := m.Get("alpha")
if dog.Work != "task-2" {
t.Errorf("After reassignment: Work = %q, want 'task-2'", dog.Work)
}
}
// =============================================================================
// Error Constant Tests
// =============================================================================
func TestErrors_AreDistinct(t *testing.T) {
errors := []error{ErrDogExists, ErrDogNotFound, ErrNoRigs}
errorStrings := make(map[string]bool)
for _, err := range errors {
s := err.Error()
if errorStrings[s] {
t.Errorf("Duplicate error string: %q", s)
}
errorStrings[s] = true
}
}
func TestErrors_Messages(t *testing.T) {
tests := []struct {
err error
want string
}{
{ErrDogExists, "dog already exists"},
{ErrDogNotFound, "dog not found"},
{ErrNoRigs, "no rigs configured"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := tt.err.Error(); got != tt.want {
t.Errorf("Error() = %q, want %q", got, tt.want)
}
})
}
}