From 857a8254e3c698b03ed86217d941fda82f34e4c5 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Thu, 21 Aug 2025 00:41:29 +0200 Subject: [PATCH] bench: add list bench test --- internal/tui/exp/list/list_bench_test.go | 198 +++++++++++++++ internal/tui/exp/list/list_navigation_test.go | 225 ++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 internal/tui/exp/list/list_bench_test.go create mode 100644 internal/tui/exp/list/list_navigation_test.go diff --git a/internal/tui/exp/list/list_bench_test.go b/internal/tui/exp/list/list_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e6ce3af4148745ac16507dcdfba94d61a6370c90 --- /dev/null +++ b/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() + } + }) + } +} \ No newline at end of file diff --git a/internal/tui/exp/list/list_navigation_test.go b/internal/tui/exp/list/list_navigation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f3ab40074bb901bee175daeb3aab1c7604e5e7d5 --- /dev/null +++ b/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) + } +} \ No newline at end of file