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("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}