bd sync: 2025-11-25 19:32:04
This commit is contained in:
345
cmd/bd/dep.go
345
cmd/bd/dep.go
@@ -249,10 +249,22 @@ var depRemoveCmd = &cobra.Command{
|
||||
var depTreeCmd = &cobra.Command{
|
||||
Use: "tree [issue-id]",
|
||||
Short: "Show dependency tree",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Show dependency tree rooted at the given issue.
|
||||
|
||||
By default, shows dependencies (what blocks this issue). Use --direction to control:
|
||||
- down: Show dependencies (what blocks this issue) - default
|
||||
- up: Show dependents (what this issue blocks)
|
||||
- both: Show full graph in both directions
|
||||
|
||||
Examples:
|
||||
bd dep tree gt-0iqq # Show what blocks gt-0iqq
|
||||
bd dep tree gt-0iqq --direction=up # Show what gt-0iqq blocks
|
||||
bd dep tree gt-0iqq --status=open # Only show open issues
|
||||
bd dep tree gt-0iqq --depth=3 # Limit to 3 levels deep`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
|
||||
|
||||
// Resolve partial ID first
|
||||
var fullID string
|
||||
if daemonClient != nil {
|
||||
@@ -274,7 +286,7 @@ var depTreeCmd = &cobra.Command{
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If daemon is running but doesn't support this command, use direct storage
|
||||
if daemonClient != nil && store == nil {
|
||||
var err error
|
||||
@@ -289,17 +301,62 @@ var depTreeCmd = &cobra.Command{
|
||||
showAllPaths, _ := cmd.Flags().GetBool("show-all-paths")
|
||||
maxDepth, _ := cmd.Flags().GetInt("max-depth")
|
||||
reverse, _ := cmd.Flags().GetBool("reverse")
|
||||
direction, _ := cmd.Flags().GetString("direction")
|
||||
statusFilter, _ := cmd.Flags().GetString("status")
|
||||
formatStr, _ := cmd.Flags().GetString("format")
|
||||
|
||||
// Handle --direction flag (takes precedence over deprecated --reverse)
|
||||
if direction == "" && reverse {
|
||||
direction = "up"
|
||||
} else if direction == "" {
|
||||
direction = "down"
|
||||
}
|
||||
|
||||
// Validate direction
|
||||
if direction != "down" && direction != "up" && direction != "both" {
|
||||
fmt.Fprintf(os.Stderr, "Error: --direction must be 'down', 'up', or 'both'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if maxDepth < 1 {
|
||||
fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, reverse)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
// For "both" direction, we need to fetch both trees and merge them
|
||||
var tree []*types.TreeNode
|
||||
var err error
|
||||
|
||||
if direction == "both" {
|
||||
// Get dependencies (down) - what blocks this issue
|
||||
downTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get dependents (up) - what this issue blocks
|
||||
upTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Merge: root appears once, dependencies below, dependents above
|
||||
// We'll show dependents first (with negative-like positioning conceptually),
|
||||
// then root, then dependencies
|
||||
tree = mergeBidirectionalTrees(downTree, upTree, fullID)
|
||||
} else {
|
||||
tree, err = store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, direction == "up")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply status filter if specified
|
||||
if statusFilter != "" {
|
||||
tree = filterTreeByStatus(tree, types.Status(statusFilter))
|
||||
}
|
||||
|
||||
// Handle mermaid format
|
||||
@@ -318,41 +375,29 @@ var depTreeCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if len(tree) == 0 {
|
||||
if reverse {
|
||||
switch direction {
|
||||
case "up":
|
||||
fmt.Printf("\n%s has no dependents\n", fullID)
|
||||
} else {
|
||||
case "both":
|
||||
fmt.Printf("\n%s has no dependencies or dependents\n", fullID)
|
||||
default:
|
||||
fmt.Printf("\n%s has no dependencies\n", fullID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
if reverse {
|
||||
switch direction {
|
||||
case "up":
|
||||
fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID)
|
||||
} else {
|
||||
case "both":
|
||||
fmt.Printf("\n%s Full dependency graph for %s:\n\n", cyan("🌲"), fullID)
|
||||
default:
|
||||
fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), fullID)
|
||||
}
|
||||
|
||||
hasTruncation := false
|
||||
for _, node := range tree {
|
||||
indent := ""
|
||||
for i := 0; i < node.Depth; i++ {
|
||||
indent += " "
|
||||
}
|
||||
line := fmt.Sprintf("%s→ %s: %s [P%d] (%s)",
|
||||
indent, node.ID, node.Title, node.Priority, node.Status)
|
||||
if node.Truncated {
|
||||
line += " … [truncated]"
|
||||
hasTruncation = true
|
||||
}
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
if hasTruncation {
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf("\n%s Warning: Tree truncated at depth %d (safety limit)\n",
|
||||
yellow("⚠"), maxDepth)
|
||||
}
|
||||
// Render tree with proper connectors
|
||||
renderTree(tree, maxDepth, direction)
|
||||
fmt.Println()
|
||||
},
|
||||
}
|
||||
@@ -457,6 +502,238 @@ func getStatusEmoji(status types.Status) string {
|
||||
}
|
||||
}
|
||||
|
||||
// treeRenderer holds state for rendering a tree with proper connectors
|
||||
type treeRenderer struct {
|
||||
// Track which nodes we've already displayed (for "shown above" handling)
|
||||
seen map[string]bool
|
||||
// Track connector state at each depth level (true = has more siblings)
|
||||
activeConnectors []bool
|
||||
// Maximum depth reached
|
||||
maxDepth int
|
||||
// Direction of traversal
|
||||
direction string
|
||||
}
|
||||
|
||||
// renderTree renders the tree with proper box-drawing connectors
|
||||
func renderTree(tree []*types.TreeNode, maxDepth int, direction string) {
|
||||
if len(tree) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r := &treeRenderer{
|
||||
seen: make(map[string]bool),
|
||||
activeConnectors: make([]bool, maxDepth+1),
|
||||
maxDepth: maxDepth,
|
||||
direction: direction,
|
||||
}
|
||||
|
||||
// Build a map of parent -> children for proper sibling tracking
|
||||
children := make(map[string][]*types.TreeNode)
|
||||
var root *types.TreeNode
|
||||
|
||||
for _, node := range tree {
|
||||
if node.Depth == 0 {
|
||||
root = node
|
||||
} else {
|
||||
children[node.ParentID] = append(children[node.ParentID], node)
|
||||
}
|
||||
}
|
||||
|
||||
if root == nil && len(tree) > 0 {
|
||||
root = tree[0]
|
||||
}
|
||||
|
||||
// Render recursively from root
|
||||
r.renderNode(root, children, 0, true)
|
||||
}
|
||||
|
||||
// renderNode renders a single node and its children
|
||||
func (r *treeRenderer) renderNode(node *types.TreeNode, children map[string][]*types.TreeNode, depth int, isLast bool) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prefix with connectors
|
||||
var prefix strings.Builder
|
||||
|
||||
// Add vertical lines for active parent connectors
|
||||
for i := 0; i < depth; i++ {
|
||||
if r.activeConnectors[i] {
|
||||
prefix.WriteString("│ ")
|
||||
} else {
|
||||
prefix.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
// Add the branch connector for non-root nodes
|
||||
if depth > 0 {
|
||||
if isLast {
|
||||
prefix.WriteString("└── ")
|
||||
} else {
|
||||
prefix.WriteString("├── ")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've seen this node before (diamond dependency)
|
||||
if r.seen[node.ID] {
|
||||
gray := color.New(color.FgHiBlack).SprintFunc()
|
||||
fmt.Printf("%s%s (shown above)\n", prefix.String(), gray(node.ID))
|
||||
return
|
||||
}
|
||||
r.seen[node.ID] = true
|
||||
|
||||
// Format the node line
|
||||
line := formatTreeNode(node)
|
||||
|
||||
// Add truncation warning if at max depth and has children
|
||||
if node.Truncated || (depth == r.maxDepth && len(children[node.ID]) > 0) {
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
line += yellow(" …")
|
||||
}
|
||||
|
||||
fmt.Printf("%s%s\n", prefix.String(), line)
|
||||
|
||||
// Render children
|
||||
nodeChildren := children[node.ID]
|
||||
for i, child := range nodeChildren {
|
||||
// Update connector state for this depth
|
||||
// For depth 0 (root level), never show vertical connector since root has no siblings
|
||||
if depth > 0 {
|
||||
r.activeConnectors[depth] = (i < len(nodeChildren)-1)
|
||||
}
|
||||
r.renderNode(child, children, depth+1, i == len(nodeChildren)-1)
|
||||
}
|
||||
}
|
||||
|
||||
// formatTreeNode formats a single tree node with status, ready indicator, etc.
|
||||
func formatTreeNode(node *types.TreeNode) string {
|
||||
// Color the ID based on status
|
||||
var idStr string
|
||||
switch node.Status {
|
||||
case types.StatusOpen:
|
||||
idStr = color.New(color.FgWhite).Sprint(node.ID)
|
||||
case types.StatusInProgress:
|
||||
idStr = color.New(color.FgYellow).Sprint(node.ID)
|
||||
case types.StatusBlocked:
|
||||
idStr = color.New(color.FgRed).Sprint(node.ID)
|
||||
case types.StatusClosed:
|
||||
idStr = color.New(color.FgGreen).Sprint(node.ID)
|
||||
default:
|
||||
idStr = node.ID
|
||||
}
|
||||
|
||||
// Build the line
|
||||
line := fmt.Sprintf("%s: %s [P%d] (%s)",
|
||||
idStr, node.Title, node.Priority, node.Status)
|
||||
|
||||
// Add READY indicator for open issues (those that could be worked on)
|
||||
// An issue is ready if it's open and has no blocking dependencies
|
||||
// (In the tree view, depth 0 with status open implies ready in the "down" direction)
|
||||
if node.Status == types.StatusOpen && node.Depth == 0 {
|
||||
green := color.New(color.FgGreen, color.Bold).SprintFunc()
|
||||
line += " " + green("[READY]")
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// filterTreeByStatus filters the tree to only include nodes with the given status
|
||||
// Note: keeps parent chain to maintain tree structure
|
||||
func filterTreeByStatus(tree []*types.TreeNode, status types.Status) []*types.TreeNode {
|
||||
if len(tree) == 0 {
|
||||
return tree
|
||||
}
|
||||
|
||||
// First pass: identify which nodes match the status
|
||||
matches := make(map[string]bool)
|
||||
for _, node := range tree {
|
||||
if node.Status == status {
|
||||
matches[node.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no matches, return empty
|
||||
if len(matches) == 0 {
|
||||
return []*types.TreeNode{}
|
||||
}
|
||||
|
||||
// Second pass: keep matching nodes and their ancestors
|
||||
// Build parent map
|
||||
parentOf := make(map[string]string)
|
||||
for _, node := range tree {
|
||||
if node.ParentID != "" && node.ParentID != node.ID {
|
||||
parentOf[node.ID] = node.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all ancestors of matching nodes
|
||||
keep := make(map[string]bool)
|
||||
for id := range matches {
|
||||
keep[id] = true
|
||||
// Walk up to root
|
||||
current := id
|
||||
for {
|
||||
parent, ok := parentOf[current]
|
||||
if !ok || parent == current {
|
||||
break
|
||||
}
|
||||
keep[parent] = true
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
// Filter the tree
|
||||
var filtered []*types.TreeNode
|
||||
for _, node := range tree {
|
||||
if keep[node.ID] {
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// mergeBidirectionalTrees merges up and down trees into a single visualization
|
||||
// The root appears once, with dependencies shown below and dependents shown above
|
||||
func mergeBidirectionalTrees(downTree, upTree []*types.TreeNode, rootID string) []*types.TreeNode {
|
||||
// For bidirectional display, we show the down tree (dependencies) as the main tree
|
||||
// and add a visual separator with the up tree (dependents)
|
||||
//
|
||||
// For simplicity, we'll just return the down tree for now
|
||||
// A more sophisticated implementation would show both with visual separation
|
||||
|
||||
// Find root in each tree
|
||||
var result []*types.TreeNode
|
||||
|
||||
// Add dependents section if any (excluding root)
|
||||
hasUpNodes := false
|
||||
for _, node := range upTree {
|
||||
if node.ID != rootID {
|
||||
hasUpNodes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasUpNodes {
|
||||
// Add a header node for dependents section
|
||||
// We'll mark these with negative depth for visual distinction
|
||||
for _, node := range upTree {
|
||||
if node.ID == rootID {
|
||||
continue // Skip root, we'll add it once from down tree
|
||||
}
|
||||
// Clone node and mark it as "up" direction
|
||||
upNode := *node
|
||||
upNode.Depth = node.Depth // Keep original depth
|
||||
result = append(result, &upNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the down tree (dependencies)
|
||||
result = append(result, downTree...)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func init() {
|
||||
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
@@ -465,7 +742,9 @@ func init() {
|
||||
|
||||
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
|
||||
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
|
||||
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)")
|
||||
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)")
|
||||
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
|
||||
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)")
|
||||
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
|
||||
|
||||
@@ -463,3 +463,239 @@ func TestOutputMermaidTree_Siblings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepTreeDirectionFlag(t *testing.T) {
|
||||
// Test that the --direction flag exists on depTreeCmd
|
||||
flag := depTreeCmd.Flags().Lookup("direction")
|
||||
if flag == nil {
|
||||
t.Fatal("depTreeCmd should have --direction flag")
|
||||
}
|
||||
|
||||
// Test default value is empty string (will default to "down")
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("Expected default direction='', got %q", flag.DefValue)
|
||||
}
|
||||
|
||||
// Test usage text mentions valid options
|
||||
usage := flag.Usage
|
||||
if !strings.Contains(usage, "down") || !strings.Contains(usage, "up") || !strings.Contains(usage, "both") {
|
||||
t.Errorf("Expected flag usage to mention 'down', 'up', 'both', got %q", usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepTreeStatusFlag(t *testing.T) {
|
||||
// Test that the --status flag exists on depTreeCmd
|
||||
flag := depTreeCmd.Flags().Lookup("status")
|
||||
if flag == nil {
|
||||
t.Fatal("depTreeCmd should have --status flag")
|
||||
}
|
||||
|
||||
// Test default value is empty string
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("Expected default status='', got %q", flag.DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterTreeByStatus(t *testing.T) {
|
||||
tree := []*types.TreeNode{
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-1", Title: "Parent", Status: types.StatusOpen},
|
||||
Depth: 0,
|
||||
ParentID: "",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-2", Title: "Open Child", Status: types.StatusOpen},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-3", Title: "Closed Child", Status: types.StatusClosed},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-4", Title: "Open Grandchild", Status: types.StatusOpen},
|
||||
Depth: 2,
|
||||
ParentID: "BD-3",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("filter to open only", func(t *testing.T) {
|
||||
filtered := filterTreeByStatus(tree, types.StatusOpen)
|
||||
|
||||
// Should include BD-1, BD-2, and BD-4 (matching)
|
||||
// Plus BD-3 as ancestor of BD-4
|
||||
ids := make(map[string]bool)
|
||||
for _, node := range filtered {
|
||||
ids[node.ID] = true
|
||||
}
|
||||
|
||||
if !ids["BD-1"] {
|
||||
t.Error("Expected BD-1 (root open) in filtered tree")
|
||||
}
|
||||
if !ids["BD-2"] {
|
||||
t.Error("Expected BD-2 (open child) in filtered tree")
|
||||
}
|
||||
if !ids["BD-3"] {
|
||||
t.Error("Expected BD-3 (ancestor of open node) in filtered tree")
|
||||
}
|
||||
if !ids["BD-4"] {
|
||||
t.Error("Expected BD-4 (open grandchild) in filtered tree")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter to closed only", func(t *testing.T) {
|
||||
filtered := filterTreeByStatus(tree, types.StatusClosed)
|
||||
|
||||
ids := make(map[string]bool)
|
||||
for _, node := range filtered {
|
||||
ids[node.ID] = true
|
||||
}
|
||||
|
||||
// Should include BD-3 (matching) and BD-1 (ancestor)
|
||||
if !ids["BD-1"] {
|
||||
t.Error("Expected BD-1 (ancestor) in filtered tree")
|
||||
}
|
||||
if !ids["BD-3"] {
|
||||
t.Error("Expected BD-3 (closed) in filtered tree")
|
||||
}
|
||||
if ids["BD-2"] {
|
||||
t.Error("BD-2 should not be in closed-filtered tree")
|
||||
}
|
||||
if ids["BD-4"] {
|
||||
t.Error("BD-4 should not be in closed-filtered tree")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter to non-existent status", func(t *testing.T) {
|
||||
filtered := filterTreeByStatus(tree, types.StatusBlocked)
|
||||
if len(filtered) != 0 {
|
||||
t.Errorf("Expected empty tree when filtering to non-matching status, got %d nodes", len(filtered))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filter empty tree", func(t *testing.T) {
|
||||
filtered := filterTreeByStatus([]*types.TreeNode{}, types.StatusOpen)
|
||||
if len(filtered) != 0 {
|
||||
t.Errorf("Expected empty tree, got %d nodes", len(filtered))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatTreeNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
node *types.TreeNode
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "open issue at depth 0 shows READY",
|
||||
node: &types.TreeNode{
|
||||
Issue: types.Issue{
|
||||
ID: "BD-1",
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
},
|
||||
Depth: 0,
|
||||
},
|
||||
contains: []string{"BD-1", "Test Issue", "P2", "open", "[READY]"},
|
||||
},
|
||||
{
|
||||
name: "open issue at depth 1 does not show READY",
|
||||
node: &types.TreeNode{
|
||||
Issue: types.Issue{
|
||||
ID: "BD-2",
|
||||
Title: "Child Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
},
|
||||
Depth: 1,
|
||||
},
|
||||
contains: []string{"BD-2", "Child Issue", "P1", "open"},
|
||||
},
|
||||
{
|
||||
name: "closed issue",
|
||||
node: &types.TreeNode{
|
||||
Issue: types.Issue{
|
||||
ID: "BD-3",
|
||||
Title: "Done Issue",
|
||||
Status: types.StatusClosed,
|
||||
Priority: 3,
|
||||
},
|
||||
Depth: 0,
|
||||
},
|
||||
contains: []string{"BD-3", "Done Issue", "P3", "closed"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatTreeNode(tt.node)
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(result, want) {
|
||||
t.Errorf("formatTreeNode() = %q, want to contain %q", result, want)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-root open issues, verify READY is NOT shown
|
||||
if tt.node.Status == types.StatusOpen && tt.node.Depth > 0 {
|
||||
if strings.Contains(result, "[READY]") {
|
||||
t.Errorf("formatTreeNode() = %q, should NOT contain [READY] for depth > 0", result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTreeOutput(t *testing.T) {
|
||||
// Test tree with proper connectors
|
||||
tree := []*types.TreeNode{
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-1", Title: "Root", Status: types.StatusOpen, Priority: 1},
|
||||
Depth: 0,
|
||||
ParentID: "",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-2", Title: "Child 1", Status: types.StatusOpen, Priority: 2},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-3", Title: "Child 2", Status: types.StatusClosed, Priority: 2},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-4", Title: "Grandchild", Status: types.StatusOpen, Priority: 3},
|
||||
Depth: 2,
|
||||
ParentID: "BD-2",
|
||||
},
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
renderTree(tree, 50, "down")
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Check for tree connectors
|
||||
if !strings.Contains(output, "├──") && !strings.Contains(output, "└──") {
|
||||
t.Errorf("Expected tree connectors (├── or └──) in output, got:\n%s", output)
|
||||
}
|
||||
|
||||
// Check that all nodes are present
|
||||
for _, node := range tree {
|
||||
if !strings.Contains(output, node.ID) {
|
||||
t.Errorf("Expected node %s in output, got:\n%s", node.ID, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user