list_test.go

  1package list
  2
  3import (
  4	"fmt"
  5	"sync"
  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)
 15
 16func TestListPosition(t *testing.T) {
 17	type positionOffsetTest struct {
 18		dir      direction
 19		test     string
 20		width    int
 21		height   int
 22		numItems int
 23
 24		moveUp   int
 25		moveDown int
 26
 27		expectedStart int
 28		expectedEnd   int
 29	}
 30	tests := []positionOffsetTest{
 31		{
 32			dir:           Forward,
 33			test:          "should have correct position initially when forward",
 34			moveUp:        0,
 35			moveDown:      0,
 36			width:         10,
 37			height:        20,
 38			numItems:      100,
 39			expectedStart: 0,
 40			expectedEnd:   19,
 41		},
 42		{
 43			dir:           Forward,
 44			test:          "should offset start and end by one when moving down by one",
 45			moveUp:        0,
 46			moveDown:      1,
 47			width:         10,
 48			height:        20,
 49			numItems:      100,
 50			expectedStart: 1,
 51			expectedEnd:   20,
 52		},
 53		{
 54			dir:           Backward,
 55			test:          "should have correct position initially when backward",
 56			moveUp:        0,
 57			moveDown:      0,
 58			width:         10,
 59			height:        20,
 60			numItems:      100,
 61			expectedStart: 80,
 62			expectedEnd:   99,
 63		},
 64		{
 65			dir:           Backward,
 66			test:          "should offset the start and end by one when moving up by one",
 67			moveUp:        1,
 68			moveDown:      0,
 69			width:         10,
 70			height:        20,
 71			numItems:      100,
 72			expectedStart: 79,
 73			expectedEnd:   98,
 74		},
 75	}
 76	for _, c := range tests {
 77		t.Run(c.test, func(t *testing.T) {
 78			items := []Item{}
 79			for i := range c.numItems {
 80				item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 81				items = append(items, item)
 82			}
 83			l := New(items, WithDirection(c.dir)).(*list[Item])
 84			l.SetSize(c.width, c.height)
 85			cmd := l.Init()
 86			if cmd != nil {
 87				cmd()
 88			}
 89
 90			if c.moveUp > 0 {
 91				l.MoveUp(c.moveUp)
 92			}
 93			if c.moveDown > 0 {
 94				l.MoveDown(c.moveDown)
 95			}
 96			start, end := l.viewPosition()
 97			assert.Equal(t, c.expectedStart, start)
 98			assert.Equal(t, c.expectedEnd, end)
 99		})
100	}
101}
102
103func TestBackwardList(t *testing.T) {
104	t.Run("within height", func(t *testing.T) {
105		t.Parallel()
106		items := []Item{}
107		for i := range 5 {
108			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
109			items = append(items, item)
110		}
111		l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
112		l.SetSize(10, 20)
113		cmd := l.Init()
114		if cmd != nil {
115			cmd()
116		}
117
118		// should select the last item
119		assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
120		golden.RequireEqual(t, []byte(l.View()))
121	})
122	t.Run("should not change selected item", func(t *testing.T) {
123		t.Parallel()
124		items := []Item{}
125		for i := range 5 {
126			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
127			items = append(items, item)
128		}
129		l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
130		l.SetSize(10, 20)
131		cmd := l.Init()
132		if cmd != nil {
133			cmd()
134		}
135		// should select the last item
136		assert.Equal(t, l.selectedItem, items[2].ID())
137	})
138	t.Run("more than height", func(t *testing.T) {
139		t.Parallel()
140		items := []Item{}
141		for i := range 10 {
142			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
143			items = append(items, item)
144		}
145		l := New(items, WithDirection(Backward))
146		l.SetSize(10, 5)
147		cmd := l.Init()
148		if cmd != nil {
149			cmd()
150		}
151
152		golden.RequireEqual(t, []byte(l.View()))
153	})
154	t.Run("more than height multi line", func(t *testing.T) {
155		t.Parallel()
156		items := []Item{}
157		for i := range 10 {
158			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
159			items = append(items, item)
160		}
161		l := New(items, WithDirection(Backward))
162		l.SetSize(10, 5)
163		cmd := l.Init()
164		if cmd != nil {
165			cmd()
166		}
167
168		golden.RequireEqual(t, []byte(l.View()))
169	})
170	t.Run("should move up", func(t *testing.T) {
171		t.Parallel()
172		items := []Item{}
173		for i := range 10 {
174			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
175			items = append(items, item)
176		}
177		l := New(items, WithDirection(Backward))
178		l.SetSize(10, 5)
179		cmd := l.Init()
180		if cmd != nil {
181			cmd()
182		}
183
184		l.MoveUp(1)
185		golden.RequireEqual(t, []byte(l.View()))
186	})
187
188	t.Run("should move at max to the top", func(t *testing.T) {
189		items := []Item{}
190		for i := range 10 {
191			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
192			items = append(items, item)
193		}
194		l := New(items, WithDirection(Backward)).(*list[Item])
195		l.SetSize(10, 5)
196		cmd := l.Init()
197		if cmd != nil {
198			cmd()
199		}
200
201		l.MoveUp(100)
202		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
203		golden.RequireEqual(t, []byte(l.View()))
204	})
205	t.Run("should do nothing with wrong move number", func(t *testing.T) {
206		t.Parallel()
207		items := []Item{}
208		for i := range 10 {
209			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
210			items = append(items, item)
211		}
212		l := New(items, WithDirection(Backward))
213		l.SetSize(10, 5)
214		cmd := l.Init()
215		if cmd != nil {
216			cmd()
217		}
218
219		l.MoveUp(-10)
220		golden.RequireEqual(t, []byte(l.View()))
221	})
222	t.Run("should move to the top", func(t *testing.T) {
223		t.Parallel()
224		items := []Item{}
225		for i := range 10 {
226			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
227			items = append(items, item)
228		}
229		l := New(items, WithDirection(Backward)).(*list[Item])
230		l.SetSize(10, 5)
231		cmd := l.Init()
232		if cmd != nil {
233			cmd()
234		}
235
236		l.GoToTop()
237		assert.Equal(t, l.direction, Forward)
238		golden.RequireEqual(t, []byte(l.View()))
239	})
240	t.Run("should select the item above", func(t *testing.T) {
241		t.Parallel()
242		items := []Item{}
243		for i := range 10 {
244			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
245			items = append(items, item)
246		}
247		l := New(items, WithDirection(Backward)).(*list[Item])
248		l.SetSize(10, 5)
249		cmd := l.Init()
250		if cmd != nil {
251			cmd()
252		}
253
254		selectedInx := len(l.items) - 2
255		currentItem := items[len(l.items)-1]
256		nextItem := items[selectedInx]
257		assert.False(t, nextItem.(SelectableItem).IsFocused())
258		assert.True(t, currentItem.(SelectableItem).IsFocused())
259		cmd = l.SelectItemAbove()
260		if cmd != nil {
261			cmd()
262		}
263
264		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
265		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
266
267		golden.RequireEqual(t, []byte(l.View()))
268	})
269	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
270		t.Parallel()
271		items := []Item{}
272		for i := range 10 {
273			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
274			items = append(items, item)
275		}
276		l := New(items, WithDirection(Backward)).(*list[Item])
277		l.SetSize(10, 5)
278		cmd := l.Init()
279		if cmd != nil {
280			cmd()
281		}
282
283		for range 5 {
284			cmd = l.SelectItemAbove()
285			if cmd != nil {
286				cmd()
287			}
288		}
289		golden.RequireEqual(t, []byte(l.View()))
290	})
291}
292
293func TestForwardList(t *testing.T) {
294	t.Run("within height", func(t *testing.T) {
295		t.Parallel()
296		items := []Item{}
297		for i := range 5 {
298			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
299			items = append(items, item)
300		}
301		l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
302		l.SetSize(10, 20)
303		cmd := l.Init()
304		if cmd != nil {
305			cmd()
306		}
307
308		// should select the last item
309		assert.Equal(t, l.selectedItem, items[0].ID())
310
311		golden.RequireEqual(t, []byte(l.View()))
312	})
313	t.Run("should not change selected item", func(t *testing.T) {
314		t.Parallel()
315		items := []Item{}
316		for i := range 5 {
317			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
318			items = append(items, item)
319		}
320		l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
321		l.SetSize(10, 20)
322		cmd := l.Init()
323		if cmd != nil {
324			cmd()
325		}
326		// should select the last item
327		assert.Equal(t, l.selectedItem, items[2].ID())
328	})
329	t.Run("more than height", func(t *testing.T) {
330		t.Parallel()
331		items := []Item{}
332		for i := range 10 {
333			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
334			items = append(items, item)
335		}
336		l := New(items, WithDirection(Forward)).(*list[Item])
337		l.SetSize(10, 5)
338		cmd := l.Init()
339		if cmd != nil {
340			cmd()
341		}
342
343		golden.RequireEqual(t, []byte(l.View()))
344	})
345	t.Run("more than height multi line", func(t *testing.T) {
346		t.Parallel()
347		items := []Item{}
348		for i := range 10 {
349			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
350			items = append(items, item)
351		}
352		l := New(items, WithDirection(Forward)).(*list[Item])
353		l.SetSize(10, 5)
354		cmd := l.Init()
355		if cmd != nil {
356			cmd()
357		}
358
359		golden.RequireEqual(t, []byte(l.View()))
360	})
361	t.Run("should move down", func(t *testing.T) {
362		t.Parallel()
363		items := []Item{}
364		for i := range 10 {
365			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
366			items = append(items, item)
367		}
368		l := New(items, WithDirection(Forward)).(*list[Item])
369		l.SetSize(10, 5)
370		cmd := l.Init()
371		if cmd != nil {
372			cmd()
373		}
374
375		l.MoveDown(1)
376		golden.RequireEqual(t, []byte(l.View()))
377	})
378	t.Run("should move at max to the bottom", func(t *testing.T) {
379		t.Parallel()
380		items := []Item{}
381		for i := range 10 {
382			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
383			items = append(items, item)
384		}
385		l := New(items, WithDirection(Forward)).(*list[Item])
386		l.SetSize(10, 5)
387		cmd := l.Init()
388		if cmd != nil {
389			cmd()
390		}
391
392		l.MoveDown(100)
393		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
394		golden.RequireEqual(t, []byte(l.View()))
395	})
396	t.Run("should do nothing with wrong move number", func(t *testing.T) {
397		t.Parallel()
398		items := []Item{}
399		for i := range 10 {
400			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
401			items = append(items, item)
402		}
403		l := New(items, WithDirection(Forward)).(*list[Item])
404		l.SetSize(10, 5)
405		cmd := l.Init()
406		if cmd != nil {
407			cmd()
408		}
409
410		l.MoveDown(-10)
411		golden.RequireEqual(t, []byte(l.View()))
412	})
413	t.Run("should move to the bottom", func(t *testing.T) {
414		t.Parallel()
415		items := []Item{}
416		for i := range 10 {
417			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
418			items = append(items, item)
419		}
420		l := New(items, WithDirection(Forward)).(*list[Item])
421		l.SetSize(10, 5)
422		cmd := l.Init()
423		if cmd != nil {
424			cmd()
425		}
426
427		l.GoToBottom()
428		assert.Equal(t, l.direction, Backward)
429		golden.RequireEqual(t, []byte(l.View()))
430	})
431	t.Run("should select the item below", func(t *testing.T) {
432		t.Parallel()
433		items := []Item{}
434		for i := range 10 {
435			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
436			items = append(items, item)
437		}
438		l := New(items, WithDirection(Forward)).(*list[Item])
439		l.SetSize(10, 5)
440		cmd := l.Init()
441		if cmd != nil {
442			cmd()
443		}
444
445		selectedInx := 1
446		currentItem := items[0]
447		nextItem := items[selectedInx]
448		assert.False(t, nextItem.(SelectableItem).IsFocused())
449		assert.True(t, currentItem.(SelectableItem).IsFocused())
450		cmd = l.SelectItemBelow()
451		if cmd != nil {
452			cmd()
453		}
454
455		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
456		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
457
458		golden.RequireEqual(t, []byte(l.View()))
459	})
460	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
461		t.Parallel()
462		items := []Item{}
463		for i := range 10 {
464			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
465			items = append(items, item)
466		}
467		l := New(items, WithDirection(Forward)).(*list[Item])
468		l.SetSize(10, 5)
469		cmd := l.Init()
470		if cmd != nil {
471			cmd()
472		}
473
474		for range 5 {
475			cmd = l.SelectItemBelow()
476			if cmd != nil {
477				cmd()
478			}
479		}
480		golden.RequireEqual(t, []byte(l.View()))
481	})
482}
483
484func TestListSelection(t *testing.T) {
485	t.Run("should skip none selectable items initially", func(t *testing.T) {
486		t.Parallel()
487		items := []Item{}
488		items = append(items, NewSimpleItem("None Selectable"))
489		for i := range 5 {
490			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
491			items = append(items, item)
492		}
493		l := New(items, WithDirection(Forward)).(*list[Item])
494		l.SetSize(100, 10)
495		cmd := l.Init()
496		if cmd != nil {
497			cmd()
498		}
499
500		assert.Equal(t, items[1].ID(), l.selectedItem)
501		golden.RequireEqual(t, []byte(l.View()))
502	})
503	t.Run("should select the correct item on startup", func(t *testing.T) {
504		t.Parallel()
505		items := []Item{}
506		for i := range 5 {
507			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
508			items = append(items, item)
509		}
510		l := New(items, WithDirection(Forward)).(*list[Item])
511		cmd := l.Init()
512		otherCmd := l.SetSelected(items[3].ID())
513		var wg sync.WaitGroup
514		if cmd != nil {
515			wg.Add(1)
516			go func() {
517				cmd()
518				wg.Done()
519			}()
520		}
521		if otherCmd != nil {
522			wg.Add(1)
523			go func() {
524				otherCmd()
525				wg.Done()
526			}()
527		}
528		wg.Wait()
529		l.SetSize(100, 10)
530		assert.Equal(t, items[3].ID(), l.selectedItem)
531		golden.RequireEqual(t, []byte(l.View()))
532	})
533	t.Run("should skip none selectable items in the middle", func(t *testing.T) {
534		t.Parallel()
535		items := []Item{}
536		item := NewSelectableItem("Item initial")
537		items = append(items, item)
538		items = append(items, NewSimpleItem("None Selectable"))
539		for i := range 5 {
540			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
541			items = append(items, item)
542		}
543		l := New(items, WithDirection(Forward)).(*list[Item])
544		l.SetSize(100, 10)
545		cmd := l.Init()
546		if cmd != nil {
547			cmd()
548		}
549		l.SelectItemBelow()
550		assert.Equal(t, items[2].ID(), l.selectedItem)
551		golden.RequireEqual(t, []byte(l.View()))
552	})
553}
554
555func TestListSetSelection(t *testing.T) {
556	t.Run("should move to the selected item", func(t *testing.T) {
557		t.Parallel()
558		items := []Item{}
559		for i := range 100 {
560			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
561			items = append(items, item)
562		}
563		l := New(items, WithDirection(Forward)).(*list[Item])
564		l.SetSize(100, 10)
565		cmd := l.Init()
566		if cmd != nil {
567			cmd()
568		}
569
570		cmd = l.SetSelected(items[52].ID())
571		if cmd != nil {
572			cmd()
573		}
574
575		assert.Equal(t, items[52].ID(), l.selectedItem)
576		golden.RequireEqual(t, []byte(l.View()))
577	})
578}
579
580type SelectableItem interface {
581	Item
582	layout.Focusable
583}
584
585type simpleItem struct {
586	width   int
587	content string
588	id      string
589}
590type selectableItem struct {
591	*simpleItem
592	focused bool
593}
594
595func NewSimpleItem(content string) *simpleItem {
596	return &simpleItem{
597		id:      uuid.NewString(),
598		width:   0,
599		content: content,
600	}
601}
602
603func NewSelectableItem(content string) SelectableItem {
604	return &selectableItem{
605		simpleItem: NewSimpleItem(content),
606		focused:    false,
607	}
608}
609
610func (s *simpleItem) ID() string {
611	return s.id
612}
613
614func (s *simpleItem) Init() tea.Cmd {
615	return nil
616}
617
618func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
619	return s, nil
620}
621
622func (s *simpleItem) View() string {
623	return lipgloss.NewStyle().Width(s.width).Render(s.content)
624}
625
626func (l *simpleItem) GetSize() (int, int) {
627	return l.width, 0
628}
629
630// SetSize implements Item.
631func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
632	s.width = width
633	return nil
634}
635
636func (s *selectableItem) View() string {
637	if s.focused {
638		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
639	}
640	return lipgloss.NewStyle().Width(s.width).Render(s.content)
641}
642
643// Blur implements SimpleItem.
644func (s *selectableItem) Blur() tea.Cmd {
645	s.focused = false
646	return nil
647}
648
649// Focus implements SimpleItem.
650func (s *selectableItem) Focus() tea.Cmd {
651	s.focused = true
652	return nil
653}
654
655// IsFocused implements SimpleItem.
656func (s *selectableItem) IsFocused() bool {
657	return s.focused
658}