tree_test.go
1package main
2
3import (
4 "io"
5 "os"
6 "strings"
7 "testing"
8 "time"
9)
10
11func TestApplySort_AgeDescOldestFirst(t *testing.T) {
12 now := time.Now()
13 issues := []BugIssue{
14 {FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
15 {FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
16 }
17
18 ApplySort(issues, &SortSpec{Field: "age", Direction: "desc"})
19
20 if issues[0].FullID != "older" {
21 t.Errorf("age:desc first = %q, want %q (oldest first)", issues[0].FullID, "older")
22 }
23}
24
25func TestApplySort_AgeAscNewestFirst(t *testing.T) {
26 now := time.Now()
27 issues := []BugIssue{
28 {FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
29 {FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
30 }
31
32 ApplySort(issues, &SortSpec{Field: "age", Direction: "asc"})
33
34 if issues[0].FullID != "newer" {
35 t.Errorf("age:asc first = %q, want %q (newest first)", issues[0].FullID, "newer")
36 }
37}
38
39func TestBuildIssueTree_NestsChildrenUnderParent(t *testing.T) {
40 now := time.Now()
41 issues := []BugIssue{
42 {FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
43 {FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
44 {FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
45 }
46
47 roots := buildIssueTree(issues)
48
49 if len(roots) != 1 {
50 t.Fatalf("roots = %d, want 1", len(roots))
51 }
52 if len(roots[0].Children) != 2 {
53 t.Fatalf("children = %d, want 2", len(roots[0].Children))
54 }
55}
56
57func TestBuildIssueTree_SortsRootsAndChildrenOldestFirst(t *testing.T) {
58 now := time.Now()
59 issues := []BugIssue{
60 {FullID: "old-parent", ShortID: "old", Title: "Old parent", CreatedAt: now.Add(-48 * time.Hour)},
61 {FullID: "new-parent", ShortID: "new", Title: "New parent", CreatedAt: now.Add(-24 * time.Hour)},
62 {FullID: "old-child", ShortID: "oldc", Title: "Old child", CreatedAt: now.Add(-2 * time.Hour), ParentID: "old-parent"},
63 {FullID: "new-child", ShortID: "newc", Title: "New child", CreatedAt: now.Add(-1 * time.Hour), ParentID: "old-parent"},
64 }
65
66 roots := buildIssueTree(issues)
67
68 if len(roots) != 2 {
69 t.Fatalf("roots = %d, want 2", len(roots))
70 }
71 if roots[0].Issue.FullID != "old-parent" {
72 t.Errorf("first root = %q, want %q (oldest parent first)", roots[0].Issue.FullID, "old-parent")
73 }
74 if len(roots[0].Children) != 2 {
75 t.Fatalf("children = %d, want 2", len(roots[0].Children))
76 }
77 if roots[0].Children[0].Issue.FullID != "old-child" {
78 t.Errorf("first child = %q, want %q (oldest child first)", roots[0].Children[0].Issue.FullID, "old-child")
79 }
80 if roots[0].Children[1].Issue.FullID != "new-child" {
81 t.Errorf("second child = %q, want %q (youngest child last)", roots[0].Children[1].Issue.FullID, "new-child")
82 }
83}
84
85func TestBuildIssueTree_OrphanParentTreatedAsRoot(t *testing.T) {
86 now := time.Now()
87 issues := []BugIssue{
88 {FullID: "orphan", ShortID: "orphan", Title: "Orphan", CreatedAt: now, ParentID: "missing-parent"},
89 }
90
91 roots := buildIssueTree(issues)
92
93 if len(roots) != 1 {
94 t.Fatalf("roots = %d, want 1 (orphan treated as root)", len(roots))
95 }
96}
97
98func TestPrintTreeTable_ColumnsAligned(t *testing.T) {
99 now := time.Now()
100 issues := []BugIssue{
101 {FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
102 {FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
103 {FullID: "child2-full-id", ShortID: "child2", Title: "Child 2 with a longer title", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
104 }
105
106 roots := buildIssueTree(issues)
107 out := captureTreeOutput(t, roots)
108
109 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
110 if len(lines) < 4 {
111 t.Fatalf("expected header, separator, and 3 rows, got:\n%s", out)
112 }
113
114 // Every row's Summary, Labels, and Age columns must start at the
115 // same rune index as the header's, even with multibyte tree markers.
116 header := []rune(stripANSI(lines[0]))
117 summaryAt := strings.Index(string(header), "Summary")
118 labelsAt := strings.Index(string(header), "Labels")
119 ageAt := strings.Index(string(header), "Age")
120 if summaryAt < 0 || labelsAt < 0 || ageAt < 0 {
121 t.Fatalf("header missing columns:\n%s", out)
122 }
123
124 // The Summary text of each row must start exactly at summaryAt.
125 wantSummaries := []string{"Parent", "Child 1", "Child 2 with a longer title"}
126 for i, line := range lines[2:] {
127 row := stripANSI(line)
128 if len([]rune(row)) < summaryAt+len(wantSummaries[i]) {
129 t.Errorf("row %d too short:\n%s", i, line)
130 continue
131 }
132 if got := string([]rune(row)[summaryAt : summaryAt+len(wantSummaries[i])]); got != wantSummaries[i] {
133 t.Errorf("row %d summary at column %d = %q, want %q\nfull output:\n%s", i, summaryAt, got, wantSummaries[i], out)
134 }
135 }
136}
137
138func captureTreeOutput(t *testing.T, roots []*TreeNode) string {
139 t.Helper()
140
141 old := os.Stdout
142 r, w, err := os.Pipe()
143 if err != nil {
144 t.Fatalf("os.Pipe failed: %v", err)
145 }
146 os.Stdout = w
147
148 printErr := printTreeTable(roots)
149
150 _ = w.Close()
151 os.Stdout = old
152
153 if printErr != nil {
154 t.Fatalf("printTreeTable failed: %v", printErr)
155 }
156
157 out, err := io.ReadAll(r)
158 if err != nil {
159 t.Fatalf("reading captured output failed: %v", err)
160 }
161 return string(out)
162}
163
164func TestPrintTreeTable_ShowsChildMarkers(t *testing.T) {
165 now := time.Now()
166 issues := []BugIssue{
167 {FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
168 {FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
169 {FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
170 }
171
172 roots := buildIssueTree(issues)
173 out := captureTreeOutput(t, roots)
174
175 if !strings.Contains(out, "├─ ") {
176 t.Errorf("output missing non-last child marker %q\noutput:\n%s", "├─ ", out)
177 }
178 if !strings.Contains(out, "└─ ") {
179 t.Errorf("output missing last child marker %q\noutput:\n%s", "└─ ", out)
180 }
181}