1package tui
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9func TestRightBarColumnsFallsBackWhenMainPaneWouldBeTooNarrow(t *testing.T) {
10 if _, _, ok := RightBarColumns(72); ok {
11 t.Fatal("right bar should fall back at 72 columns")
12 }
13 main, rail, ok := RightBarColumns(73)
14 if !ok {
15 t.Fatal("right bar should fit at 73 columns")
16 }
17 if main < RightBarMinMainWidth || rail < RightBarMinWidth || rail > RightBarMaxWidth {
18 t.Fatalf("right bar columns = main %d, rail %d", main, rail)
19 }
20 if main+RightBarSeparatorWidth+rail != 73 {
21 t.Fatalf("column budget = %d, want 73", main+RightBarSeparatorWidth+rail)
22 }
23}
24
25func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
26 lines := RenderRightBar(Dark, []RightBarWidget{
27 {Extension: "zeta", ID: "second", Title: "Z", Lines: []string{"z"}},
28 {Extension: "alpha", ID: "first", Title: "A", Lines: []string{"a"}},
29 }, 24, 7)
30 if len(lines) != 7 {
31 t.Fatalf("right bar rows = %d, want 7", len(lines))
32 }
33 for idx, line := range lines {
34 if width := visibleWidth(line); width != 24 {
35 t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line)
36 }
37 }
38 joined := stripANSI(strings.Join(lines, "\n"))
39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40 t.Fatalf("widgets are not deterministic by extension: %q", joined)
41 }
42 if strings.ContainsAny(joined, "┌┐└┘─") {
43 t.Fatalf("right bar added an outer border: %q", joined)
44 }
45
46 truncated := RenderRightBar(Dark, []RightBarWidget{
47 {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}},
48 }, 24, 7)
49 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
50 t.Fatalf("truncation marker missing: %q", truncated)
51 }
52}
53
54func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
55 lines := RenderRightBar(Dark, []RightBarWidget{{
56 Extension: "plan",
57 Lines: []string{"one two three four five six"},
58 }}, 16, 8)
59
60 if len(lines) != 8 {
61 t.Fatalf("right bar rows = %d, want 8", len(lines))
62 }
63 plain := stripANSI(strings.Join(lines, "\n"))
64 if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") {
65 t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
66 }
67 for idx, line := range lines {
68 if width := visibleWidth(line); width != 16 {
69 t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
70 }
71 }
72}
73
74func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
75 lines := RenderRightBar(Dark, []RightBarWidget{
76 {
77 Extension: "tasked-phases",
78 Lines: []string{"[ ] A phase title that is much too long to fit 0/3"},
79 },
80 }, 24, 5)
81 plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n")
82 if len(plainLines) < 3 {
83 t.Fatalf("phase row is missing: %q", plainLines)
84 }
85 phase := strings.TrimRight(plainLines[2], " ")
86 if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") {
87 t.Fatalf("phase progress was clipped with its title: %q", phase)
88 }
89}
90
91func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
92 lines := RenderRightBar(Dark, []RightBarWidget{{
93 Extension: "tasked-phases",
94 Title: "p 0/1 | t 0/1",
95 Lines: []string{
96 "[>] Copper Lantern Setup 0/1",
97 " [ ] Sort the leftover star stickers",
98 },
99 }}, 36, 8)
100 plain := stripANSI(strings.Join(lines, "\n"))
101 if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
102 t.Fatalf("phase row has the wrong indentation: %q", plain)
103 }
104 plainLines := strings.Split(plain, "\n")
105 if len(plainLines) < 4 {
106 t.Fatalf("checklist rows are missing: %q", plain)
107 }
108 expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
109 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111 }
112 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
113 t.Fatalf("phase name is not bold: %q", lines)
114 }
115 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
116 t.Fatalf("task text should not be bold: %q", lines)
117 }
118 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
119 t.Fatalf("widget title is not followed by an empty row: %q", plain)
120 }
121}
122
123func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
124 lines := RenderRightBar(Dark, []RightBarWidget{{
125 Extension: "plan",
126 Lines: []string{"[x] done", "[>] active", "[ ] pending"},
127 }}, 24, 8)
128 rendered := strings.Join(lines, "\n")
129 for _, marker := range []string{"[x]", "[>]", "[ ]"} {
130 if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) &&
131 !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) {
132 t.Fatalf("marker %q was not muted: %q", marker, rendered)
133 }
134 }
135 if !strings.Contains(stripANSI(rendered), " done") {
136 t.Fatalf("checklist text was not kept readable: %q", rendered)
137 }
138}
139
140func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
141 lines := RenderRightBar(Dark, []RightBarWidget{{
142 Extension: "plan",
143 Lines: []string{
144 "[x] finished phase",
145 " [x] finished task",
146 "[>] active phase",
147 " [>] active task",
148 "[ ] pending phase",
149 " [ ] pending task",
150 },
151 }}, 32, 12)
152 for _, line := range lines {
153 if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
154 if strings.Contains(line, "\x1b[2m") {
155 t.Fatalf("active checklist row was dimmed: %q", line)
156 }
157 }
158 }
159 plain := stripANSI(strings.Join(lines, "\n"))
160 for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
161 if !strings.Contains(plain, text) {
162 t.Fatalf("inactive checklist text %q missing: %q", text, plain)
163 }
164 }
165 if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
166 t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
167 }
168}
169
170func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
171 line := JoinRightBar(Dark, "main", "right", 8, 8)
172 if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) {
173 t.Fatalf("separator is not muted: %q", line)
174 }
175 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
176 t.Fatalf("joined right bar added an outer rule: %q", line)
177 }
178}
179
180func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
181 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
182 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
183 t.Fatalf("joined width = %d, want %d: %q", got, want, line)
184 }
185}
186
187func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
188 t.Setenv("TERM_PROGRAM", "")
189
190 var out bytes.Buffer
191 r := NewRenderer(&out)
192 r.SetTheme(Dark)
193 r.Resize(80, 5)
194 r.DrawRightBar(
195 []string{"chat"},
196 []string{"input"},
197 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
198 0,
199 0,
200 )
201
202 out.Reset()
203 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
204 if !strings.Contains(stripANSI(out.String()), "input") {
205 t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
206 }
207 if strings.Contains(out.String(), SeqClearScrollback) {
208 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
209 }
210}
211
212func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
213 var out bytes.Buffer
214 r := NewRenderer(&out)
215 r.SetTheme(Dark)
216 r.Resize(80, 5)
217 r.DrawRightBar(
218 []string{"chat"},
219 []string{"input"},
220 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5),
221 0,
222 0,
223 )
224 if !strings.Contains(stripANSI(out.String()), "Old") {
225 t.Fatalf("initial right-bar frame omitted widget: %q", out.String())
226 }
227
228 out.Reset()
229 r.DrawRightBar(
230 []string{"chat"},
231 []string{"input"},
232 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5),
233 0,
234 0,
235 )
236 if !strings.Contains(stripANSI(out.String()), "Updated") {
237 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
238 }
239 if r.logInit {
240 t.Fatal("right-bar mode left flow renderer initialized")
241 }
242
243 out.Reset()
244 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
245 if !r.logInit {
246 t.Fatal("flow renderer was not reinitialized after right-bar clearing")
247 }
248
249 out.Reset()
250 r.Resize(100, 6)
251 if out.Len() == 0 {
252 t.Fatal("resize did not invalidate the right-bar frame")
253 }
254 main, rail, ok := RightBarColumns(100)
255 if !ok {
256 t.Fatal("right bar should fit after resize")
257 }
258 if main+RightBarSeparatorWidth+rail != 100 {
259 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
260 }
261}