fix: emit rich mutation events for status changes (bd-313v)
- Add emitRichMutation() function for events with metadata - handleClose now emits MutationStatus with old/new status - handleUpdate detects status changes and emits MutationStatus - Add comprehensive tests for rich mutation events Also: - Add activity.go test coverage (bd-3jcw): - Tests for parseDurationString, filterEvents, formatEvent - Tests for all mutation type displays - Fix silent error handling in --follow mode (bd-csnr): - Track consecutive daemon failures - Show warning after 5 failures (rate-limited to 30s) - Show reconnection message on recovery 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
305
cmd/bd/activity_test.go
Normal file
305
cmd/bd/activity_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
)
|
||||
|
||||
// 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 added" },
|
||||
},
|
||||
{
|
||||
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 in_progress" },
|
||||
},
|
||||
{
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user