BT  ·  2026-09-13

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}