@@ -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()
+ }
+ })
+ }
+}
@@ -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)
+ }
+}