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 thr...") || 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 TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) {
75 lines := RenderRightBar(Dark, []RightBarWidget{{
76 Extension: "tasked-phases",
77 Title: "p 0/1 | t 0/1",
78 Lines: []string{"[>] A phase title that is much too long to fit 0/1"},
79 }}, 24, 4)
80
81 for idx, line := range lines {
82 plain := stripANSI(line)
83 if !strings.HasSuffix(plain, " ") {
84 t.Fatalf("right bar line %d lacks right padding: %q", idx, plain)
85 }
86 if visibleWidth(strings.TrimRight(plain, " ")) > 23 {
87 t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain)
88 }
89 }
90}
91
92func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
93 lines := RenderRightBar(Dark, []RightBarWidget{
94 {
95 Extension: "tasked-phases",
96 Lines: []string{"[ ] A phase title that is much too long to fit 0/3"},
97 },
98 }, 24, 5)
99 plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n")
100 if len(plainLines) < 3 {
101 t.Fatalf("phase row is missing: %q", plainLines)
102 }
103 phase := strings.TrimRight(plainLines[2], " ")
104 if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") {
105 t.Fatalf("phase progress was clipped with its title: %q", phase)
106 }
107}
108
109func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) {
110 got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9)
111 if want := "[>] 0/1"; got != want {
112 t.Fatalf("truncated phase = %q, want %q", got, want)
113 }
114}
115
116func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
117 lines := RenderRightBar(Dark, []RightBarWidget{{
118 Extension: "tasked-phases",
119 Title: "p 0/1 | t 0/1",
120 Lines: []string{
121 "[>] Copper Lantern Setup 0/1",
122 " [ ] Sort the leftover star stickers",
123 },
124 }}, 36, 8)
125 plain := stripANSI(strings.Join(lines, "\n"))
126 if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
127 t.Fatalf("phase row has the wrong indentation: %q", plain)
128 }
129 plainLines := strings.Split(plain, "\n")
130 if len(plainLines) < 4 {
131 t.Fatalf("checklist rows are missing: %q", plain)
132 }
133 const expectedTask = " [ ] Sort the leftover star sti..."
134 if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
135 t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
136 }
137 if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
138 t.Fatalf("phase name is not bold: %q", lines)
139 }
140 if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
141 t.Fatalf("task text should not be bold: %q", lines)
142 }
143 if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
144 t.Fatalf("widget title is not followed by an empty row: %q", plain)
145 }
146}
147
148func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
149 lines := RenderRightBar(Dark, []RightBarWidget{{
150 Extension: "plan",
151 Lines: []string{"[x] done", "[>] active", "[ ] pending"},
152 }}, 24, 8)
153 rendered := strings.Join(lines, "\n")
154 for _, marker := range []string{"[x]", "[>]", "[ ]"} {
155 if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) &&
156 !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) {
157 t.Fatalf("marker %q was not muted: %q", marker, rendered)
158 }
159 }
160 if !strings.Contains(stripANSI(rendered), " done") {
161 t.Fatalf("checklist text was not kept readable: %q", rendered)
162 }
163}
164
165func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
166 lines := RenderRightBar(Dark, []RightBarWidget{{
167 Extension: "plan",
168 Lines: []string{
169 "[x] finished phase",
170 " [x] finished task",
171 "[>] active phase",
172 " [>] active task",
173 "[ ] pending phase",
174 " [ ] pending task",
175 },
176 }}, 32, 12)
177 for _, line := range lines {
178 if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
179 if strings.Contains(line, "\x1b[2m") {
180 t.Fatalf("active checklist row was dimmed: %q", line)
181 }
182 }
183 }
184 plain := stripANSI(strings.Join(lines, "\n"))
185 for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
186 if !strings.Contains(plain, text) {
187 t.Fatalf("inactive checklist text %q missing: %q", text, plain)
188 }
189 }
190 if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
191 t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
192 }
193}
194
195func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
196 line := JoinRightBar(Dark, "main", "right", 8, 8)
197 if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) {
198 t.Fatalf("separator is not muted: %q", line)
199 }
200 if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
201 t.Fatalf("joined right bar added an outer rule: %q", line)
202 }
203}
204
205func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
206 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
207 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
208 t.Fatalf("joined width = %d, want %d: %q", got, want, line)
209 }
210}
211
212func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
213 t.Setenv("TERM_PROGRAM", "")
214
215 var out bytes.Buffer
216 r := NewRenderer(&out)
217 r.SetTheme(Dark)
218 r.Resize(80, 5)
219 r.DrawRightBar(
220 []string{"chat"},
221 []string{"input"},
222 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
223 0,
224 0,
225 )
226
227 out.Reset()
228 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
229 if !strings.Contains(stripANSI(out.String()), "input") {
230 t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
231 }
232 if strings.Contains(out.String(), SeqClearScrollback) {
233 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
234 }
235}
236
237func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
238 var out bytes.Buffer
239 r := NewRenderer(&out)
240 r.SetTheme(Dark)
241 r.Resize(80, 5)
242 r.DrawRightBar(
243 []string{"chat"},
244 []string{"input"},
245 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5),
246 0,
247 0,
248 )
249 if !strings.Contains(stripANSI(out.String()), "Old") {
250 t.Fatalf("initial right-bar frame omitted widget: %q", out.String())
251 }
252
253 out.Reset()
254 r.DrawRightBar(
255 []string{"chat"},
256 []string{"input"},
257 RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5),
258 0,
259 0,
260 )
261 if !strings.Contains(stripANSI(out.String()), "Updated") {
262 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
263 }
264 if r.logInit {
265 t.Fatal("right-bar mode left flow renderer initialized")
266 }
267
268 out.Reset()
269 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
270 if !r.logInit {
271 t.Fatal("flow renderer was not reinitialized after right-bar clearing")
272 }
273
274 out.Reset()
275 r.Resize(100, 6)
276 if out.Len() == 0 {
277 t.Fatal("resize did not invalidate the right-bar frame")
278 }
279 main, rail, ok := RightBarColumns(100)
280 if !ok {
281 t.Fatal("right bar should fit after resize")
282 }
283 if main+RightBarSeparatorWidth+rail != 100 {
284 t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
285 }
286}
287
288func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) {
289 var out bytes.Buffer
290 r := NewRenderer(&out)
291 r.SetTheme(Dark)
292 r.Resize(80, 5)
293
294 r.DrawRightBarDimmed(
295 DimLines([]string{"chat"}),
296 DimLines([]string{"input"}),
297 DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)),
298 0,
299 0,
300 )
301
302 separator := Dim(Dark.FGColor(Dark.Muted, "│"))
303 if !strings.Contains(out.String(), separator) {
304 t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String())
305 }
306}