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("1", "Item 1"),
14 NewStringItem("2", "Item 2"),
15 NewStringItem("3", "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("1", "Item 1"),
43 NewStringItem("2", "Item 2"),
44 NewStringItem("3", "Item 3"),
45 NewStringItem("4", "Item 4"),
46 NewStringItem("5", "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("1", "Item 1"),
93 NewStringItem("2", "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(string(rune(i)), "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(string(rune(i)), "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("1", "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("1", "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("2", 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("3", 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("1", markdown)
219
220 if item.ID() != "1" {
221 t.Errorf("expected ID '1', got '%s'", item.ID())
222 }
223
224 // Test that height is calculated
225 height := item.Height(80)
226 if height < 1 {
227 t.Errorf("expected height >= 1, got %d", height)
228 }
229
230 // Test drawing
231 screen := uv.NewScreenBuffer(80, 10)
232 area := uv.Rect(0, 0, 80, 10)
233 item.Draw(&screen, area)
234
235 // Should not panic and should render something
236 rendered := screen.Render()
237 if len(rendered) == 0 {
238 t.Error("expected non-empty rendered output")
239 }
240}
241
242func TestMarkdownItemCache(t *testing.T) {
243 markdown := "# Test\n\nSome content."
244 item := NewMarkdownItem("1", markdown)
245
246 // First render at width 80 should populate cache
247 height1 := item.Height(80)
248 if len(item.cache) != 1 {
249 t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
250 }
251
252 // Second render at same width should reuse cache
253 height2 := item.Height(80)
254 if height1 != height2 {
255 t.Errorf("expected consistent height, got %d then %d", height1, height2)
256 }
257 if len(item.cache) != 1 {
258 t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
259 }
260
261 // Render at different width should add to cache
262 _ = item.Height(40)
263 if len(item.cache) != 2 {
264 t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
265 }
266}
267
268func TestMarkdownItemMaxCacheWidth(t *testing.T) {
269 markdown := "# Test\n\nSome content."
270 item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
271
272 // Render at width 40 (below limit) - should cache at width 40
273 _ = item.Height(40)
274 if len(item.cache) != 1 {
275 t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
276 }
277
278 // Render at width 80 (above limit) - should cap to 50 and cache
279 _ = item.Height(80)
280 // Cache should have width 50 entry (capped from 80)
281 if len(item.cache) != 2 {
282 t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
283 }
284 if _, ok := item.cache[50]; !ok {
285 t.Error("expected cache to have entry for width 50 (capped from 80)")
286 }
287
288 // Render at width 100 (also above limit) - should reuse cached width 50
289 _ = item.Height(100)
290 if len(item.cache) != 2 {
291 t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
292 }
293}
294
295func TestMarkdownItemWithStyleConfig(t *testing.T) {
296 markdown := "# Styled\n\nContent with **bold** text."
297
298 // Create a custom style config
299 styleConfig := ansi.StyleConfig{
300 Document: ansi.StyleBlock{
301 Margin: uintPtr(0),
302 },
303 }
304
305 item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
306
307 // Render should use the custom style
308 height := item.Height(80)
309 if height < 1 {
310 t.Errorf("expected height >= 1, got %d", height)
311 }
312
313 // Draw should work without panic
314 screen := uv.NewScreenBuffer(80, 10)
315 area := uv.Rect(0, 0, 80, 10)
316 item.Draw(&screen, area)
317
318 rendered := screen.Render()
319 if len(rendered) == 0 {
320 t.Error("expected non-empty rendered output with custom style")
321 }
322}
323
324func TestMarkdownItemInList(t *testing.T) {
325 items := []Item{
326 NewMarkdownItem("1", "# First\n\nMarkdown item."),
327 NewMarkdownItem("2", "# Second\n\nAnother item."),
328 NewStringItem("3", "Regular string item"),
329 }
330
331 l := New(items...)
332 l.SetSize(80, 20)
333
334 // Should render without error
335 output := l.Render()
336 if len(output) == 0 {
337 t.Error("expected non-empty output from list with markdown items")
338 }
339
340 // Should contain content from markdown items
341 if !strings.Contains(output, "First") {
342 t.Error("expected output to contain 'First'")
343 }
344 if !strings.Contains(output, "Second") {
345 t.Error("expected output to contain 'Second'")
346 }
347 if !strings.Contains(output, "Regular string item") {
348 t.Error("expected output to contain 'Regular string item'")
349 }
350}
351
352func TestMarkdownItemHeightWithWidth(t *testing.T) {
353 // Test that widths are capped to maxWidth
354 markdown := "This is a paragraph with some text."
355
356 item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
357
358 // At width 30 (below limit), should cache and render at width 30
359 height30 := item.Height(30)
360 if height30 < 1 {
361 t.Errorf("expected height >= 1, got %d", height30)
362 }
363
364 // At width 100 (above maxWidth), should cap to 50 and cache
365 height100 := item.Height(100)
366 if height100 < 1 {
367 t.Errorf("expected height >= 1, got %d", height100)
368 }
369
370 // Both should be cached (width 30 and capped width 50)
371 if len(item.cache) != 2 {
372 t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
373 }
374 if _, ok := item.cache[30]; !ok {
375 t.Error("expected cache to have entry for width 30")
376 }
377 if _, ok := item.cache[50]; !ok {
378 t.Error("expected cache to have entry for width 50 (capped from 100)")
379 }
380}
381
382func BenchmarkMarkdownItemRender(b *testing.B) {
383 markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
384 item := NewMarkdownItem("1", markdown)
385
386 // Prime the cache
387 screen := uv.NewScreenBuffer(80, 10)
388 area := uv.Rect(0, 0, 80, 10)
389 item.Draw(&screen, area)
390
391 b.ResetTimer()
392 for i := 0; i < b.N; i++ {
393 screen := uv.NewScreenBuffer(80, 10)
394 area := uv.Rect(0, 0, 80, 10)
395 item.Draw(&screen, area)
396 }
397}
398
399func BenchmarkMarkdownItemUncached(b *testing.B) {
400 markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
401
402 b.ResetTimer()
403 for i := 0; i < b.N; i++ {
404 item := NewMarkdownItem("1", markdown)
405 screen := uv.NewScreenBuffer(80, 10)
406 area := uv.Rect(0, 0, 80, 10)
407 item.Draw(&screen, area)
408 }
409}
410
411func TestSpacerItem(t *testing.T) {
412 spacer := NewSpacerItem("spacer1", 3)
413
414 // Check ID
415 if spacer.ID() != "spacer1" {
416 t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
417 }
418
419 // Check height
420 if h := spacer.Height(80); h != 3 {
421 t.Errorf("expected height 3, got %d", h)
422 }
423
424 // Height should be constant regardless of width
425 if h := spacer.Height(20); h != 3 {
426 t.Errorf("expected height 3 for width 20, got %d", h)
427 }
428
429 // Draw should not produce any visible content
430 screen := uv.NewScreenBuffer(20, 3)
431 area := uv.Rect(0, 0, 20, 3)
432 spacer.Draw(&screen, area)
433
434 output := screen.Render()
435 // Should be empty (just spaces)
436 for _, line := range strings.Split(output, "\n") {
437 trimmed := strings.TrimSpace(line)
438 if trimmed != "" {
439 t.Errorf("expected empty spacer output, got: %q", line)
440 }
441 }
442}
443
444func TestSpacerItemInList(t *testing.T) {
445 // Create a list with items separated by spacers
446 items := []Item{
447 NewStringItem("1", "Item 1"),
448 NewSpacerItem("spacer1", 1),
449 NewStringItem("2", "Item 2"),
450 NewSpacerItem("spacer2", 2),
451 NewStringItem("3", "Item 3"),
452 }
453
454 l := New(items...)
455 l.SetSize(20, 10)
456
457 output := l.Render()
458
459 // Should contain all three items
460 if !strings.Contains(output, "Item 1") {
461 t.Error("expected output to contain 'Item 1'")
462 }
463 if !strings.Contains(output, "Item 2") {
464 t.Error("expected output to contain 'Item 2'")
465 }
466 if !strings.Contains(output, "Item 3") {
467 t.Error("expected output to contain 'Item 3'")
468 }
469
470 // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
471 expectedHeight := 6
472 if l.TotalHeight() != expectedHeight {
473 t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
474 }
475}
476
477func TestSpacerItemNavigation(t *testing.T) {
478 // Spacers should not be selectable (they're not focusable)
479 items := []Item{
480 NewStringItem("1", "Item 1"),
481 NewSpacerItem("spacer1", 1),
482 NewStringItem("2", "Item 2"),
483 }
484
485 l := New(items...)
486 l.SetSize(20, 10)
487
488 // Select first item
489 l.SetSelectedIndex(0)
490 if l.SelectedIndex() != 0 {
491 t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
492 }
493
494 // Can select the spacer (it's a valid item, just not focusable)
495 l.SetSelectedIndex(1)
496 if l.SelectedIndex() != 1 {
497 t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
498 }
499
500 // Can select item after spacer
501 l.SetSelectedIndex(2)
502 if l.SelectedIndex() != 2 {
503 t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
504 }
505}
506
507// Helper function to create a pointer to uint
508func uintPtr(v uint) *uint {
509 return &v
510}
511
512func TestListDoesNotEatLastLine(t *testing.T) {
513 // Create items that exactly fill the viewport
514 items := []Item{
515 NewStringItem("1", "Line 1"),
516 NewStringItem("2", "Line 2"),
517 NewStringItem("3", "Line 3"),
518 NewStringItem("4", "Line 4"),
519 NewStringItem("5", "Line 5"),
520 }
521
522 // Create list with height exactly matching content (5 lines, no gaps)
523 l := New(items...)
524 l.SetSize(20, 5)
525
526 // Render the list
527 output := l.Render()
528
529 // Count actual lines in output
530 lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
531 actualLineCount := 0
532 for _, line := range lines {
533 if strings.TrimSpace(line) != "" {
534 actualLineCount++
535 }
536 }
537
538 // All 5 items should be visible
539 if !strings.Contains(output, "Line 1") {
540 t.Error("expected output to contain 'Line 1'")
541 }
542 if !strings.Contains(output, "Line 2") {
543 t.Error("expected output to contain 'Line 2'")
544 }
545 if !strings.Contains(output, "Line 3") {
546 t.Error("expected output to contain 'Line 3'")
547 }
548 if !strings.Contains(output, "Line 4") {
549 t.Error("expected output to contain 'Line 4'")
550 }
551 if !strings.Contains(output, "Line 5") {
552 t.Error("expected output to contain 'Line 5'")
553 }
554
555 if actualLineCount != 5 {
556 t.Errorf("expected 5 lines with content, got %d", actualLineCount)
557 }
558}
559
560func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
561 // Create more items than viewport height
562 items := []Item{
563 NewStringItem("1", "Item 1"),
564 NewStringItem("2", "Item 2"),
565 NewStringItem("3", "Item 3"),
566 NewStringItem("4", "Item 4"),
567 NewStringItem("5", "Item 5"),
568 NewStringItem("6", "Item 6"),
569 NewStringItem("7", "Item 7"),
570 }
571
572 // Viewport shows 3 items at a time
573 l := New(items...)
574 l.SetSize(20, 3)
575
576 // Need to render first to build the buffer and calculate total height
577 _ = l.Render()
578
579 // Now scroll to bottom
580 l.ScrollToBottom()
581
582 output := l.Render()
583
584 t.Logf("Output:\n%s", output)
585 t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
586
587 // Should show last 3 items: 5, 6, 7
588 if !strings.Contains(output, "Item 5") {
589 t.Error("expected output to contain 'Item 5'")
590 }
591 if !strings.Contains(output, "Item 6") {
592 t.Error("expected output to contain 'Item 6'")
593 }
594 if !strings.Contains(output, "Item 7") {
595 t.Error("expected output to contain 'Item 7'")
596 }
597
598 // Should not show earlier items
599 if strings.Contains(output, "Item 1") {
600 t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
601 }
602}