list_test.go

  1package list
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"testing"
  7
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 10	"github.com/charmbracelet/lipgloss/v2"
 11	"github.com/charmbracelet/x/exp/golden"
 12	"github.com/google/uuid"
 13	"github.com/stretchr/testify/assert"
 14	"github.com/stretchr/testify/require"
 15)
 16
 17func TestList(t *testing.T) {
 18	t.Parallel()
 19	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
 20		t.Parallel()
 21		items := []Item{}
 22		for i := range 5 {
 23			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 24			items = append(items, item)
 25		}
 26		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
 27		execCmd(l, l.Init())
 28
 29		// should select the last item
 30		assert.Equal(t, items[0].ID(), l.selectedItem)
 31		assert.Equal(t, 0, l.offset)
 32		require.Len(t, l.indexMap, 5)
 33		require.Len(t, l.items, 5)
 34		require.Len(t, l.renderedItems, 5)
 35		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 36		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 37		start, end := l.viewPosition()
 38		assert.Equal(t, 0, start)
 39		assert.Equal(t, 4, end)
 40		for i := range 5 {
 41			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 42			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 43		}
 44
 45		golden.RequireEqual(t, []byte(l.View()))
 46	})
 47	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
 48		t.Parallel()
 49		items := []Item{}
 50		for i := range 5 {
 51			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 52			items = append(items, item)
 53		}
 54		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
 55		execCmd(l, l.Init())
 56
 57		// should select the last item
 58		assert.Equal(t, items[4].ID(), l.selectedItem)
 59		assert.Equal(t, 0, l.offset)
 60		require.Len(t, l.indexMap, 5)
 61		require.Len(t, l.items, 5)
 62		require.Len(t, l.renderedItems, 5)
 63		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 64		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 65		start, end := l.viewPosition()
 66		assert.Equal(t, 0, start)
 67		assert.Equal(t, 4, end)
 68		for i := range 5 {
 69			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 70			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 71		}
 72
 73		golden.RequireEqual(t, []byte(l.View()))
 74	})
 75
 76	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
 77		t.Parallel()
 78		items := []Item{}
 79		for i := range 30 {
 80			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 81			items = append(items, item)
 82		}
 83		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 84		execCmd(l, l.Init())
 85
 86		// should select the last item
 87		assert.Equal(t, items[0].ID(), l.selectedItem)
 88		assert.Equal(t, 0, l.offset)
 89		require.Len(t, l.indexMap, 30)
 90		require.Len(t, l.items, 30)
 91		require.Len(t, l.renderedItems, 30)
 92		assert.Equal(t, 30, lipgloss.Height(l.rendered))
 93		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 94		start, end := l.viewPosition()
 95		assert.Equal(t, 0, start)
 96		assert.Equal(t, 9, end)
 97		for i := range 30 {
 98			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 99			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
100		}
101
102		golden.RequireEqual(t, []byte(l.View()))
103	})
104	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
105		t.Parallel()
106		items := []Item{}
107		for i := range 30 {
108			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
109			items = append(items, item)
110		}
111		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
112		execCmd(l, l.Init())
113
114		// should select the last item
115		assert.Equal(t, items[29].ID(), l.selectedItem)
116		assert.Equal(t, 0, l.offset)
117		require.Len(t, l.indexMap, 30)
118		require.Len(t, l.items, 30)
119		require.Len(t, l.renderedItems, 30)
120		assert.Equal(t, 30, lipgloss.Height(l.rendered))
121		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
122		start, end := l.viewPosition()
123		assert.Equal(t, 20, start)
124		assert.Equal(t, 29, end)
125		for i := range 30 {
126			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
127			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
128		}
129
130		golden.RequireEqual(t, []byte(l.View()))
131	})
132
133	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
134		t.Parallel()
135		items := []Item{}
136		for i := range 30 {
137			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
138			content = strings.TrimSuffix(content, "\n")
139			item := NewSelectableItem(content)
140			items = append(items, item)
141		}
142		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
143		execCmd(l, l.Init())
144
145		// should select the last item
146		assert.Equal(t, items[0].ID(), l.selectedItem)
147		assert.Equal(t, 0, l.offset)
148		require.Len(t, l.indexMap, 30)
149		require.Len(t, l.items, 30)
150		require.Len(t, l.renderedItems, 30)
151		expectedLines := 0
152		for i := range 30 {
153			expectedLines += (i + 1) * 1
154		}
155		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
156		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
157		start, end := l.viewPosition()
158		assert.Equal(t, 0, start)
159		assert.Equal(t, 9, end)
160		currentPosition := 0
161		for i := range 30 {
162			rItem := l.renderedItems[items[i].ID()]
163			assert.Equal(t, currentPosition, rItem.start)
164			assert.Equal(t, currentPosition+i, rItem.end)
165			currentPosition += i + 1
166		}
167
168		golden.RequireEqual(t, []byte(l.View()))
169	})
170	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
171		t.Parallel()
172		items := []Item{}
173		for i := range 30 {
174			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
175			content = strings.TrimSuffix(content, "\n")
176			item := NewSelectableItem(content)
177			items = append(items, item)
178		}
179		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
180		execCmd(l, l.Init())
181
182		// should select the last item
183		assert.Equal(t, items[29].ID(), l.selectedItem)
184		assert.Equal(t, 0, l.offset)
185		require.Len(t, l.indexMap, 30)
186		require.Len(t, l.items, 30)
187		require.Len(t, l.renderedItems, 30)
188		expectedLines := 0
189		for i := range 30 {
190			expectedLines += (i + 1) * 1
191		}
192		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
193		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
194		start, end := l.viewPosition()
195		assert.Equal(t, expectedLines-10, start)
196		assert.Equal(t, expectedLines-1, end)
197		currentPosition := 0
198		for i := range 30 {
199			rItem := l.renderedItems[items[i].ID()]
200			assert.Equal(t, currentPosition, rItem.start)
201			assert.Equal(t, currentPosition+i, rItem.end)
202			currentPosition += i + 1
203		}
204
205		golden.RequireEqual(t, []byte(l.View()))
206	})
207
208	t.Run("should go to selected item at the beginning", func(t *testing.T) {
209		t.Parallel()
210		items := []Item{}
211		for i := range 30 {
212			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
213			content = strings.TrimSuffix(content, "\n")
214			item := NewSelectableItem(content)
215			items = append(items, item)
216		}
217		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
218		execCmd(l, l.Init())
219
220		// should select the last item
221		assert.Equal(t, items[10].ID(), l.selectedItem)
222
223		golden.RequireEqual(t, []byte(l.View()))
224	})
225
226	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
227		t.Parallel()
228		items := []Item{}
229		for i := range 30 {
230			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
231			content = strings.TrimSuffix(content, "\n")
232			item := NewSelectableItem(content)
233			items = append(items, item)
234		}
235		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
236		execCmd(l, l.Init())
237
238		// should select the last item
239		assert.Equal(t, items[10].ID(), l.selectedItem)
240
241		golden.RequireEqual(t, []byte(l.View()))
242	})
243}
244
245func TestListMovement(t *testing.T) {
246	t.Parallel()
247	t.Run("should move viewport up", func(t *testing.T) {
248		t.Parallel()
249		items := []Item{}
250		for i := range 30 {
251			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
252			content = strings.TrimSuffix(content, "\n")
253			item := NewSelectableItem(content)
254			items = append(items, item)
255		}
256		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
257		execCmd(l, l.Init())
258
259		execCmd(l, l.MoveUp(25))
260
261		assert.Equal(t, 25, l.offset)
262		golden.RequireEqual(t, []byte(l.View()))
263	})
264	t.Run("should move viewport up and down", func(t *testing.T) {
265		t.Parallel()
266		items := []Item{}
267		for i := range 30 {
268			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
269			content = strings.TrimSuffix(content, "\n")
270			item := NewSelectableItem(content)
271			items = append(items, item)
272		}
273		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
274		execCmd(l, l.Init())
275
276		execCmd(l, l.MoveUp(25))
277		execCmd(l, l.MoveDown(25))
278
279		assert.Equal(t, 0, l.offset)
280		golden.RequireEqual(t, []byte(l.View()))
281	})
282
283	t.Run("should move viewport down", func(t *testing.T) {
284		t.Parallel()
285		items := []Item{}
286		for i := range 30 {
287			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
288			content = strings.TrimSuffix(content, "\n")
289			item := NewSelectableItem(content)
290			items = append(items, item)
291		}
292		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
293		execCmd(l, l.Init())
294
295		execCmd(l, l.MoveDown(25))
296
297		assert.Equal(t, 25, l.offset)
298		golden.RequireEqual(t, []byte(l.View()))
299	})
300	t.Run("should move viewport down and up", func(t *testing.T) {
301		t.Parallel()
302		items := []Item{}
303		for i := range 30 {
304			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
305			content = strings.TrimSuffix(content, "\n")
306			item := NewSelectableItem(content)
307			items = append(items, item)
308		}
309		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
310		execCmd(l, l.Init())
311
312		execCmd(l, l.MoveDown(25))
313		execCmd(l, l.MoveUp(25))
314
315		assert.Equal(t, 0, l.offset)
316		golden.RequireEqual(t, []byte(l.View()))
317	})
318}
319
320type SelectableItem interface {
321	Item
322	layout.Focusable
323}
324
325type simpleItem struct {
326	width   int
327	content string
328	id      string
329}
330type selectableItem struct {
331	*simpleItem
332	focused bool
333}
334
335func NewSimpleItem(content string) *simpleItem {
336	return &simpleItem{
337		id:      uuid.NewString(),
338		width:   0,
339		content: content,
340	}
341}
342
343func NewSelectableItem(content string) SelectableItem {
344	return &selectableItem{
345		simpleItem: NewSimpleItem(content),
346		focused:    false,
347	}
348}
349
350func (s *simpleItem) ID() string {
351	return s.id
352}
353
354func (s *simpleItem) Init() tea.Cmd {
355	return nil
356}
357
358func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
359	return s, nil
360}
361
362func (s *simpleItem) View() string {
363	return lipgloss.NewStyle().Width(s.width).Render(s.content)
364}
365
366func (l *simpleItem) GetSize() (int, int) {
367	return l.width, 0
368}
369
370// SetSize implements Item.
371func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
372	s.width = width
373	return nil
374}
375
376func (s *selectableItem) View() string {
377	if s.focused {
378		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
379	}
380	return lipgloss.NewStyle().Width(s.width).Render(s.content)
381}
382
383// Blur implements SimpleItem.
384func (s *selectableItem) Blur() tea.Cmd {
385	s.focused = false
386	return nil
387}
388
389// Focus implements SimpleItem.
390func (s *selectableItem) Focus() tea.Cmd {
391	s.focused = true
392	return nil
393}
394
395// IsFocused implements SimpleItem.
396func (s *selectableItem) IsFocused() bool {
397	return s.focused
398}
399
400func execCmd(m tea.Model, cmd tea.Cmd) {
401	for cmd != nil {
402		msg := cmd()
403		m, cmd = m.Update(msg)
404	}
405}