Files
beads/cmd/bd/activity_test.go
Andrew B f8a4fcd036 feat(activity): add --details/-d flag for full issue information (#1317)
* feat(activity): add --details/-d flag for full issue information

Add a new --details (-d) flag to the `bd activity` command that includes
full issue information in the output, including comments.

* style(activity): simplify --details text output format

Remove ASCII tree characters and use cleaner indentation with
blank lines between events for better readability.
2026-01-25 17:59:50 -08:00

454 lines
13 KiB
Go

package main
import (
"testing"
"time"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
)
// TestParseDurationString tests the duration parsing function
func TestParseDurationString(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
expectErr bool
}{
// Standard Go duration formats
{"5 minutes", "5m", 5 * time.Minute, false},
{"1 hour", "1h", time.Hour, false},
{"30 seconds", "30s", 30 * time.Second, false},
{"2h30m", "2h30m", 2*time.Hour + 30*time.Minute, false},
// Custom day format
{"2 days", "2d", 2 * 24 * time.Hour, false},
{"1 day", "1d", 24 * time.Hour, false},
{"7 days", "7d", 7 * 24 * time.Hour, false},
// Case insensitivity for custom formats
{"uppercase D", "3D", 3 * 24 * time.Hour, false},
{"uppercase H", "2H", 2 * time.Hour, false},
{"uppercase M", "15M", 15 * time.Minute, false},
{"uppercase S", "45S", 45 * time.Second, false},
// Invalid formats
{"empty string", "", 0, true},
{"invalid unit", "5x", 0, true},
{"no number", "m", 0, true},
{"text only", "five minutes", 0, true},
// Note: negative durations are actually valid in Go's time.ParseDuration
// so we test it works rather than expecting an error
{"negative duration", "-5m", -5 * time.Minute, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseDurationString(tt.input)
if tt.expectErr {
if err == nil {
t.Errorf("expected error for input %q, got nil", tt.input)
}
} else {
if err != nil {
t.Errorf("unexpected error for input %q: %v", tt.input, err)
}
if result != tt.expected {
t.Errorf("for input %q: expected %v, got %v", tt.input, tt.expected, result)
}
}
})
}
}
// TestFilterEvents tests event filtering by --mol and --type
func TestFilterEvents(t *testing.T) {
// Create test events
events := []rpc.MutationEvent{
{Type: rpc.MutationCreate, IssueID: "bd-abc123", Timestamp: time.Now()},
{Type: rpc.MutationUpdate, IssueID: "bd-abc456", Timestamp: time.Now()},
{Type: rpc.MutationDelete, IssueID: "bd-xyz789", Timestamp: time.Now()},
{Type: rpc.MutationStatus, IssueID: "bd-abc789", Timestamp: time.Now()},
{Type: rpc.MutationComment, IssueID: "bd-def123", Timestamp: time.Now()},
}
// Reset global filter vars before each test
defer func() {
activityMol = ""
activityType = ""
}()
t.Run("no filters returns all events", func(t *testing.T) {
activityMol = ""
activityType = ""
result := filterEvents(events)
if len(result) != len(events) {
t.Errorf("expected %d events, got %d", len(events), len(result))
}
})
t.Run("filter by mol prefix", func(t *testing.T) {
activityMol = "bd-abc"
activityType = ""
result := filterEvents(events)
// Should match: bd-abc123, bd-abc456, bd-abc789
if len(result) != 3 {
t.Errorf("expected 3 events matching bd-abc*, got %d", len(result))
}
for _, e := range result {
if e.IssueID[:6] != "bd-abc" {
t.Errorf("unexpected event with ID %s", e.IssueID)
}
}
})
t.Run("filter by event type", func(t *testing.T) {
activityMol = ""
activityType = rpc.MutationCreate
result := filterEvents(events)
if len(result) != 1 {
t.Errorf("expected 1 create event, got %d", len(result))
}
if len(result) > 0 && result[0].Type != rpc.MutationCreate {
t.Errorf("expected create event, got %s", result[0].Type)
}
})
t.Run("filter by both mol and type", func(t *testing.T) {
activityMol = "bd-abc"
activityType = rpc.MutationUpdate
result := filterEvents(events)
// Should match: only bd-abc456 (update)
if len(result) != 1 {
t.Errorf("expected 1 event, got %d", len(result))
}
if len(result) > 0 {
if result[0].IssueID != "bd-abc456" {
t.Errorf("expected bd-abc456, got %s", result[0].IssueID)
}
if result[0].Type != rpc.MutationUpdate {
t.Errorf("expected update type, got %s", result[0].Type)
}
}
})
t.Run("filter returns empty for no matches", func(t *testing.T) {
activityMol = "bd-nomatch"
activityType = ""
result := filterEvents(events)
if len(result) != 0 {
t.Errorf("expected 0 events, got %d", len(result))
}
})
}
// TestGetEventDisplay tests the symbol and message generation for all event types
func TestGetEventDisplay(t *testing.T) {
tests := []struct {
name string
event rpc.MutationEvent
expectedSymbol string
checkMessage func(string) bool
}{
{
name: "create event",
event: rpc.MutationEvent{Type: rpc.MutationCreate, IssueID: "bd-123"},
expectedSymbol: "+",
checkMessage: func(m string) bool { return m == "bd-123 created" },
},
{
name: "update event",
event: rpc.MutationEvent{Type: rpc.MutationUpdate, IssueID: "bd-456"},
expectedSymbol: "\u2192", // →
checkMessage: func(m string) bool { return m == "bd-456 updated" },
},
{
name: "delete event",
event: rpc.MutationEvent{Type: rpc.MutationDelete, IssueID: "bd-789"},
expectedSymbol: "\u2298", // ⊘
checkMessage: func(m string) bool { return m == "bd-789 deleted" },
},
{
name: "comment event",
event: rpc.MutationEvent{Type: rpc.MutationComment, IssueID: "bd-abc"},
expectedSymbol: "\U0001F4AC", // 💬
checkMessage: func(m string) bool { return m == "bd-abc comment" },
},
{
name: "bonded event with step count",
event: rpc.MutationEvent{
Type: rpc.MutationBonded,
IssueID: "bd-mol",
StepCount: 5,
},
expectedSymbol: "+",
checkMessage: func(m string) bool { return m == "bd-mol bonded (5 steps)" },
},
{
name: "bonded event without step count",
event: rpc.MutationEvent{Type: rpc.MutationBonded, IssueID: "bd-mol2"},
expectedSymbol: "+",
checkMessage: func(m string) bool { return m == "bd-mol2 bonded" },
},
{
name: "squashed event",
event: rpc.MutationEvent{Type: rpc.MutationSquashed, IssueID: "bd-wisp"},
expectedSymbol: "\u25C9", // ◉
checkMessage: func(m string) bool { return m == "bd-wisp SQUASHED" },
},
{
name: "burned event",
event: rpc.MutationEvent{Type: rpc.MutationBurned, IssueID: "bd-burn"},
expectedSymbol: "\U0001F525", // 🔥
checkMessage: func(m string) bool { return m == "bd-burn burned" },
},
{
name: "status event - in_progress",
event: rpc.MutationEvent{
Type: rpc.MutationStatus,
IssueID: "bd-wip",
OldStatus: "open",
NewStatus: "in_progress",
},
expectedSymbol: "\u2192", // →
checkMessage: func(m string) bool { return m == "bd-wip started" },
},
{
name: "status event - closed",
event: rpc.MutationEvent{
Type: rpc.MutationStatus,
IssueID: "bd-done",
OldStatus: "in_progress",
NewStatus: "closed",
},
expectedSymbol: "\u2713", // ✓
checkMessage: func(m string) bool { return m == "bd-done completed" },
},
{
name: "status event - reopened",
event: rpc.MutationEvent{
Type: rpc.MutationStatus,
IssueID: "bd-reopen",
OldStatus: "closed",
NewStatus: "open",
},
expectedSymbol: "\u21BA", // ↺
checkMessage: func(m string) bool { return m == "bd-reopen reopened" },
},
{
name: "unknown event type",
event: rpc.MutationEvent{Type: "custom", IssueID: "bd-custom"},
expectedSymbol: "\u2022", // •
checkMessage: func(m string) bool { return m == "bd-custom custom" },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
symbol, message := getEventDisplay(tt.event)
if symbol != tt.expectedSymbol {
t.Errorf("expected symbol %q, got %q", tt.expectedSymbol, symbol)
}
if !tt.checkMessage(message) {
t.Errorf("unexpected message: %q", message)
}
})
}
}
// TestFormatEvent tests the ActivityEvent formatting
func TestFormatEvent(t *testing.T) {
now := time.Now()
event := rpc.MutationEvent{
Type: rpc.MutationStatus,
IssueID: "bd-test",
Timestamp: now,
OldStatus: "open",
NewStatus: "in_progress",
ParentID: "bd-parent",
StepCount: 3,
}
result := formatEvent(event)
if result.Timestamp != now {
t.Errorf("expected timestamp %v, got %v", now, result.Timestamp)
}
if result.Type != rpc.MutationStatus {
t.Errorf("expected type %s, got %s", rpc.MutationStatus, result.Type)
}
if result.IssueID != "bd-test" {
t.Errorf("expected issue ID bd-test, got %s", result.IssueID)
}
if result.OldStatus != "open" {
t.Errorf("expected OldStatus 'open', got %s", result.OldStatus)
}
if result.NewStatus != "in_progress" {
t.Errorf("expected NewStatus 'in_progress', got %s", result.NewStatus)
}
if result.ParentID != "bd-parent" {
t.Errorf("expected ParentID 'bd-parent', got %s", result.ParentID)
}
if result.StepCount != 3 {
t.Errorf("expected StepCount 3, got %d", result.StepCount)
}
if result.Symbol == "" {
t.Error("expected non-empty symbol")
}
if result.Message == "" {
t.Error("expected non-empty message")
}
}
// TestFormatEventWithDetails tests that issue details are included when provided
func TestFormatEventWithDetails(t *testing.T) {
now := time.Now()
event := rpc.MutationEvent{
Type: rpc.MutationCreate,
IssueID: "bd-test",
Timestamp: now,
}
t.Run("without details", func(t *testing.T) {
result := formatEventWithDetails(event, nil)
if result.Issue != nil {
t.Error("expected nil issue when no details provided")
}
if result.IssueID != "bd-test" {
t.Errorf("expected issue ID bd-test, got %s", result.IssueID)
}
})
t.Run("with details", func(t *testing.T) {
details := &types.IssueDetails{
Issue: types.Issue{
ID: "bd-test",
Title: "Test Issue",
Description: "A test description",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
Assignee: "testuser",
},
Labels: []string{"bug", "urgent"},
Dependencies: nil,
Dependents: nil,
}
result := formatEventWithDetails(event, details)
if result.Issue == nil {
t.Fatal("expected issue details to be included")
}
if result.Issue.ID != "bd-test" {
t.Errorf("expected issue ID bd-test, got %s", result.Issue.ID)
}
if result.Issue.Title != "Test Issue" {
t.Errorf("expected title 'Test Issue', got %s", result.Issue.Title)
}
if result.Issue.Assignee != "testuser" {
t.Errorf("expected assignee 'testuser', got %s", result.Issue.Assignee)
}
if len(result.Issue.Labels) != 2 {
t.Errorf("expected 2 labels, got %d", len(result.Issue.Labels))
}
})
}
// TestFindDaemonForIssue tests daemon lookup by issue ID prefix
func TestFindDaemonForIssue(t *testing.T) {
// Create mock daemons (without actual connections)
daemons := []rigDaemon{
{prefix: "bd-", rig: "beads", client: nil}, // No client
{prefix: "app-", rig: "app", client: nil}, // No client
{prefix: "test-", rig: "test", client: nil}, // No client
}
t.Run("no matching prefix returns nil", func(t *testing.T) {
result := findDaemonForIssue(daemons, "other-123")
if result != nil {
t.Error("expected nil for non-matching prefix")
}
})
t.Run("matching prefix but nil client returns nil", func(t *testing.T) {
result := findDaemonForIssue(daemons, "bd-abc123")
if result != nil {
t.Error("expected nil when daemon client is nil")
}
})
t.Run("empty daemons list returns nil", func(t *testing.T) {
result := findDaemonForIssue([]rigDaemon{}, "bd-abc")
if result != nil {
t.Error("expected nil for empty daemon list")
}
})
}
// TestFetchIssueDetails tests the issue details fetching function
func TestFetchIssueDetails(t *testing.T) {
t.Run("nil client returns nil", func(t *testing.T) {
result := fetchIssueDetails(nil, "bd-123")
if result != nil {
t.Error("expected nil when client is nil")
}
})
}
// TestFormatEventWithDetailsIncludesComments verifies comments are preserved in details
func TestFormatEventWithDetailsIncludesComments(t *testing.T) {
now := time.Now()
event := rpc.MutationEvent{
Type: rpc.MutationUpdate,
IssueID: "bd-test",
Timestamp: now,
}
details := &types.IssueDetails{
Issue: types.Issue{
ID: "bd-test",
Title: "Test Issue",
Status: types.StatusInProgress,
},
Comments: []*types.Comment{
{
ID: 1,
IssueID: "bd-test",
Author: "alice",
Text: "First comment with full text that should not be truncated in JSON",
CreatedAt: now.Add(-time.Hour),
},
{
ID: 2,
IssueID: "bd-test",
Author: "bob",
Text: "Second comment",
CreatedAt: now.Add(-30 * time.Minute),
},
},
}
result := formatEventWithDetails(event, details)
if result.Issue == nil {
t.Fatal("expected issue details to be included")
}
if len(result.Issue.Comments) != 2 {
t.Errorf("expected 2 comments, got %d", len(result.Issue.Comments))
}
if result.Issue.Comments[0].Author != "alice" {
t.Errorf("expected first comment author 'alice', got %s", result.Issue.Comments[0].Author)
}
// Verify full text is preserved (not truncated) for JSON output
expectedText := "First comment with full text that should not be truncated in JSON"
if result.Issue.Comments[0].Text != expectedText {
t.Errorf("expected full comment text preserved, got %s", result.Issue.Comments[0].Text)
}
}