Files
gastown/internal/checkpoint/checkpoint_test.go
Bo 1f272ffc53 test: comprehensive test coverage for 5 packages (#351)
* test(util): add comprehensive tests for atomic write functions

Add tests for:
- File permissions
- Empty data handling
- Various JSON types (string, int, float, bool, null, array, nested)
- Unmarshallable types error handling
- Read-only directory permission errors
- Concurrent writes
- Original content preservation on failure
- Struct serialization/deserialization
- Large data (1MB)

* test(connection): add edge case tests for address parsing

Add comprehensive test coverage for ParseAddress edge cases:
- Empty/whitespace/slash-only inputs
- Leading/trailing slash handling
- Machine prefix edge cases (colons, empty machine)
- Multiple slashes in polecat name (SplitN behavior)
- Unicode and emoji support
- Very long addresses
- Special characters (hyphens, underscores, dots)
- Whitespace in components

Also adds tests for MustParseAddress panic behavior and RigPath method.

Closes: gt-xgjyp

* test(checkpoint): add comprehensive test coverage for checkpoint package

Tests all public functions: Read, Write, Remove, Capture, WithMolecule,
WithHookedBead, WithNotes, Age, IsStale, Summary, Path.

Edge cases covered: missing file, corrupted JSON, stale detection.

Closes: gt-09yn1

* test(lock): add comprehensive tests for lock package

Add lock_test.go with tests covering:
- LockInfo.IsStale() with valid/invalid PIDs
- Lock.Acquire/Release lifecycle
- Re-acquiring own lock (session refresh)
- Stale lock cleanup during Acquire
- Lock.Read() for missing/invalid/valid files
- Lock.Check() for unlocked/owned/stale scenarios
- Lock.Status() string formatting
- Lock.ForceRelease()
- processExists() helper
- FindAllLocks() directory scanning
- CleanStaleLocks() with mocked tmux
- getActiveTmuxSessions() parsing
- splitOnColon() and splitLines() helpers
- DetectCollisions() for stale/orphaned locks

Coverage: 84.4%

* test(keepalive): add example tests demonstrating usage patterns

Add ExampleTouchInWorkspace, ExampleRead, and ExampleState_Age to
serve as documentation for how to use the keepalive package.

* fix(test): correct boundary test timing race in checkpoint_test.go

The 'exactly threshold' test case was flaky due to timing: by the time
time.Since() runs after setting Timestamp, microseconds have passed,
making age > threshold. Changed expectation to true since at-threshold
is effectively stale.

---------

Co-authored-by: slit <gt@gastown.local>
2026-01-11 23:04:03 -08:00

399 lines
9.3 KiB
Go

package checkpoint
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestPath(t *testing.T) {
dir := "/some/polecat/dir"
got := Path(dir)
want := filepath.Join(dir, Filename)
if got != want {
t.Errorf("Path(%q) = %q, want %q", dir, got, want)
}
}
func TestReadWrite(t *testing.T) {
// Create temp directory
tmpDir := t.TempDir()
// Test reading non-existent checkpoint returns nil, nil
cp, err := Read(tmpDir)
if err != nil {
t.Fatalf("Read non-existent: unexpected error: %v", err)
}
if cp != nil {
t.Fatal("Read non-existent: expected nil checkpoint")
}
// Create and write a checkpoint
original := &Checkpoint{
MoleculeID: "mol-123",
CurrentStep: "step-1",
StepTitle: "Build the thing",
ModifiedFiles: []string{"file1.go", "file2.go"},
LastCommit: "abc123",
Branch: "feature/test",
HookedBead: "gt-xyz",
Notes: "Some notes",
}
if err := Write(tmpDir, original); err != nil {
t.Fatalf("Write: unexpected error: %v", err)
}
// Verify file exists
path := Path(tmpDir)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("Write: checkpoint file not created")
}
// Read it back
loaded, err := Read(tmpDir)
if err != nil {
t.Fatalf("Read: unexpected error: %v", err)
}
if loaded == nil {
t.Fatal("Read: expected non-nil checkpoint")
}
// Verify fields
if loaded.MoleculeID != original.MoleculeID {
t.Errorf("MoleculeID = %q, want %q", loaded.MoleculeID, original.MoleculeID)
}
if loaded.CurrentStep != original.CurrentStep {
t.Errorf("CurrentStep = %q, want %q", loaded.CurrentStep, original.CurrentStep)
}
if loaded.StepTitle != original.StepTitle {
t.Errorf("StepTitle = %q, want %q", loaded.StepTitle, original.StepTitle)
}
if loaded.Branch != original.Branch {
t.Errorf("Branch = %q, want %q", loaded.Branch, original.Branch)
}
if loaded.HookedBead != original.HookedBead {
t.Errorf("HookedBead = %q, want %q", loaded.HookedBead, original.HookedBead)
}
if loaded.Notes != original.Notes {
t.Errorf("Notes = %q, want %q", loaded.Notes, original.Notes)
}
if len(loaded.ModifiedFiles) != len(original.ModifiedFiles) {
t.Errorf("ModifiedFiles len = %d, want %d", len(loaded.ModifiedFiles), len(original.ModifiedFiles))
}
// Verify timestamp was set
if loaded.Timestamp.IsZero() {
t.Error("Timestamp should be set by Write")
}
// Verify SessionID was set
if loaded.SessionID == "" {
t.Error("SessionID should be set by Write")
}
}
func TestWritePreservesTimestamp(t *testing.T) {
tmpDir := t.TempDir()
// Create checkpoint with explicit timestamp
ts := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
cp := &Checkpoint{
Timestamp: ts,
Notes: "test",
}
if err := Write(tmpDir, cp); err != nil {
t.Fatalf("Write: %v", err)
}
loaded, err := Read(tmpDir)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !loaded.Timestamp.Equal(ts) {
t.Errorf("Timestamp = %v, want %v", loaded.Timestamp, ts)
}
}
func TestReadCorruptedJSON(t *testing.T) {
tmpDir := t.TempDir()
path := Path(tmpDir)
// Write invalid JSON
if err := os.WriteFile(path, []byte("not valid json{"), 0600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := Read(tmpDir)
if err == nil {
t.Fatal("Read corrupted JSON: expected error")
}
}
func TestRemove(t *testing.T) {
tmpDir := t.TempDir()
// Write a checkpoint
cp := &Checkpoint{Notes: "to be removed"}
if err := Write(tmpDir, cp); err != nil {
t.Fatalf("Write: %v", err)
}
// Verify it exists
path := Path(tmpDir)
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("checkpoint should exist before Remove")
}
// Remove it
if err := Remove(tmpDir); err != nil {
t.Fatalf("Remove: %v", err)
}
// Verify it's gone
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("checkpoint should not exist after Remove")
}
// Remove again should not error
if err := Remove(tmpDir); err != nil {
t.Fatalf("Remove non-existent: %v", err)
}
}
func TestCapture(t *testing.T) {
// Use current directory (should be a git repo)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
// Find git root
gitRoot := cwd
for {
if _, err := os.Stat(filepath.Join(gitRoot, ".git")); err == nil {
break
}
parent := filepath.Dir(gitRoot)
if parent == gitRoot {
t.Skip("not in a git repository")
}
gitRoot = parent
}
cp, err := Capture(gitRoot)
if err != nil {
t.Fatalf("Capture: %v", err)
}
// Should have timestamp
if cp.Timestamp.IsZero() {
t.Error("Timestamp should be set")
}
// Should have branch (we're in a git repo)
if cp.Branch == "" {
t.Error("Branch should be set in git repo")
}
// Should have last commit
if cp.LastCommit == "" {
t.Error("LastCommit should be set in git repo")
}
}
func TestWithMolecule(t *testing.T) {
cp := &Checkpoint{}
result := cp.WithMolecule("mol-abc", "step-1", "Do the thing")
if result != cp {
t.Error("WithMolecule should return same checkpoint")
}
if cp.MoleculeID != "mol-abc" {
t.Errorf("MoleculeID = %q, want %q", cp.MoleculeID, "mol-abc")
}
if cp.CurrentStep != "step-1" {
t.Errorf("CurrentStep = %q, want %q", cp.CurrentStep, "step-1")
}
if cp.StepTitle != "Do the thing" {
t.Errorf("StepTitle = %q, want %q", cp.StepTitle, "Do the thing")
}
}
func TestWithHookedBead(t *testing.T) {
cp := &Checkpoint{}
result := cp.WithHookedBead("gt-123")
if result != cp {
t.Error("WithHookedBead should return same checkpoint")
}
if cp.HookedBead != "gt-123" {
t.Errorf("HookedBead = %q, want %q", cp.HookedBead, "gt-123")
}
}
func TestWithNotes(t *testing.T) {
cp := &Checkpoint{}
result := cp.WithNotes("important context")
if result != cp {
t.Error("WithNotes should return same checkpoint")
}
if cp.Notes != "important context" {
t.Errorf("Notes = %q, want %q", cp.Notes, "important context")
}
}
func TestAge(t *testing.T) {
cp := &Checkpoint{
Timestamp: time.Now().Add(-5 * time.Minute),
}
age := cp.Age()
if age < 4*time.Minute || age > 6*time.Minute {
t.Errorf("Age = %v, expected ~5 minutes", age)
}
}
func TestIsStale(t *testing.T) {
tests := []struct {
name string
age time.Duration
threshold time.Duration
want bool
}{
{"fresh", 5 * time.Minute, 1 * time.Hour, false},
{"stale", 2 * time.Hour, 1 * time.Hour, true},
{"exactly threshold", 1 * time.Hour, 1 * time.Hour, true}, // timing race: by the time IsStale runs, age > threshold
{"just over threshold", 1*time.Hour + time.Second, 1 * time.Hour, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp := &Checkpoint{
Timestamp: time.Now().Add(-tt.age),
}
got := cp.IsStale(tt.threshold)
if got != tt.want {
t.Errorf("IsStale(%v) = %v, want %v", tt.threshold, got, tt.want)
}
})
}
}
func TestSummary(t *testing.T) {
tests := []struct {
name string
cp *Checkpoint
want string
}{
{
name: "empty",
cp: &Checkpoint{},
want: "no significant state",
},
{
name: "molecule only",
cp: &Checkpoint{MoleculeID: "mol-123"},
want: "molecule mol-123",
},
{
name: "molecule with step",
cp: &Checkpoint{MoleculeID: "mol-123", CurrentStep: "step-1"},
want: "molecule mol-123, step step-1",
},
{
name: "hooked bead",
cp: &Checkpoint{HookedBead: "gt-abc"},
want: "hooked: gt-abc",
},
{
name: "modified files",
cp: &Checkpoint{ModifiedFiles: []string{"a.go", "b.go"}},
want: "2 modified files",
},
{
name: "branch",
cp: &Checkpoint{Branch: "feature/test"},
want: "branch: feature/test",
},
{
name: "full",
cp: &Checkpoint{
MoleculeID: "mol-123",
CurrentStep: "step-1",
HookedBead: "gt-abc",
ModifiedFiles: []string{"a.go"},
Branch: "main",
},
want: "molecule mol-123, step step-1, hooked: gt-abc, 1 modified files, branch: main",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cp.Summary()
if got != tt.want {
t.Errorf("Summary() = %q, want %q", got, tt.want)
}
})
}
}
func TestCheckpointJSONRoundtrip(t *testing.T) {
original := &Checkpoint{
MoleculeID: "mol-test",
CurrentStep: "step-2",
StepTitle: "Testing JSON",
ModifiedFiles: []string{"x.go", "y.go", "z.go"},
LastCommit: "deadbeef",
Branch: "develop",
HookedBead: "gt-roundtrip",
Timestamp: time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC),
SessionID: "session-123",
Notes: "Testing round trip",
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var loaded Checkpoint
if err := json.Unmarshal(data, &loaded); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if loaded.MoleculeID != original.MoleculeID {
t.Errorf("MoleculeID mismatch")
}
if loaded.CurrentStep != original.CurrentStep {
t.Errorf("CurrentStep mismatch")
}
if loaded.StepTitle != original.StepTitle {
t.Errorf("StepTitle mismatch")
}
if loaded.Branch != original.Branch {
t.Errorf("Branch mismatch")
}
if loaded.HookedBead != original.HookedBead {
t.Errorf("HookedBead mismatch")
}
if loaded.SessionID != original.SessionID {
t.Errorf("SessionID mismatch")
}
if loaded.Notes != original.Notes {
t.Errorf("Notes mismatch")
}
if !loaded.Timestamp.Equal(original.Timestamp) {
t.Errorf("Timestamp mismatch")
}
if len(loaded.ModifiedFiles) != len(original.ModifiedFiles) {
t.Errorf("ModifiedFiles length mismatch")
}
}