item_test.go

  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}