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