Add tests for Decision 004 Phase 4 thread traversal functions
Tests findRepliesTo() and findReplies() helper functions in cmd/bd/show.go that traverse message threads via dependencies. Covers: - 3-message linear thread chain traversal (up and down) - Thread root finding via repeated findRepliesTo() calls - Branching threads (one message with multiple replies) - Empty/standalone messages - Nonexistent issue IDs - Verification that only replies-to deps are followed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
414
cmd/bd/thread_test.go
Normal file
414
cmd/bd/thread_test.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestThreadTraversal tests the findRepliesTo() and findReplies() functions
|
||||
// that were added in Decision 004 Phase 4 to support message thread navigation.
|
||||
func TestThreadTraversal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a 3-message thread chain: original → reply1 → reply2
|
||||
now := time.Now()
|
||||
|
||||
original := &types.Issue{
|
||||
Title: "Original Message",
|
||||
Description: "This is the original message",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "worker",
|
||||
Sender: "manager",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
|
||||
t.Fatalf("Failed to create original message: %v", err)
|
||||
}
|
||||
|
||||
reply1 := &types.Issue{
|
||||
Title: "Re: Original Message",
|
||||
Description: "This is reply 1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "manager",
|
||||
Sender: "worker",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, reply1, "test"); err != nil {
|
||||
t.Fatalf("Failed to create reply1: %v", err)
|
||||
}
|
||||
|
||||
reply2 := &types.Issue{
|
||||
Title: "Re: Re: Original Message",
|
||||
Description: "This is reply 2",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "worker",
|
||||
Sender: "manager",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(2 * time.Minute),
|
||||
UpdatedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, reply2, "test"); err != nil {
|
||||
t.Fatalf("Failed to create reply2: %v", err)
|
||||
}
|
||||
|
||||
// Add replies-to dependencies to form the thread chain
|
||||
// reply1 replies to original
|
||||
dep1 := &types.Dependency{
|
||||
IssueID: reply1.ID,
|
||||
DependsOnID: original.ID,
|
||||
Type: types.DepRepliesTo,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, dep1, "test"); err != nil {
|
||||
t.Fatalf("Failed to add reply1 -> original dependency: %v", err)
|
||||
}
|
||||
|
||||
// reply2 replies to reply1
|
||||
dep2 := &types.Dependency{
|
||||
IssueID: reply2.ID,
|
||||
DependsOnID: reply1.ID,
|
||||
Type: types.DepRepliesTo,
|
||||
CreatedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, dep2, "test"); err != nil {
|
||||
t.Fatalf("Failed to add reply2 -> reply1 dependency: %v", err)
|
||||
}
|
||||
|
||||
t.Run("findRepliesTo walks UP the thread", func(t *testing.T) {
|
||||
// From reply2, should find reply1
|
||||
parent := findRepliesTo(ctx, reply2.ID, nil, testStore)
|
||||
if parent != reply1.ID {
|
||||
t.Errorf("findRepliesTo(reply2) = %q, want %q", parent, reply1.ID)
|
||||
}
|
||||
|
||||
// From reply1, should find original
|
||||
parent = findRepliesTo(ctx, reply1.ID, nil, testStore)
|
||||
if parent != original.ID {
|
||||
t.Errorf("findRepliesTo(reply1) = %q, want %q", parent, original.ID)
|
||||
}
|
||||
|
||||
// From original, should return empty (no parent)
|
||||
parent = findRepliesTo(ctx, original.ID, nil, testStore)
|
||||
if parent != "" {
|
||||
t.Errorf("findRepliesTo(original) = %q, want empty string", parent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("findReplies walks DOWN the thread", func(t *testing.T) {
|
||||
// From original, should find reply1
|
||||
replies := findReplies(ctx, original.ID, nil, testStore)
|
||||
if len(replies) != 1 {
|
||||
t.Fatalf("findReplies(original) returned %d replies, want 1", len(replies))
|
||||
}
|
||||
if replies[0].ID != reply1.ID {
|
||||
t.Errorf("findReplies(original)[0].ID = %q, want %q", replies[0].ID, reply1.ID)
|
||||
}
|
||||
|
||||
// From reply1, should find reply2
|
||||
replies = findReplies(ctx, reply1.ID, nil, testStore)
|
||||
if len(replies) != 1 {
|
||||
t.Fatalf("findReplies(reply1) returned %d replies, want 1", len(replies))
|
||||
}
|
||||
if replies[0].ID != reply2.ID {
|
||||
t.Errorf("findReplies(reply1)[0].ID = %q, want %q", replies[0].ID, reply2.ID)
|
||||
}
|
||||
|
||||
// From reply2, should return empty (no children)
|
||||
replies = findReplies(ctx, reply2.ID, nil, testStore)
|
||||
if len(replies) != 0 {
|
||||
t.Errorf("findReplies(reply2) returned %d replies, want 0", len(replies))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("thread root finding via repeated findRepliesTo", func(t *testing.T) {
|
||||
// Starting from reply2, walk up via findRepliesTo() until reaching original
|
||||
current := reply2.ID
|
||||
var visited []string
|
||||
visited = append(visited, current)
|
||||
|
||||
for {
|
||||
parent := findRepliesTo(ctx, current, nil, testStore)
|
||||
if parent == "" {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
visited = append(visited, current)
|
||||
}
|
||||
|
||||
// Should have visited: reply2, reply1, original
|
||||
if len(visited) != 3 {
|
||||
t.Fatalf("Thread walk visited %d nodes, want 3: %v", len(visited), visited)
|
||||
}
|
||||
|
||||
// Final current should be the original (root)
|
||||
if current != original.ID {
|
||||
t.Errorf("Thread root = %q, want %q", current, original.ID)
|
||||
}
|
||||
|
||||
// Verify visited order: reply2 -> reply1 -> original
|
||||
if visited[0] != reply2.ID {
|
||||
t.Errorf("visited[0] = %q, want %q", visited[0], reply2.ID)
|
||||
}
|
||||
if visited[1] != reply1.ID {
|
||||
t.Errorf("visited[1] = %q, want %q", visited[1], reply1.ID)
|
||||
}
|
||||
if visited[2] != original.ID {
|
||||
t.Errorf("visited[2] = %q, want %q", visited[2], original.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestThreadTraversalEmptyThread tests thread traversal with an isolated message
|
||||
func TestThreadTraversalEmptyThread(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a single message with no thread
|
||||
now := time.Now()
|
||||
standalone := &types.Issue{
|
||||
Title: "Standalone Message",
|
||||
Description: "This message has no thread",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "user",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, standalone, "test"); err != nil {
|
||||
t.Fatalf("Failed to create standalone message: %v", err)
|
||||
}
|
||||
|
||||
// findRepliesTo should return empty
|
||||
parent := findRepliesTo(ctx, standalone.ID, nil, testStore)
|
||||
if parent != "" {
|
||||
t.Errorf("findRepliesTo(standalone) = %q, want empty string", parent)
|
||||
}
|
||||
|
||||
// findReplies should return empty slice
|
||||
replies := findReplies(ctx, standalone.ID, nil, testStore)
|
||||
if len(replies) != 0 {
|
||||
t.Errorf("findReplies(standalone) returned %d replies, want 0", len(replies))
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadTraversalBranching tests a branching thread (one message with multiple replies)
|
||||
func TestThreadTraversalBranching(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create original message
|
||||
original := &types.Issue{
|
||||
Title: "Original Message",
|
||||
Description: "This message will have multiple replies",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "user",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
|
||||
t.Fatalf("Failed to create original message: %v", err)
|
||||
}
|
||||
|
||||
// Create two replies to the original (branching)
|
||||
replyA := &types.Issue{
|
||||
Title: "Reply A",
|
||||
Description: "First branch reply",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "sender",
|
||||
Sender: "user",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, replyA, "test"); err != nil {
|
||||
t.Fatalf("Failed to create replyA: %v", err)
|
||||
}
|
||||
|
||||
replyB := &types.Issue{
|
||||
Title: "Reply B",
|
||||
Description: "Second branch reply",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "sender",
|
||||
Sender: "another-user",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(2 * time.Minute),
|
||||
UpdatedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, replyB, "test"); err != nil {
|
||||
t.Fatalf("Failed to create replyB: %v", err)
|
||||
}
|
||||
|
||||
// Both replies point to original
|
||||
depA := &types.Dependency{
|
||||
IssueID: replyA.ID,
|
||||
DependsOnID: original.ID,
|
||||
Type: types.DepRepliesTo,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, depA, "test"); err != nil {
|
||||
t.Fatalf("Failed to add replyA -> original dependency: %v", err)
|
||||
}
|
||||
|
||||
depB := &types.Dependency{
|
||||
IssueID: replyB.ID,
|
||||
DependsOnID: original.ID,
|
||||
Type: types.DepRepliesTo,
|
||||
CreatedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, depB, "test"); err != nil {
|
||||
t.Fatalf("Failed to add replyB -> original dependency: %v", err)
|
||||
}
|
||||
|
||||
t.Run("findRepliesTo from branches find original", func(t *testing.T) {
|
||||
parentA := findRepliesTo(ctx, replyA.ID, nil, testStore)
|
||||
if parentA != original.ID {
|
||||
t.Errorf("findRepliesTo(replyA) = %q, want %q", parentA, original.ID)
|
||||
}
|
||||
|
||||
parentB := findRepliesTo(ctx, replyB.ID, nil, testStore)
|
||||
if parentB != original.ID {
|
||||
t.Errorf("findRepliesTo(replyB) = %q, want %q", parentB, original.ID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("findReplies from original returns both branches", func(t *testing.T) {
|
||||
replies := findReplies(ctx, original.ID, nil, testStore)
|
||||
if len(replies) != 2 {
|
||||
t.Fatalf("findReplies(original) returned %d replies, want 2", len(replies))
|
||||
}
|
||||
|
||||
// Verify both replies are present (order may vary)
|
||||
foundA := false
|
||||
foundB := false
|
||||
for _, r := range replies {
|
||||
if r.ID == replyA.ID {
|
||||
foundA = true
|
||||
}
|
||||
if r.ID == replyB.ID {
|
||||
foundB = true
|
||||
}
|
||||
}
|
||||
if !foundA {
|
||||
t.Errorf("findReplies(original) missing replyA")
|
||||
}
|
||||
if !foundB {
|
||||
t.Errorf("findReplies(original) missing replyB")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestThreadTraversalNonexistentIssue tests behavior with nonexistent issue IDs
|
||||
func TestThreadTraversalNonexistentIssue(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
// findRepliesTo with nonexistent ID should return empty
|
||||
parent := findRepliesTo(ctx, "nonexistent-id", nil, testStore)
|
||||
if parent != "" {
|
||||
t.Errorf("findRepliesTo(nonexistent) = %q, want empty string", parent)
|
||||
}
|
||||
|
||||
// findReplies with nonexistent ID should return nil/empty
|
||||
replies := findReplies(ctx, "nonexistent-id", nil, testStore)
|
||||
if len(replies) != 0 {
|
||||
t.Errorf("findReplies(nonexistent) returned %d replies, want 0", len(replies))
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadTraversalOnlyRepliesTo verifies that only replies-to dependencies are followed
|
||||
func TestThreadTraversalOnlyRepliesTo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, filepath.Join(tmpDir, ".beads", "beads.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create three messages to test different dependency types
|
||||
msg1 := &types.Issue{
|
||||
Title: "Message 1",
|
||||
Description: "First message (target of blocks dep)",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "user",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, msg1, "test"); err != nil {
|
||||
t.Fatalf("Failed to create msg1: %v", err)
|
||||
}
|
||||
|
||||
msg2 := &types.Issue{
|
||||
Title: "Message 2",
|
||||
Description: "Second message with blocks dependency to msg1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "user",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, msg2, "test"); err != nil {
|
||||
t.Fatalf("Failed to create msg2: %v", err)
|
||||
}
|
||||
|
||||
// Add a "blocks" dependency (NOT replies-to)
|
||||
// msg2 depends on (blocks) msg1
|
||||
blocksDep := &types.Dependency{
|
||||
IssueID: msg2.ID,
|
||||
DependsOnID: msg1.ID,
|
||||
Type: types.DepBlocks,
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, blocksDep, "test"); err != nil {
|
||||
t.Fatalf("Failed to add blocks dependency: %v", err)
|
||||
}
|
||||
|
||||
// findRepliesTo should NOT find msg1 (blocks dependency, not replies-to)
|
||||
parent := findRepliesTo(ctx, msg2.ID, nil, testStore)
|
||||
if parent != "" {
|
||||
t.Errorf("findRepliesTo(msg2) = %q, want empty (blocks dep should be ignored)", parent)
|
||||
}
|
||||
|
||||
// findReplies from msg1 should NOT find msg2 (blocks dependency, not replies-to)
|
||||
replies := findReplies(ctx, msg1.ID, nil, testStore)
|
||||
if len(replies) != 0 {
|
||||
t.Errorf("findReplies(msg1) returned %d replies, want 0 (blocks dep should be ignored)", len(replies))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user