1package list
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/charmbracelet/glamour/v2/ansi"
8 uv "github.com/charmbracelet/ultraviolet"
9)
10
11func TestRenderHelper(t *testing.T) {
12 items := []Item{
13 NewStringItem("Item 1"),
14 NewStringItem("Item 2"),
15 NewStringItem("Item 3"),
16 }
17
18 l := New(items...)
19 l.SetSize(80, 10)
20
21 // Render to string
22 output := l.Render()
23
24 if len(output) == 0 {
25 t.Error("expected non-empty output from Render()")
26 }
27
28 // Check that output contains the items
29 if !strings.Contains(output, "Item 1") {
30 t.Error("expected output to contain 'Item 1'")
31 }
32 if !strings.Contains(output, "Item 2") {
33 t.Error("expected output to contain 'Item 2'")
34 }
35 if !strings.Contains(output, "Item 3") {
36 t.Error("expected output to contain 'Item 3'")
37 }
38}
39
40func TestRenderWithScrolling(t *testing.T) {
41 items := []Item{
42 NewStringItem("Item 1"),
43 NewStringItem("Item 2"),
44 NewStringItem("Item 3"),
45 NewStringItem("Item 4"),
46 NewStringItem("Item 5"),
47 }
48
49 l := New(items...)
50 l.SetSize(80, 2) // Small viewport
51
52 // Initial render should show first 2 items
53 output := l.Render()
54 if !strings.Contains(output, "Item 1") {
55 t.Error("expected output to contain 'Item 1'")
56 }
57 if !strings.Contains(output, "Item 2") {
58 t.Error("expected output to contain 'Item 2'")
59 }
60 if strings.Contains(output, "Item 3") {
61 t.Error("expected output to NOT contain 'Item 3' in initial view")
62 }
63
64 // Scroll down and render
65 l.ScrollBy(2)
66 output = l.Render()
67
68 // Now should show items 3 and 4
69 if strings.Contains(output, "Item 1") {
70 t.Error("expected output to NOT contain 'Item 1' after scrolling")
71 }
72 if !strings.Contains(output, "Item 3") {
73 t.Error("expected output to contain 'Item 3' after scrolling")
74 }
75 if !strings.Contains(output, "Item 4") {
76 t.Error("expected output to contain 'Item 4' after scrolling")
77 }
78}
79
80func TestRenderEmptyList(t *testing.T) {
81 l := New()
82 l.SetSize(80, 10)
83
84 output := l.Render()
85 if output != "" {
86 t.Errorf("expected empty output for empty list, got: %q", output)
87 }
88}
89
90func TestRenderVsDrawConsistency(t *testing.T) {
91 items := []Item{
92 NewStringItem("Item 1"),
93 NewStringItem("Item 2"),
94 }
95
96 l := New(items...)
97 l.SetSize(80, 10)
98
99 // Render using Render() method
100 renderOutput := l.Render()
101
102 // Render using Draw() method
103 screen := uv.NewScreenBuffer(80, 10)
104 area := uv.Rect(0, 0, 80, 10)
105 l.Draw(&screen, area)
106 drawOutput := screen.Render()
107
108 // Trim any trailing whitespace for comparison
109 renderOutput = strings.TrimRight(renderOutput, "\n")
110 drawOutput = strings.TrimRight(drawOutput, "\n")
111
112 // Both methods should produce the same output
113 if renderOutput != drawOutput {
114 t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
115 renderOutput, drawOutput)
116 }
117}
118
119func BenchmarkRender(b *testing.B) {
120 items := make([]Item, 100)
121 for i := range items {
122 items[i] = NewStringItem("Item content here")
123 }
124
125 l := New(items...)
126 l.SetSize(80, 24)
127 l.Render() // Prime the buffer
128
129 b.ResetTimer()
130 for i := 0; i < b.N; i++ {
131 _ = l.Render()
132 }
133}
134
135func BenchmarkRenderWithScrolling(b *testing.B) {
136 items := make([]Item, 1000)
137 for i := range items {
138 items[i] = NewStringItem("Item content here")
139 }
140
141 l := New(items...)
142 l.SetSize(80, 24)
143 l.Render() // Prime the buffer
144
145 b.ResetTimer()
146 for i := 0; i < b.N; i++ {
147 l.ScrollBy(1)
148 _ = l.Render()
149 }
150}
151
152func TestStringItemCache(t *testing.T) {
153 item := NewStringItem("Test content")
154
155 // First draw at width 80 should populate cache
156 screen1 := uv.NewScreenBuffer(80, 5)
157 area1 := uv.Rect(0, 0, 80, 5)
158 item.Draw(&screen1, area1)
159
160 if len(item.cache) != 1 {
161 t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
162 }
163 if _, ok := item.cache[80]; !ok {
164 t.Error("expected cache to have entry for width 80")
165 }
166
167 // Second draw at same width should reuse cache
168 screen2 := uv.NewScreenBuffer(80, 5)
169 area2 := uv.Rect(0, 0, 80, 5)
170 item.Draw(&screen2, area2)
171
172 if len(item.cache) != 1 {
173 t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
174 }
175
176 // Draw at different width should add to cache
177 screen3 := uv.NewScreenBuffer(40, 5)
178 area3 := uv.Rect(0, 0, 40, 5)
179 item.Draw(&screen3, area3)
180
181 if len(item.cache) != 2 {
182 t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
183 }
184 if _, ok := item.cache[40]; !ok {
185 t.Error("expected cache to have entry for width 40")
186 }
187}
188
189func TestWrappingItemHeight(t *testing.T) {
190 // Short text that fits in one line
191 item1 := NewWrappingStringItem("Short")
192 if h := item1.Height(80); h != 1 {
193 t.Errorf("expected height 1 for short text, got %d", h)
194 }
195
196 // Long text that wraps
197 longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
198 item2 := NewWrappingStringItem(longText)
199
200 // At width 80, should be fewer lines than width 20
201 height80 := item2.Height(80)
202 height20 := item2.Height(20)
203
204 if height20 <= height80 {
205 t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
206 height20, height80)
207 }
208
209 // Non-wrapping version should always be 1 line
210 item3 := NewStringItem(longText)
211 if h := item3.Height(20); h != 1 {
212 t.Errorf("expected height 1 for non-wrapping item, got %d", h)
213 }
214}
215
216func TestMarkdownItemBasic(t *testing.T) {
217 markdown := "# Hello\n\nThis is a **test**."
218 item := NewMarkdownItem(markdown)
219
220 // Test that height is calculated
221 height := item.Height(80)
222 if height < 1 {
223 t.Errorf("expected height >= 1, got %d", height)
224 }
225
226 // Test drawing
227 screen := uv.NewScreenBuffer(80, 10)
228 area := uv.Rect(0, 0, 80, 10)
229 item.Draw(&screen, area)
230
231 // Should not panic and should render something
232 rendered := screen.Render()
233 if len(rendered) == 0 {
234 t.Error("expected non-empty rendered output")
235 }
236}
237
238func TestMarkdownItemCache(t *testing.T) {
239 markdown := "# Test\n\nSome content."
240 item := NewMarkdownItem(markdown)
241
242 // First render at width 80 should populate cache
243 height1 := item.Height(80)
244 if len(item.cache) != 1 {
245 t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
246 }
247
248 // Second render at same width should reuse cache
249 height2 := item.Height(80)
250 if height1 != height2 {
251 t.Errorf("expected consistent height, got %d then %d", height1, height2)
252 }
253 if len(item.cache) != 1 {
254 t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
255 }
256
257 // Render at different width should add to cache
258 _ = item.Height(40)
259 if len(item.cache) != 2 {
260 t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
261 }
262}
263
264func TestMarkdownItemMaxCacheWidth(t *testing.T) {
265 markdown := "# Test\n\nSome content."
266 item := NewMarkdownItem(markdown).WithMaxWidth(50)
267
268 // Render at width 40 (below limit) - should cache at width 40
269 _ = item.Height(40)
270 if len(item.cache) != 1 {
271 t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
272 }
273
274 // Render at width 80 (above limit) - should cap to 50 and cache
275 _ = item.Height(80)
276 // Cache should have width 50 entry (capped from 80)
277 if len(item.cache) != 2 {
278 t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
279 }
280 if _, ok := item.cache[50]; !ok {
281 t.Error("expected cache to have entry for width 50 (capped from 80)")
282 }
283
284 // Render at width 100 (also above limit) - should reuse cached width 50
285 _ = item.Height(100)
286 if len(item.cache) != 2 {
287 t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
288 }
289}
290
291func TestMarkdownItemWithStyleConfig(t *testing.T) {
292 markdown := "# Styled\n\nContent with **bold** text."
293
294 // Create a custom style config
295 styleConfig := ansi.StyleConfig{
296 Document: ansi.StyleBlock{
297 Margin: uintPtr(0),
298 },
299 }
300
301 item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig)
302
303 // Render should use the custom style
304 height := item.Height(80)
305 if height < 1 {
306 t.Errorf("expected height >= 1, got %d", height)
307 }
308
309 // Draw should work without panic
310 screen := uv.NewScreenBuffer(80, 10)
311 area := uv.Rect(0, 0, 80, 10)
312 item.Draw(&screen, area)
313
314 rendered := screen.Render()
315 if len(rendered) == 0 {
316 t.Error("expected non-empty rendered output with custom style")
317 }
318}
319
320func TestMarkdownItemInList(t *testing.T) {
321 items := []Item{
322 NewMarkdownItem("# First\n\nMarkdown item."),
323 NewMarkdownItem("# Second\n\nAnother item."),
324 NewStringItem("Regular string item"),
325 }
326
327 l := New(items...)
328 l.SetSize(80, 20)
329
330 // Should render without error
331 output := l.Render()
332 if len(output) == 0 {
333 t.Error("expected non-empty output from list with markdown items")
334 }
335
336 // Should contain content from markdown items
337 if !strings.Contains(output, "First") {
338 t.Error("expected output to contain 'First'")
339 }
340 if !strings.Contains(output, "Second") {
341 t.Error("expected output to contain 'Second'")
342 }
343 if !strings.Contains(output, "Regular string item") {
344 t.Error("expected output to contain 'Regular string item'")
345 }
346}
347
348func TestMarkdownItemHeightWithWidth(t *testing.T) {
349 // Test that widths are capped to maxWidth
350 markdown := "This is a paragraph with some text."
351
352 item := NewMarkdownItem(markdown).WithMaxWidth(50)
353
354 // At width 30 (below limit), should cache and render at width 30
355 height30 := item.Height(30)
356 if height30 < 1 {
357 t.Errorf("expected height >= 1, got %d", height30)
358 }
359
360 // At width 100 (above maxWidth), should cap to 50 and cache
361 height100 := item.Height(100)
362 if height100 < 1 {
363 t.Errorf("expected height >= 1, got %d", height100)
364 }
365
366 // Both should be cached (width 30 and capped width 50)
367 if len(item.cache) != 2 {
368 t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
369 }
370 if _, ok := item.cache[30]; !ok {
371 t.Error("expected cache to have entry for width 30")
372 }
373 if _, ok := item.cache[50]; !ok {
374 t.Error("expected cache to have entry for width 50 (capped from 100)")
375 }
376}
377
378func BenchmarkMarkdownItemRender(b *testing.B) {
379 markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
380 item := NewMarkdownItem(markdown)
381
382 // Prime the cache
383 screen := uv.NewScreenBuffer(80, 10)
384 area := uv.Rect(0, 0, 80, 10)
385 item.Draw(&screen, area)
386
387 b.ResetTimer()
388 for i := 0; i < b.N; i++ {
389 screen := uv.NewScreenBuffer(80, 10)
390 area := uv.Rect(0, 0, 80, 10)
391 item.Draw(&screen, area)
392 }
393}
394
395func BenchmarkMarkdownItemUncached(b *testing.B) {
396 markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
397
398 b.ResetTimer()
399 for i := 0; i < b.N; i++ {
400 item := NewMarkdownItem(markdown)
401 screen := uv.NewScreenBuffer(80, 10)
402 area := uv.Rect(0, 0, 80, 10)
403 item.Draw(&screen, area)
404 }
405}
406
407func TestSpacerItem(t *testing.T) {
408 spacer := NewSpacerItem(3)
409
410 // Check height
411 if h := spacer.Height(80); h != 3 {
412 t.Errorf("expected height 3, got %d", h)
413 }
414
415 // Height should be constant regardless of width
416 if h := spacer.Height(20); h != 3 {
417 t.Errorf("expected height 3 for width 20, got %d", h)
418 }
419
420 // Draw should not produce any visible content
421 screen := uv.NewScreenBuffer(20, 3)
422 area := uv.Rect(0, 0, 20, 3)
423 spacer.Draw(&screen, area)
424
425 output := screen.Render()
426 // Should be empty (just spaces)
427 for _, line := range strings.Split(output, "\n") {
428 trimmed := strings.TrimSpace(line)
429 if trimmed != "" {
430 t.Errorf("expected empty spacer output, got: %q", line)
431 }
432 }
433}
434
435func TestSpacerItemInList(t *testing.T) {
436 // Create a list with items separated by spacers
437 items := []Item{
438 NewStringItem("Item 1"),
439 NewSpacerItem(1),
440 NewStringItem("Item 2"),
441 NewSpacerItem(2),
442 NewStringItem("Item 3"),
443 }
444
445 l := New(items...)
446 l.SetSize(20, 10)
447
448 output := l.Render()
449
450 // Should contain all three items
451 if !strings.Contains(output, "Item 1") {
452 t.Error("expected output to contain 'Item 1'")
453 }
454 if !strings.Contains(output, "Item 2") {
455 t.Error("expected output to contain 'Item 2'")
456 }
457 if !strings.Contains(output, "Item 3") {
458 t.Error("expected output to contain 'Item 3'")
459 }
460
461 // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
462 expectedHeight := 6
463 if l.TotalHeight() != expectedHeight {
464 t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
465 }
466}
467
468func TestSpacerItemNavigation(t *testing.T) {
469 // Spacers should not be selectable (they're not focusable)
470 items := []Item{
471 NewStringItem("Item 1"),
472 NewSpacerItem(1),
473 NewStringItem("Item 2"),
474 }
475
476 l := New(items...)
477 l.SetSize(20, 10)
478
479 // Select first item
480 l.SetSelected(0)
481 if l.SelectedIndex() != 0 {
482 t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
483 }
484
485 // Can select the spacer (it's a valid item, just not focusable)
486 l.SetSelected(1)
487 if l.SelectedIndex() != 1 {
488 t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
489 }
490
491 // Can select item after spacer
492 l.SetSelected(2)
493 if l.SelectedIndex() != 2 {
494 t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
495 }
496}
497
498// Helper function to create a pointer to uint
499func uintPtr(v uint) *uint {
500 return &v
501}
502
503func TestListDoesNotEatLastLine(t *testing.T) {
504 // Create items that exactly fill the viewport
505 items := []Item{
506 NewStringItem("Line 1"),
507 NewStringItem("Line 2"),
508 NewStringItem("Line 3"),
509 NewStringItem("Line 4"),
510 NewStringItem("Line 5"),
511 }
512
513 // Create list with height exactly matching content (5 lines, no gaps)
514 l := New(items...)
515 l.SetSize(20, 5)
516
517 // Render the list
518 output := l.Render()
519
520 // Count actual lines in output
521 lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
522 actualLineCount := 0
523 for _, line := range lines {
524 if strings.TrimSpace(line) != "" {
525 actualLineCount++
526 }
527 }
528
529 // All 5 items should be visible
530 if !strings.Contains(output, "Line 1") {
531 t.Error("expected output to contain 'Line 1'")
532 }
533 if !strings.Contains(output, "Line 2") {
534 t.Error("expected output to contain 'Line 2'")
535 }
536 if !strings.Contains(output, "Line 3") {
537 t.Error("expected output to contain 'Line 3'")
538 }
539 if !strings.Contains(output, "Line 4") {
540 t.Error("expected output to contain 'Line 4'")
541 }
542 if !strings.Contains(output, "Line 5") {
543 t.Error("expected output to contain 'Line 5'")
544 }
545
546 if actualLineCount != 5 {
547 t.Errorf("expected 5 lines with content, got %d", actualLineCount)
548 }
549}
550
551func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
552 // Create more items than viewport height
553 items := []Item{
554 NewStringItem("Item 1"),
555 NewStringItem("Item 2"),
556 NewStringItem("Item 3"),
557 NewStringItem("Item 4"),
558 NewStringItem("Item 5"),
559 NewStringItem("Item 6"),
560 NewStringItem("Item 7"),
561 }
562
563 // Viewport shows 3 items at a time
564 l := New(items...)
565 l.SetSize(20, 3)
566
567 // Need to render first to build the buffer and calculate total height
568 _ = l.Render()
569
570 // Now scroll to bottom
571 l.ScrollToBottom()
572
573 output := l.Render()
574
575 t.Logf("Output:\n%s", output)
576 t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
577
578 // Should show last 3 items: 5, 6, 7
579 if !strings.Contains(output, "Item 5") {
580 t.Error("expected output to contain 'Item 5'")
581 }
582 if !strings.Contains(output, "Item 6") {
583 t.Error("expected output to contain 'Item 6'")
584 }
585 if !strings.Contains(output, "Item 7") {
586 t.Error("expected output to contain 'Item 7'")
587 }
588
589 // Should not show earlier items
590 if strings.Contains(output, "Item 1") {
591 t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
592 }
593}