feat: add Mermaid.js format for dependency tree visualization (#191)
Adds `bd dep tree --format mermaid` to export dependency trees as Mermaid.js flowcharts. Features: - Status indicators: ☐ open, ◧ in_progress, ⚠ blocked, ☑ closed - Theme-agnostic design - Works with --reverse flag - Comprehensive unit tests following TDD Co-authored-by: David Laing <david@davidlaing.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -266,3 +270,217 @@ func TestDepRemove(t *testing.T) {
|
||||
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepTreeFormatFlag(t *testing.T) {
|
||||
// Test that the --format flag exists on depTreeCmd
|
||||
flag := depTreeCmd.Flags().Lookup("format")
|
||||
if flag == nil {
|
||||
t.Fatal("depTreeCmd should have --format flag")
|
||||
}
|
||||
|
||||
// Test default value is empty string
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("Expected default format='', got %q", flag.DefValue)
|
||||
}
|
||||
|
||||
// Test usage text mentions mermaid
|
||||
if !strings.Contains(flag.Usage, "mermaid") {
|
||||
t.Errorf("Expected flag usage to mention 'mermaid', got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatusEmoji(t *testing.T) {
|
||||
tests := []struct {
|
||||
status types.Status
|
||||
want string
|
||||
}{
|
||||
{types.StatusOpen, "☐"},
|
||||
{types.StatusInProgress, "◧"},
|
||||
{types.StatusBlocked, "⚠"},
|
||||
{types.StatusClosed, "☑"},
|
||||
{types.Status("unknown"), "?"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.status), func(t *testing.T) {
|
||||
got := getStatusEmoji(tt.status)
|
||||
if got != tt.want {
|
||||
t.Errorf("getStatusEmoji(%q) = %q, want %q", tt.status, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputMermaidTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tree []*types.TreeNode
|
||||
rootID string
|
||||
want []string // Lines that must appear in output
|
||||
}{
|
||||
{
|
||||
name: "empty tree",
|
||||
tree: []*types.TreeNode{},
|
||||
rootID: "test-1",
|
||||
want: []string{
|
||||
"flowchart TD",
|
||||
`test-1["No dependencies"]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single dependency",
|
||||
tree: []*types.TreeNode{
|
||||
{
|
||||
Issue: types.Issue{ID: "test-1", Title: "Task 1", Status: types.StatusInProgress},
|
||||
Depth: 0,
|
||||
ParentID: "",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "test-2", Title: "Task 2", Status: types.StatusClosed},
|
||||
Depth: 1,
|
||||
ParentID: "test-1",
|
||||
},
|
||||
},
|
||||
rootID: "test-1",
|
||||
want: []string{
|
||||
"flowchart TD",
|
||||
`test-1["◧ test-1: Task 1"]`,
|
||||
`test-2["☑ test-2: Task 2"]`,
|
||||
"test-1 --> test-2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple dependencies",
|
||||
tree: []*types.TreeNode{
|
||||
{
|
||||
Issue: types.Issue{ID: "test-1", Title: "Main", Status: types.StatusOpen},
|
||||
Depth: 0,
|
||||
ParentID: "",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "test-2", Title: "Sub 1", Status: types.StatusClosed},
|
||||
Depth: 1,
|
||||
ParentID: "test-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "test-3", Title: "Sub 2", Status: types.StatusBlocked},
|
||||
Depth: 1,
|
||||
ParentID: "test-1",
|
||||
},
|
||||
},
|
||||
rootID: "test-1",
|
||||
want: []string{
|
||||
"flowchart TD",
|
||||
`test-1["☐ test-1: Main"]`,
|
||||
`test-2["☑ test-2: Sub 1"]`,
|
||||
`test-3["⚠ test-3: Sub 2"]`,
|
||||
"test-1 --> test-2",
|
||||
"test-1 --> test-3",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
outputMermaidTree(tt.tree, tt.rootID)
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Verify all expected lines appear
|
||||
for _, line := range tt.want {
|
||||
if !strings.Contains(output, line) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", line, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputMermaidTree_Siblings(t *testing.T) {
|
||||
// Test case: Siblings with children (reproduces issue with wrong parent inference)
|
||||
// Structure:
|
||||
// BD-1 (root)
|
||||
// ├── BD-2 (sibling 1)
|
||||
// │ └── BD-4 (child of BD-2)
|
||||
// └── BD-3 (sibling 2)
|
||||
// └── BD-5 (child of BD-3)
|
||||
tree := []*types.TreeNode{
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-1", Title: "Parent", Status: types.StatusOpen},
|
||||
Depth: 0,
|
||||
ParentID: "",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-2", Title: "Sibling 1", Status: types.StatusOpen},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-3", Title: "Sibling 2", Status: types.StatusOpen},
|
||||
Depth: 1,
|
||||
ParentID: "BD-1",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-4", Title: "Child of Sibling 1", Status: types.StatusOpen},
|
||||
Depth: 2,
|
||||
ParentID: "BD-2",
|
||||
},
|
||||
{
|
||||
Issue: types.Issue{ID: "BD-5", Title: "Child of Sibling 2", Status: types.StatusOpen},
|
||||
Depth: 2,
|
||||
ParentID: "BD-3",
|
||||
},
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
outputMermaidTree(tree, "BD-1")
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
// Verify correct edges exist
|
||||
correctEdges := []string{
|
||||
"BD-1 --> BD-2",
|
||||
"BD-1 --> BD-3",
|
||||
"BD-2 --> BD-4",
|
||||
"BD-3 --> BD-5",
|
||||
}
|
||||
|
||||
for _, edge := range correctEdges {
|
||||
if !strings.Contains(output, edge) {
|
||||
t.Errorf("expected edge %q to be present, got:\n%s", edge, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify incorrect edges do NOT exist (siblings shouldn't be connected)
|
||||
incorrectEdges := []string{
|
||||
"BD-2 --> BD-3", // Siblings shouldn't be connected
|
||||
"BD-3 --> BD-4", // BD-4's parent is BD-2, not BD-3
|
||||
"BD-4 --> BD-3", // Wrong direction
|
||||
"BD-4 --> BD-5", // These are cousins, not parent-child
|
||||
}
|
||||
|
||||
for _, edge := range incorrectEdges {
|
||||
if strings.Contains(output, edge) {
|
||||
t.Errorf("incorrect edge %q should NOT be present, got:\n%s", edge, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user