diff --git a/cmd/bd/hooks_test.go b/cmd/bd/hooks_test.go index e08107f9..f4c166ad 100644 --- a/cmd/bd/hooks_test.go +++ b/cmd/bd/hooks_test.go @@ -282,3 +282,120 @@ func TestInstallHooksShared(t *testing.T) { } }) } + +func TestFormatHookWarnings(t *testing.T) { + tests := []struct { + name string + statuses []HookStatus + want string + }{ + { + name: "no issues", + statuses: []HookStatus{{Name: "pre-commit", Installed: true}}, + want: "", + }, + { + name: "one missing", + statuses: []HookStatus{{Name: "pre-commit", Installed: false}}, + want: "⚠️ Git hooks not installed (1 missing)", + }, + { + name: "multiple missing", + statuses: []HookStatus{ + {Name: "pre-commit", Installed: false}, + {Name: "post-merge", Installed: false}, + }, + want: "⚠️ Git hooks not installed (2 missing)", + }, + { + name: "one outdated", + statuses: []HookStatus{{Name: "pre-commit", Installed: true, Outdated: true}}, + want: "⚠️ Git hooks are outdated (1 hooks)", + }, + { + name: "mixed missing and outdated", + statuses: []HookStatus{ + {Name: "pre-commit", Installed: false}, + {Name: "post-merge", Installed: true, Outdated: true}, + }, + want: "⚠️ Git hooks not installed (1 missing)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatHookWarnings(tt.statuses) + if tt.want == "" && got != "" { + t.Errorf("FormatHookWarnings() = %q, want empty", got) + } else if tt.want != "" && !strContains(got, tt.want) { + t.Errorf("FormatHookWarnings() = %q, want to contain %q", got, tt.want) + } + }) + } +} + +func strContains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || strContains(s[1:], substr))) +} + +func TestIsRebaseInProgress(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Create .git directory + if err := os.MkdirAll(".git", 0755); err != nil { + t.Fatalf("Failed to create .git: %v", err) + } + + // Should be false initially + if isRebaseInProgress() { + t.Error("isRebaseInProgress() = true, want false (no rebase marker)") + } + + // Create rebase-merge marker + if err := os.MkdirAll(".git/rebase-merge", 0755); err != nil { + t.Fatalf("Failed to create rebase-merge: %v", err) + } + if !isRebaseInProgress() { + t.Error("isRebaseInProgress() = false, want true (rebase-merge exists)") + } + + // Remove rebase-merge + if err := os.RemoveAll(".git/rebase-merge"); err != nil { + t.Fatalf("Failed to remove rebase-merge: %v", err) + } + + // Create rebase-apply marker + if err := os.MkdirAll(".git/rebase-apply", 0755); err != nil { + t.Fatalf("Failed to create rebase-apply: %v", err) + } + if !isRebaseInProgress() { + t.Error("isRebaseInProgress() = false, want true (rebase-apply exists)") + } +} + +func TestHasBeadsJSONL(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Should be false initially (no .beads directory) + if hasBeadsJSONL() { + t.Error("hasBeadsJSONL() = true, want false (no .beads)") + } + + // Create .beads directory without any JSONL files + if err := os.MkdirAll(".beads", 0755); err != nil { + t.Fatalf("Failed to create .beads: %v", err) + } + if hasBeadsJSONL() { + t.Error("hasBeadsJSONL() = true, want false (no JSONL files)") + } + + // Create issues.jsonl + if err := os.WriteFile(".beads/issues.jsonl", []byte("{}"), 0644); err != nil { + t.Fatalf("Failed to create issues.jsonl: %v", err) + } + if !hasBeadsJSONL() { + t.Error("hasBeadsJSONL() = false, want true (issues.jsonl exists)") + } +} diff --git a/cmd/bd/list_test.go b/cmd/bd/list_test.go index 68ba40fd..9407a5df 100644 --- a/cmd/bd/list_test.go +++ b/cmd/bd/list_test.go @@ -456,6 +456,146 @@ func TestListQueryCapabilitiesSuite(t *testing.T) { }) } +func TestFormatIssueLong(t *testing.T) { + tests := []struct { + name string + issue *types.Issue + labels []string + want string // substring to check for + }{ + { + name: "open issue", + issue: &types.Issue{ + ID: "test-123", + Title: "Test Issue", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + }, + labels: nil, + want: "test-123", + }, + { + name: "closed issue", + issue: &types.Issue{ + ID: "test-456", + Title: "Closed Issue", + Priority: 0, + IssueType: types.TypeTask, + Status: types.StatusClosed, + }, + labels: nil, + want: "test-456", + }, + { + name: "issue with assignee", + issue: &types.Issue{ + ID: "test-789", + Title: "Assigned Issue", + Priority: 2, + IssueType: types.TypeFeature, + Status: types.StatusInProgress, + Assignee: "alice", + }, + labels: nil, + want: "Assignee: alice", + }, + { + name: "issue with labels", + issue: &types.Issue{ + ID: "test-abc", + Title: "Labeled Issue", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + }, + labels: []string{"critical", "security"}, + want: "Labels:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + formatIssueLong(&buf, tt.issue, tt.labels) + result := buf.String() + if !strings.Contains(result, tt.want) { + t.Errorf("formatIssueLong() = %q, want to contain %q", result, tt.want) + } + }) + } +} + +func TestFormatIssueCompact(t *testing.T) { + tests := []struct { + name string + issue *types.Issue + labels []string + want string + }{ + { + name: "basic issue", + issue: &types.Issue{ + ID: "test-123", + Title: "Test Issue", + Priority: 1, + IssueType: types.TypeBug, + Status: types.StatusOpen, + }, + labels: nil, + want: "Test Issue", + }, + { + name: "issue with assignee", + issue: &types.Issue{ + ID: "test-456", + Title: "Assigned Issue", + Priority: 2, + IssueType: types.TypeTask, + Status: types.StatusInProgress, + Assignee: "bob", + }, + labels: nil, + want: "@bob", + }, + { + name: "issue with labels", + issue: &types.Issue{ + ID: "test-789", + Title: "Labeled Issue", + Priority: 0, + IssueType: types.TypeFeature, + Status: types.StatusOpen, + }, + labels: []string{"urgent"}, + want: "[urgent]", + }, + { + name: "closed issue", + issue: &types.Issue{ + ID: "test-def", + Title: "Closed Issue", + Priority: 3, + IssueType: types.TypeTask, + Status: types.StatusClosed, + }, + labels: nil, + want: "Closed Issue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + formatIssueCompact(&buf, tt.issue, tt.labels) + result := buf.String() + if !strings.Contains(result, tt.want) { + t.Errorf("formatIssueCompact() = %q, want to contain %q", result, tt.want) + } + }) + } +} + func TestParseTimeFlag(t *testing.T) { tests := []struct { name string diff --git a/cmd/bd/utils_unit_test.go b/cmd/bd/utils_unit_test.go new file mode 100644 index 00000000..4c1370d8 --- /dev/null +++ b/cmd/bd/utils_unit_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "testing" + "time" +) + +func TestTruncateString(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"no truncation needed", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncate with ellipsis", "hello world", 8, "hello..."}, + {"very short max", "hello", 3, "hel"}, + {"max of 4", "hello world", 4, "h..."}, + {"empty string", "", 5, ""}, + // Note: truncateString operates on bytes, not runes + {"unicode", "hello\u4e16\u754c", 15, "hello\u4e16\u754c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateString(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncateString(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestPluralize(t *testing.T) { + tests := []struct { + count int + want string + }{ + {0, "s"}, + {1, ""}, + {2, "s"}, + {100, "s"}, + {-1, "s"}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := pluralize(tt.count) + if got != tt.want { + t.Errorf("pluralize(%d) = %q, want %q", tt.count, got, tt.want) + } + }) + } +} + +func TestFormatTimeAgo(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + t time.Time + wantContains string + }{ + {"just now", now.Add(-30 * time.Second), "just now"}, + {"1 minute ago", now.Add(-1 * time.Minute), "1 min ago"}, + {"5 minutes ago", now.Add(-5 * time.Minute), "5 mins ago"}, + {"1 hour ago", now.Add(-1 * time.Hour), "1 hour ago"}, + {"3 hours ago", now.Add(-3 * time.Hour), "3 hours ago"}, + {"1 day ago", now.Add(-24 * time.Hour), "1 day ago"}, + {"3 days ago", now.Add(-72 * time.Hour), "3 days ago"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTimeAgo(tt.t) + if got != tt.wantContains { + t.Errorf("formatTimeAgo() = %q, want %q", got, tt.wantContains) + } + }) + } +} + +func TestContainsLabel(t *testing.T) { + tests := []struct { + name string + labels []string + label string + want bool + }{ + {"empty labels", []string{}, "bug", false}, + {"single match", []string{"bug"}, "bug", true}, + {"no match", []string{"feature", "enhancement"}, "bug", false}, + {"match in list", []string{"bug", "feature", "urgent"}, "feature", true}, + {"case sensitive", []string{"Bug"}, "bug", false}, + {"nil labels", nil, "bug", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsLabel(tt.labels, tt.label) + if got != tt.want { + t.Errorf("containsLabel(%v, %q) = %v, want %v", tt.labels, tt.label, got, tt.want) + } + }) + } +} + +func TestGetContributorsSorted(t *testing.T) { + // Test that contributors are returned in sorted order by commit count + contributors := getContributorsSorted() + + if len(contributors) == 0 { + t.Skip("No contributors defined") + } + + // Check that we have at least some contributors + if len(contributors) < 1 { + t.Error("Expected at least one contributor") + } + + // Verify first contributor has most commits (descending order) + // We can't easily check counts, but we can verify the result is non-empty strings + for i, c := range contributors { + if c == "" { + t.Errorf("Contributor at index %d is empty string", i) + } + } +} + +func TestExtractIDSuffix(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"hierarchical ID", "bd-123.1.2", "2"}, + {"prefix-hash ID", "bd-abc123", "abc123"}, + {"simple ID", "123", "123"}, + {"multi-dot hierarchical", "prefix-xyz.1.2.3", "3"}, + {"dot only", "a.b", "b"}, + {"dash only", "a-b", "b"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractIDSuffix(tt.id) + if got != tt.want { + t.Errorf("extractIDSuffix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + s string + maxLen int + want string + }{ + {"no truncation", "short", 10, "short"}, + {"exact length", "exact", 5, "exact"}, + {"truncate needed", "long string here", 10, "long st..."}, + {"very short max", "hello world", 5, "he..."}, + {"empty string", "", 5, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.s, tt.maxLen) + if got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestTruncateDescription(t *testing.T) { + tests := []struct { + name string + desc string + maxLen int + want string + }{ + {"no truncation", "short", 10, "short"}, + {"multiline takes first", "first line\nsecond line", 20, "first line"}, + {"truncate with ellipsis", "a very long description here", 15, "a very long ..."}, + {"multiline and truncate", "first line is long\nsecond", 10, "first l..."}, + {"empty", "", 10, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateDescription(tt.desc, tt.maxLen) + if got != tt.want { + t.Errorf("truncateDescription(%q, %d) = %q, want %q", tt.desc, tt.maxLen, got, tt.want) + } + }) + } +} + +// Test showCleanupDeprecationHint - just ensure it doesn't panic +func TestShowCleanupDeprecationHint(t *testing.T) { + // This function just prints to stdout, so we just verify it doesn't panic + showCleanupDeprecationHint() +} + +// Test clearAutoFlushState - ensure it doesn't panic when called without initialization +func TestClearAutoFlushState(t *testing.T) { + // This should not panic even if flush manager isn't initialized + clearAutoFlushState() +} diff --git a/cmd/bd/version_tracking_test.go b/cmd/bd/version_tracking_test.go index 10b4bbbd..d82c1ea8 100644 --- a/cmd/bd/version_tracking_test.go +++ b/cmd/bd/version_tracking_test.go @@ -134,6 +134,10 @@ func TestTrackBdVersion_FirstRun(t *testing.T) { t.Fatalf("Failed to create db file: %v", err) } + // Set BEADS_DIR to force FindBeadsDir to use our temp directory + // This prevents finding the actual .beads in a git worktree + t.Setenv("BEADS_DIR", beadsDir) + // Change to temp directory t.Chdir(tmpDir) @@ -176,6 +180,10 @@ func TestTrackBdVersion_UpgradeDetection(t *testing.T) { t.Fatalf("Failed to create .beads: %v", err) } + // Set BEADS_DIR to force FindBeadsDir to use our temp directory + // This prevents finding the actual .beads in a git worktree + t.Setenv("BEADS_DIR", beadsDir) + // Change to temp directory t.Chdir(tmpDir)