feat: v0.2 tests for manifest capture, diff, ignore rules, and session log

11 new tests covering manifest capture/exclusion, roundtrip, diff logic, ignore rules (task.yaml, sessions.log, .ctask/), notes updated detection, session log formatting, append-only behavior, short session detection. Fix cross-platform ignore rule for logs/sessions.log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:01:31 -04:00
parent f5ca85a788
commit 69c487cf79
3 changed files with 349 additions and 1 deletions
+169
View File
@@ -0,0 +1,169 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{5 * time.Second, "5s"},
{65 * time.Second, "1m 5s"},
{3661 * time.Second, "1h 1m 1s"},
{0, "0s"},
}
for _, tt := range tests {
got := FormatDuration(tt.d)
if got != tt.want {
t.Errorf("FormatDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestFormatSessionEntry(t *testing.T) {
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
end := time.Date(2026, 4, 5, 15, 45, 10, 0, time.UTC)
info := &SessionInfo{
Agent: "claude",
Mode: "local",
StartTime: start,
EndTime: end,
Diff: &ManifestDiff{
Added: []string{"output/migration-plan.md", "output/schema.sql"},
Modified: []string{"notes.md", "CLAUDE.md"},
Deleted: nil,
},
}
entry := FormatSessionEntry(info)
// Check required fields
checks := []string{
"Session 2026-04-05 14:30:22",
"Agent: claude",
"Mode: local",
"Start: 2026-04-05 14:30:22",
"End: 2026-04-05 15:45:10",
"Duration: 1h 14m 48s",
"output/migration-plan.md",
"output/schema.sql",
"notes.md",
"CLAUDE.md",
"Notes updated: yes",
}
for _, check := range checks {
if !strings.Contains(entry, check) {
t.Errorf("entry missing %q", check)
}
}
// Deleted should show (none)
if !strings.Contains(entry, "Deleted:\n (none)") {
t.Error("expected '(none)' for empty deleted section")
}
}
func TestFormatSessionEntryShortNoChanges(t *testing.T) {
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
end := start.Add(2 * time.Second)
info := &SessionInfo{
Agent: "claude",
Mode: "local",
StartTime: start,
EndTime: end,
Diff: &ManifestDiff{},
}
entry := FormatSessionEntry(info)
if !strings.Contains(entry, "No changes detected") {
t.Error("short session with no changes should have 'No changes detected' note")
}
}
func TestAppendSessionLog(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "logs"), 0755)
start := time.Date(2026, 4, 5, 14, 30, 22, 0, time.UTC)
end := time.Date(2026, 4, 5, 15, 0, 0, 0, time.UTC)
info := &SessionInfo{
Agent: "claude",
Mode: "local",
StartTime: start,
EndTime: end,
Diff: &ManifestDiff{
Added: []string{"output/result.md"},
},
}
if err := AppendSessionLog(dir, info); err != nil {
t.Fatalf("AppendSessionLog: %v", err)
}
// Append a second entry
info2 := &SessionInfo{
Agent: "shell",
Mode: "local",
StartTime: end,
EndTime: end.Add(time.Hour),
Diff: &ManifestDiff{},
}
if err := AppendSessionLog(dir, info2); err != nil {
t.Fatalf("AppendSessionLog 2: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "logs", "sessions.log"))
content := string(data)
// Should contain both sessions
if strings.Count(content, "── Session") != 2 {
t.Errorf("expected 2 session entries, got %d", strings.Count(content, "── Session"))
}
}
func TestManifestCleanupOnSuccess(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, ".ctask"), 0755)
os.MkdirAll(filepath.Join(dir, "logs"), 0755)
mPath := filepath.Join(dir, ".ctask", "manifest-start.json")
now := time.Now().UTC().Truncate(time.Second)
m := &Manifest{
CapturedAt: now,
Files: []FileEntry{{Path: "notes.md", Size: 100, Mtime: now}},
}
WriteManifest(mPath, m)
// Simulate successful logging
info := &SessionInfo{
Agent: "claude",
Mode: "local",
StartTime: now,
EndTime: now.Add(time.Minute),
Diff: &ManifestDiff{},
}
err := AppendSessionLog(dir, info)
if err != nil {
t.Fatalf("AppendSessionLog: %v", err)
}
// Clean up manifest (as session.Run does)
os.Remove(mPath)
if _, err := os.Stat(mPath); !os.IsNotExist(err) {
t.Error("manifest should be cleaned up after successful logging")
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ func ignoredPath(relPath string) bool {
return true
}
// Ignore sessions.log (ctask's own logging)
if relPath == filepath.Join("logs", "sessions.log") {
if relPath == "logs/sessions.log" || relPath == "logs\\sessions.log" {
return true
}
return false
+179
View File
@@ -0,0 +1,179 @@
package session
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCaptureManifest(t *testing.T) {
dir := t.TempDir()
// Create some files
os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644)
os.WriteFile(filepath.Join(dir, "task.yaml"), []byte("meta"), 0644)
os.MkdirAll(filepath.Join(dir, "output"), 0755)
os.WriteFile(filepath.Join(dir, "output", "result.txt"), []byte("result"), 0644)
m, err := CaptureManifest(dir)
if err != nil {
t.Fatalf("CaptureManifest: %v", err)
}
if len(m.Files) != 3 {
t.Errorf("expected 3 files, got %d", len(m.Files))
}
// Verify paths are forward-slash normalized
for _, f := range m.Files {
if filepath.IsAbs(f.Path) {
t.Errorf("expected relative path, got %q", f.Path)
}
}
}
func TestCaptureManifestExcludesCtaskDir(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "notes.md"), []byte("hello"), 0644)
os.MkdirAll(filepath.Join(dir, ".ctask"), 0755)
os.WriteFile(filepath.Join(dir, ".ctask", "manifest-start.json"), []byte("{}"), 0644)
m, err := CaptureManifest(dir)
if err != nil {
t.Fatalf("CaptureManifest: %v", err)
}
for _, f := range m.Files {
if f.Path == ".ctask/manifest-start.json" {
t.Error(".ctask/ contents should be excluded from manifest")
}
}
}
func TestWriteAndReadManifest(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".ctask", "manifest-start.json")
now := time.Now().UTC().Truncate(time.Second)
m := &Manifest{
CapturedAt: now,
Files: []FileEntry{
{Path: "notes.md", Size: 100, Mtime: now},
},
}
if err := WriteManifest(path, m); err != nil {
t.Fatalf("WriteManifest: %v", err)
}
got, err := ReadManifest(path)
if err != nil {
t.Fatalf("ReadManifest: %v", err)
}
if len(got.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(got.Files))
}
if got.Files[0].Path != "notes.md" {
t.Errorf("path: got %q", got.Files[0].Path)
}
}
func TestDiffManifests(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
later := now.Add(time.Minute)
start := &Manifest{
CapturedAt: now,
Files: []FileEntry{
{Path: "notes.md", Size: 100, Mtime: now},
{Path: "CLAUDE.md", Size: 200, Mtime: now},
{Path: "old-file.txt", Size: 50, Mtime: now},
},
}
end := &Manifest{
CapturedAt: later,
Files: []FileEntry{
{Path: "notes.md", Size: 150, Mtime: later}, // modified (size + mtime)
{Path: "CLAUDE.md", Size: 200, Mtime: now}, // unchanged
{Path: "output/result.md", Size: 300, Mtime: later}, // added
},
}
diff := DiffManifests(start, end)
if len(diff.Added) != 1 || diff.Added[0] != "output/result.md" {
t.Errorf("Added: got %v, want [output/result.md]", diff.Added)
}
if len(diff.Modified) != 1 || diff.Modified[0] != "notes.md" {
t.Errorf("Modified: got %v, want [notes.md]", diff.Modified)
}
if len(diff.Deleted) != 1 || diff.Deleted[0] != "old-file.txt" {
t.Errorf("Deleted: got %v, want [old-file.txt]", diff.Deleted)
}
}
func TestDiffManifestsIgnoreRules(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
later := now.Add(time.Minute)
start := &Manifest{
CapturedAt: now,
Files: []FileEntry{
{Path: "notes.md", Size: 100, Mtime: now},
{Path: "task.yaml", Size: 200, Mtime: now},
{Path: "logs/sessions.log", Size: 50, Mtime: now},
{Path: ".ctask/manifest-start.json", Size: 300, Mtime: now},
},
}
end := &Manifest{
CapturedAt: later,
Files: []FileEntry{
{Path: "notes.md", Size: 100, Mtime: now}, // unchanged
{Path: "task.yaml", Size: 250, Mtime: later}, // changed but ignored
{Path: "logs/sessions.log", Size: 100, Mtime: later}, // changed but ignored
{Path: ".ctask/manifest-start.json", Size: 300, Mtime: now}, // ignored
},
}
diff := DiffManifests(start, end)
if len(diff.Added) != 0 {
t.Errorf("Added should be empty (ignore rules), got %v", diff.Added)
}
if len(diff.Modified) != 0 {
t.Errorf("Modified should be empty (ignore rules), got %v", diff.Modified)
}
if len(diff.Deleted) != 0 {
t.Errorf("Deleted should be empty (ignore rules), got %v", diff.Deleted)
}
}
func TestNotesUpdated(t *testing.T) {
diff := &ManifestDiff{
Modified: []string{"CLAUDE.md", "notes.md"},
}
if !NotesUpdated(diff) {
t.Error("expected NotesUpdated=true when notes.md in Modified")
}
diff2 := &ManifestDiff{
Modified: []string{"CLAUDE.md"},
}
if NotesUpdated(diff2) {
t.Error("expected NotesUpdated=false when notes.md not changed")
}
diff3 := &ManifestDiff{
Added: []string{"notes.md"},
}
if !NotesUpdated(diff3) {
t.Error("expected NotesUpdated=true when notes.md in Added")
}
}