bench: add list bench test

Raphael Amorim created

Change summary

internal/tui/exp/list/list_bench_test.go      | 198 ++++++++++++++++++
internal/tui/exp/list/list_navigation_test.go | 225 +++++++++++++++++++++
2 files changed, 423 insertions(+)

Detailed changes

internal/tui/exp/list/list_bench_test.go 🔗

@@ -0,0 +1,198 @@
+package list
+
+import (
+	"fmt"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+// Mock item for benchmarking
+type benchItem struct {
+	id      string
+	content string
+	height  int
+	width   int
+}
+
+func (b benchItem) ID() string {
+	return b.id
+}
+
+func (b benchItem) SetSize(width, height int) tea.Cmd {
+	b.width = width
+	b.height = height
+	return nil
+}
+
+func (b benchItem) GetSize() (int, int) {
+	return b.width, b.height
+}
+
+func (b benchItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return b, nil
+}
+
+func (b benchItem) Init() tea.Cmd {
+	return nil
+}
+
+func (b benchItem) View() string {
+	return b.content
+}
+
+func (b benchItem) Height() int {
+	return b.height
+}
+
+// createBenchItems creates n items for benchmarking
+func createBenchItems(n int) []Item {
+	items := make([]Item, n)
+	for i := 0; i < n; i++ {
+		items[i] = benchItem{
+			id:      fmt.Sprintf("item-%d", i),
+			content: fmt.Sprintf("This is item %d with some content that spans multiple lines\nLine 2\nLine 3", i),
+			height:  3,
+		}
+	}
+	return items
+}
+
+// BenchmarkListRender benchmarks the render performance with different list sizes
+func BenchmarkListRender(b *testing.B) {
+	sizes := []int{100, 500, 1000, 5000, 10000}
+	
+	for _, size := range sizes {
+		b.Run(fmt.Sprintf("Items_%d", size), func(b *testing.B) {
+			items := createBenchItems(size)
+			list := New(items, WithDirectionForward()).(*list[Item])
+			
+			// Set dimensions
+			list.SetSize(80, 30)
+			
+			// Initialize to calculate positions
+			list.Init()
+			
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				list.render()
+			}
+		})
+	}
+}
+
+// BenchmarkListScroll benchmarks scrolling performance
+func BenchmarkListScroll(b *testing.B) {
+	sizes := []int{100, 500, 1000, 5000, 10000}
+	
+	for _, size := range sizes {
+		b.Run(fmt.Sprintf("Items_%d", size), func(b *testing.B) {
+			items := createBenchItems(size)
+			list := New(items, WithDirectionForward())
+			
+			// Set dimensions
+			list.SetSize(80, 30)
+			
+			// Initialize
+			list.Init()
+			
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				// Scroll down and up
+				list.MoveDown(10)
+				list.MoveUp(10)
+			}
+		})
+	}
+}
+
+// BenchmarkListView benchmarks the View() method performance
+func BenchmarkListView(b *testing.B) {
+	sizes := []int{100, 500, 1000, 5000, 10000}
+	
+	for _, size := range sizes {
+		b.Run(fmt.Sprintf("Items_%d", size), func(b *testing.B) {
+			items := createBenchItems(size)
+			list := New(items, WithDirectionForward()).(*list[Item])
+			
+			// Set dimensions
+			list.SetSize(80, 30)
+			
+			// Initialize and render once
+			list.Init()
+			list.render()
+			
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				_ = list.View()
+			}
+		})
+	}
+}
+
+// BenchmarkListMemory benchmarks memory allocation
+func BenchmarkListMemory(b *testing.B) {
+	sizes := []int{100, 500, 1000, 5000, 10000}
+	
+	for _, size := range sizes {
+		b.Run(fmt.Sprintf("Items_%d", size), func(b *testing.B) {
+			b.ReportAllocs()
+			
+			for i := 0; i < b.N; i++ {
+				items := createBenchItems(size)
+				list := New(items, WithDirectionForward()).(*list[Item])
+				list.SetSize(80, 30)
+				list.Init()
+				list.render()
+				_ = list.View()
+			}
+		})
+	}
+}
+
+// BenchmarkVirtualScrolling specifically tests virtual scrolling efficiency
+func BenchmarkVirtualScrolling(b *testing.B) {
+	// Test with a very large list to see virtual scrolling benefits
+	items := createBenchItems(10000)
+	list := New(items, WithDirectionForward()).(*list[Item])
+	list.SetSize(80, 30)
+	list.Init()
+	
+	b.Run("RenderVisibleOnly", func(b *testing.B) {
+		b.ResetTimer()
+		for i := 0; i < b.N; i++ {
+			// This should only render ~10 items that fit in viewport
+			list.renderVirtualScrolling()
+		}
+	})
+	
+	b.Run("ScrollThroughList", func(b *testing.B) {
+		b.ResetTimer()
+		for i := 0; i < b.N; i++ {
+			// Scroll through the entire list
+			for j := 0; j < 100; j++ {
+				list.MoveDown(100)
+			}
+			// Reset to top
+			list.GoToTop()
+		}
+	})
+}
+
+// BenchmarkCalculatePositions benchmarks position calculation
+func BenchmarkCalculatePositions(b *testing.B) {
+	sizes := []int{100, 500, 1000, 5000, 10000}
+	
+	for _, size := range sizes {
+		b.Run(fmt.Sprintf("Items_%d", size), func(b *testing.B) {
+			items := createBenchItems(size)
+			list := New(items, WithDirectionForward()).(*list[Item])
+			list.SetSize(80, 30)
+			
+			b.ResetTimer()
+			for i := 0; i < b.N; i++ {
+				list.calculateItemPositions()
+			}
+		})
+	}
+}

internal/tui/exp/list/list_navigation_test.go 🔗

@@ -0,0 +1,225 @@
+package list
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// mockVariableHeightItem is a test item with configurable height
+type mockVariableHeightItem struct {
+	id      string
+	height  int
+	content string
+}
+
+func (m *mockVariableHeightItem) ID() string {
+	return m.id
+}
+
+func (m *mockVariableHeightItem) Init() tea.Cmd {
+	return nil
+}
+
+func (m *mockVariableHeightItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *mockVariableHeightItem) View() string {
+	lines := make([]string, m.height)
+	for i := 0; i < m.height; i++ {
+		if i == 0 {
+			lines[i] = m.content
+		} else {
+			lines[i] = fmt.Sprintf("  Line %d", i+1)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+func (m *mockVariableHeightItem) SetSize(width, height int) tea.Cmd {
+	return nil
+}
+
+func (m *mockVariableHeightItem) IsFocused() bool {
+	return false
+}
+
+func (m *mockVariableHeightItem) Focus() tea.Cmd {
+	return nil
+}
+
+func (m *mockVariableHeightItem) Blur() tea.Cmd {
+	return nil
+}
+
+func (m *mockVariableHeightItem) GetSize() (int, int) {
+	return 0, m.height
+}
+
+func TestArrowKeyNavigation(t *testing.T) {
+	t.Run("should show full item when navigating with arrow keys", func(t *testing.T) {
+		// Create items with varying heights
+		items := []Item{
+			&mockVariableHeightItem{id: "item1", height: 2, content: "Item 1 (2 lines)"},
+			&mockVariableHeightItem{id: "item2", height: 3, content: "Item 2 (3 lines)"},
+			&mockVariableHeightItem{id: "item3", height: 1, content: "Item 3 (1 line)"},
+			&mockVariableHeightItem{id: "item4", height: 4, content: "Item 4 (4 lines)"},
+			&mockVariableHeightItem{id: "item5", height: 2, content: "Item 5 (2 lines)"},
+		}
+
+		// Create list with viewport height of 6, width of 40
+		l := New(items, WithDirectionForward(), WithSize(40, 6)).(*list[Item])
+		execCmdNav(l, l.Init())
+
+		// Initial state - first item should be selected
+		assert.Equal(t, "item1", l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+
+		// Navigate down to item 2
+		_, cmd := l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyDown,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item2", l.selectedItem)
+		// Item 2 should be fully visible
+		view := l.View()
+		assert.Contains(t, view, "Item 2 (3 lines)")
+		assert.Contains(t, view, "  Line 2")
+		assert.Contains(t, view, "  Line 3")
+
+		// Navigate down to item 3
+		_, cmd = l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyDown,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item3", l.selectedItem)
+		view = l.View()
+		assert.Contains(t, view, "Item 3 (1 line)")
+
+		// Navigate down to item 4 (4 lines - might need scrolling)
+		_, cmd = l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyDown,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item4", l.selectedItem)
+		view = l.View()
+		// All lines of item 4 should be visible
+		assert.Contains(t, view, "Item 4 (4 lines)")
+		assert.Contains(t, view, "  Line 2")
+		assert.Contains(t, view, "  Line 3")
+		assert.Contains(t, view, "  Line 4")
+
+		// Navigate back up to item 3
+		_, cmd = l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyUp,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item3", l.selectedItem)
+		view = l.View()
+		assert.Contains(t, view, "Item 3 (1 line)")
+
+		// Navigate back up to item 2
+		_, cmd = l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyUp,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item2", l.selectedItem)
+		view = l.View()
+		// All lines of item 2 should be visible
+		assert.Contains(t, view, "Item 2 (3 lines)")
+		assert.Contains(t, view, "  Line 2")
+		assert.Contains(t, view, "  Line 3")
+	})
+
+	t.Run("should not show partial items at viewport boundaries", func(t *testing.T) {
+		// Create items with specific heights to test boundary conditions
+		items := []Item{
+			&mockVariableHeightItem{id: "item1", height: 3, content: "Item 1"},
+			&mockVariableHeightItem{id: "item2", height: 3, content: "Item 2"},
+			&mockVariableHeightItem{id: "item3", height: 3, content: "Item 3"},
+			&mockVariableHeightItem{id: "item4", height: 3, content: "Item 4"},
+		}
+
+		// Create list with viewport height of 5, width of 40 (can't fit 2 full 3-line items)
+		l := New(items, WithDirectionForward(), WithSize(40, 5)).(*list[Item])
+		execCmdNav(l, l.Init())
+
+		// Navigate to item 2
+		_, cmd := l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyDown,
+		}))
+		execCmdNav(l, cmd)
+
+		view := l.View()
+		lines := strings.Split(view, "\n")
+
+		// Check that we have exactly 5 lines (viewport height)
+		require.Len(t, lines, 5)
+
+		// Item 2 should be fully visible
+		assert.Contains(t, view, "Item 2")
+
+		// Count how many lines of each item are visible
+		item1Lines := 0
+		item2Lines := 0
+		for _, line := range lines {
+			if strings.Contains(line, "Item 1") || (item1Lines > 0 && item1Lines < 3) {
+				item1Lines++
+			}
+			if strings.Contains(line, "Item 2") || (item2Lines > 0 && item2Lines < 3) {
+				item2Lines++
+			}
+		}
+
+		// Item 2 should have all 3 lines visible
+		assert.Equal(t, 3, item2Lines, "Item 2 should be fully visible")
+	})
+
+	t.Run("should handle items taller than viewport", func(t *testing.T) {
+		// Create an item taller than the viewport
+		items := []Item{
+			&mockVariableHeightItem{id: "item1", height: 2, content: "Item 1"},
+			&mockVariableHeightItem{id: "item2", height: 8, content: "Item 2 (tall)"},
+			&mockVariableHeightItem{id: "item3", height: 2, content: "Item 3"},
+		}
+
+		// Create list with viewport height of 5, width of 40
+		l := New(items, WithDirectionForward(), WithSize(40, 5)).(*list[Item])
+		execCmdNav(l, l.Init())
+
+		// Navigate to the tall item
+		_, cmd := l.Update(tea.KeyPressMsg(tea.Key{
+			Code: tea.KeyDown,
+		}))
+		execCmdNav(l, cmd)
+
+		assert.Equal(t, "item2", l.selectedItem)
+		view := l.View()
+
+		// Should show the item from the top
+		assert.Contains(t, view, "Item 2 (tall)")
+		lines := strings.Split(view, "\n")
+		assert.Len(t, lines, 5) // Should fill viewport
+	})
+}
+
+// Helper function to execute commands
+func execCmdNav(l *list[Item], cmd tea.Cmd) {
+	if cmd == nil {
+		return
+	}
+	msg := cmd()
+	if msg != nil {
+		l.Update(msg)
+	}
+}