test(graph): add tests for graph utility functions

Add comprehensive tests for the new graph.go functions to meet
coverage threshold after rebase introduced 405 new lines:

- TestTruncateTitle: tests rune-safe title truncation
- TestPadRight: tests rune-safe string padding
- TestRenderNodeBox: tests ASCII box rendering for all status types
- TestComputeLayout: tests topological layout computation with
  dependencies

These tests cover the pure utility functions and basic graph layout
logic, bringing coverage from 44.7% to 45.1%.
This commit is contained in:
Charles P. Cross
2025-12-18 17:56:24 -05:00
parent 2b0a8cecdb
commit 5316559cf6

265
cmd/bd/graph_test.go Normal file
View File

@@ -0,0 +1,265 @@
package main
import (
"testing"
"github.com/steveyegge/beads/internal/types"
)
func TestTruncateTitle(t *testing.T) {
tests := []struct {
name string
title string
maxLen int
want string
}{
{
name: "no truncation needed",
title: "Short title",
maxLen: 20,
want: "Short title",
},
{
name: "exact length",
title: "Exact",
maxLen: 5,
want: "Exact",
},
{
name: "needs truncation",
title: "This is a very long title that needs to be truncated",
maxLen: 20,
want: "This is a very long…",
},
{
name: "unicode safe",
title: "日本語タイトル",
maxLen: 5,
want: "日本語タ…",
},
{
name: "empty string",
title: "",
maxLen: 10,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateTitle(tt.title, tt.maxLen)
if got != tt.want {
t.Errorf("truncateTitle(%q, %d) = %q, want %q", tt.title, tt.maxLen, got, tt.want)
}
})
}
}
func TestPadRight(t *testing.T) {
tests := []struct {
name string
s string
width int
want string
}{
{
name: "needs padding",
s: "abc",
width: 6,
want: "abc ",
},
{
name: "exact width",
s: "exact",
width: 5,
want: "exact",
},
{
name: "truncates when too long",
s: "toolong",
width: 4,
want: "tool",
},
{
name: "empty string",
s: "",
width: 3,
want: " ",
},
{
name: "unicode safe",
s: "日本",
width: 5,
want: "日本 ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := padRight(tt.s, tt.width)
if got != tt.want {
t.Errorf("padRight(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want)
}
})
}
}
func TestRenderNodeBox(t *testing.T) {
tests := []struct {
name string
node *GraphNode
width int
notEmpty bool
}{
{
name: "open status",
node: &GraphNode{
Issue: &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
},
},
width: 20,
notEmpty: true,
},
{
name: "in progress status",
node: &GraphNode{
Issue: &types.Issue{
ID: "test-2",
Title: "In Progress Issue",
Status: types.StatusInProgress,
},
},
width: 25,
notEmpty: true,
},
{
name: "blocked status",
node: &GraphNode{
Issue: &types.Issue{
ID: "test-3",
Title: "Blocked Issue",
Status: types.StatusBlocked,
},
},
width: 20,
notEmpty: true,
},
{
name: "closed status",
node: &GraphNode{
Issue: &types.Issue{
ID: "test-4",
Title: "Closed Issue",
Status: types.StatusClosed,
},
},
width: 20,
notEmpty: true,
},
{
name: "unknown status",
node: &GraphNode{
Issue: &types.Issue{
ID: "test-5",
Title: "Unknown Status",
Status: "unknown",
},
},
width: 20,
notEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := renderNodeBox(tt.node, tt.width)
if tt.notEmpty && len(got) == 0 {
t.Error("renderNodeBox() returned empty string")
}
// Verify the output contains expected elements
if !contains(got, tt.node.Issue.ID) {
t.Errorf("renderNodeBox() output missing issue ID %s", tt.node.Issue.ID)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && stringContains(s, substr)))
}
func stringContains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestComputeLayout(t *testing.T) {
t.Run("empty subgraph", func(t *testing.T) {
subgraph := &TemplateSubgraph{
Root: &types.Issue{ID: "root-1", Title: "Root"},
Issues: []*types.Issue{},
Dependencies: []*types.Dependency{},
IssueMap: make(map[string]*types.Issue),
}
layout := computeLayout(subgraph)
if layout == nil {
t.Fatal("computeLayout returned nil")
}
if layout.RootID != "root-1" {
t.Errorf("RootID = %q, want %q", layout.RootID, "root-1")
}
})
t.Run("single issue", func(t *testing.T) {
issue := &types.Issue{ID: "test-1", Title: "Test Issue"}
subgraph := &TemplateSubgraph{
Root: issue,
Issues: []*types.Issue{issue},
Dependencies: []*types.Dependency{},
IssueMap: map[string]*types.Issue{"test-1": issue},
}
layout := computeLayout(subgraph)
if len(layout.Nodes) != 1 {
t.Errorf("len(Nodes) = %d, want 1", len(layout.Nodes))
}
if layout.Nodes["test-1"].Layer != 0 {
t.Errorf("Node layer = %d, want 0", layout.Nodes["test-1"].Layer)
}
})
t.Run("with dependencies", func(t *testing.T) {
issue1 := &types.Issue{ID: "test-1", Title: "First"}
issue2 := &types.Issue{ID: "test-2", Title: "Second"}
dep := &types.Dependency{
IssueID: "test-2",
DependsOnID: "test-1",
Type: types.DepBlocks,
}
subgraph := &TemplateSubgraph{
Root: issue1,
Issues: []*types.Issue{issue1, issue2},
Dependencies: []*types.Dependency{dep},
IssueMap: map[string]*types.Issue{"test-1": issue1, "test-2": issue2},
}
layout := computeLayout(subgraph)
if len(layout.Nodes) != 2 {
t.Errorf("len(Nodes) = %d, want 2", len(layout.Nodes))
}
// test-1 has no dependencies, should be layer 0
if layout.Nodes["test-1"].Layer != 0 {
t.Errorf("test-1 layer = %d, want 0", layout.Nodes["test-1"].Layer)
}
// test-2 depends on test-1, should be layer 1
if layout.Nodes["test-2"].Layer != 1 {
t.Errorf("test-2 layer = %d, want 1", layout.Nodes["test-2"].Layer)
}
})
}