Files
beads/cmd/bd/thread_test.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -08:00

415 lines
12 KiB
Go

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: "message",
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: "message",
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: "message",
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: "message",
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: "message",
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: "message",
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: "message",
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: "message",
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: "message",
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))
}
}