Files
gastown/internal/dog/manager_lifecycle_test.go
Erik LaBianca 14435cacad fix: update test assertions and set BEADS_DIR in EnsureCustomTypes (#853)
* fix: update test assertions and set BEADS_DIR in EnsureCustomTypes

- Update TestBuildAgentStartupCommand to check for 'exec env' instead
  of 'export' (matches current BuildStartupCommand implementation)
- Add 'config' command handling to fake bd script in manager_test.go
- Set BEADS_DIR env var when running bd config in EnsureCustomTypes
  to ensure bd operates on the correct database during agent bead creation
- Apply gofmt formatting

These fixes address pre-existing test failures on main.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: inject mock in TestRoleLabelCheck_NoBeadsDir for Windows CI

The test was failing on Windows CI because bd is not installed,
causing exec.LookPath("bd") to fail and return "beads not installed"
before checking for the .beads directory.

Inject an empty mock beadShower to skip the LookPath check, allowing
the test to properly verify the "No beads database" path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: regenerate formulas and fix unused parameter lint error

- Regenerate mol-witness-patrol.formula.toml to sync with source
- Mark unused hookName parameter with _ in installHookTo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): make Windows CI tests pass

- Skip symlink tests on Windows (require elevated privileges)
- Fix GT_ROOT assertion to handle Windows path escaping
- Use platform-appropriate paths in TestNewManager_PathConstruction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix tests for quoted env and OS paths

* fix(test): add Windows batch scripts to molecule lifecycle tests

The molecule_lifecycle_test.go tests were failing on Windows CI because
they used Unix shell scripts (#!/bin/sh) for mock bd commands, which
don't work on Windows.

This commit adds Windows batch file equivalents for all three tests:
- TestSlingFormulaOnBeadHooksBaseBead
- TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead
- TestDoneClosesAttachedMolecule

Uses the same pattern as writeBDStub() from sling_test.go for
cross-platform test mocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(test): add Windows batch scripts to more tests

Adds Windows batch script equivalents to tests that use mock bd commands:

molecule_lifecycle_test.go:
- TestSlingFormulaOnBeadHooksBaseBead
- TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead
- TestDoneClosesAttachedMolecule

sling_288_test.go:
- TestInstantiateFormulaOnBead
- TestInstantiateFormulaOnBeadSkipCook
- TestCookFormula
- TestFormulaOnBeadPassesVariables

These tests were failing on Windows CI because they used Unix shell
scripts (#!/bin/sh) which don't work on Windows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(test): skip TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead on Windows

The test's Windows batch script JSON output causes
storeAttachedMoleculeInBead to fail silently when parsing the bd show
response. This is a pre-existing limitation - the test was failing on
Windows before the batch scripts were added (shell scripts don't work
on Windows at all).

Skip this test on Windows until the underlying JSON parsing issue is
resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: re-trigger CI after GitHub Internal Server Error

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:43:21 -08:00

978 lines
24 KiB
Go

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
}{
{
name: "standard path",
townRoot: "/home/user/gt",
},
{
name: "path with trailing slash",
townRoot: "/tmp/town/",
},
{
name: "nested path",
townRoot: "/a/b/c/d/e",
},
}
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)
}
wantKennelPath := filepath.Join(tt.townRoot, "deacon", "dogs")
if m.kennelPath != wantKennelPath {
t.Errorf("kennelPath = %q, want %q", m.kennelPath, 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)
}
})
}
}