cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package ui
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/charmbracelet/bubbles/help"
13 "github.com/charmbracelet/bubbles/key"
14 "github.com/charmbracelet/bubbles/viewport"
15 tea "github.com/charmbracelet/bubbletea"
16)
17
18type MockListItem struct {
19 title string
20 description string
21 filterValue string
22}
23
24func (m MockListItem) GetID() int64 {
25 return 1 // Mock ID
26}
27
28func (m MockListItem) SetID(id int64) {
29 // Mock - no-op
30}
31
32func (m MockListItem) GetTableName() string {
33 return "mock_items"
34}
35
36func (m MockListItem) GetCreatedAt() time.Time {
37 return time.Time{} // Mock - zero time
38}
39
40func (m MockListItem) SetCreatedAt(t time.Time) {
41 // Mock - no-op
42}
43
44func (m MockListItem) GetUpdatedAt() time.Time {
45 return time.Time{} // Mock - zero time
46}
47
48func (m MockListItem) SetUpdatedAt(t time.Time) {
49 // Mock - no-op
50}
51
52func (m MockListItem) GetTitle() string {
53 return m.title
54}
55
56func (m MockListItem) GetDescription() string {
57 return m.description
58}
59
60func (m MockListItem) GetFilterValue() string {
61 return m.filterValue
62}
63
64func NewMockItem(id int64, title, description, filterValue string) MockListItem {
65 return MockListItem{
66 title: title,
67 description: description,
68 filterValue: filterValue,
69 }
70}
71
72type MockListSource struct {
73 items []ListItem
74 loadError error
75 countError error
76 searchError error
77}
78
79func (m *MockListSource) Load(ctx context.Context, opts ListOptions) ([]ListItem, error) {
80 if m.loadError != nil {
81 return nil, m.loadError
82 }
83
84 filtered := make([]ListItem, 0)
85 for _, item := range m.items {
86 include := true
87 for filterField, filterValue := range opts.Filters {
88 if filterField == "title" && item.GetTitle() != filterValue {
89 include = false
90 break
91 }
92 }
93 if include {
94 filtered = append(filtered, item)
95 }
96 }
97
98 if opts.Limit > 0 && len(filtered) > opts.Limit {
99 filtered = filtered[:opts.Limit]
100 }
101
102 return filtered, nil
103}
104
105func (m *MockListSource) Count(ctx context.Context, opts ListOptions) (int, error) {
106 if m.countError != nil {
107 return 0, m.countError
108 }
109
110 count := 0
111 for _, item := range m.items {
112 include := true
113 for filterField, filterValue := range opts.Filters {
114 if filterField == "title" && item.GetTitle() != filterValue {
115 include = false
116 break
117 }
118 }
119 if include {
120 count++
121 }
122 }
123
124 return count, nil
125}
126
127func (m *MockListSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) {
128 if m.searchError != nil {
129 return nil, m.searchError
130 }
131
132 results := make([]ListItem, 0)
133 for _, item := range m.items {
134 if strings.Contains(strings.ToLower(item.GetTitle()), strings.ToLower(query)) ||
135 strings.Contains(strings.ToLower(item.GetDescription()), strings.ToLower(query)) ||
136 strings.Contains(strings.ToLower(item.GetFilterValue()), strings.ToLower(query)) {
137 results = append(results, item)
138 }
139 }
140
141 return results, nil
142}
143
144func createMockItems() []ListItem {
145 return []ListItem{
146 NewMockItem(1, "First Item", "Description of first item", "item1 tag1"),
147 NewMockItem(2, "Second Item", "Description of second item", "item2 tag2"),
148 NewMockItem(3, "Third Item", "Description of third item", "item3 tag1"),
149 }
150}
151
152func TestDataList(t *testing.T) {
153 t.Run("Options", func(t *testing.T) {
154 t.Run("default options", func(t *testing.T) {
155 source := &MockListSource{items: createMockItems()}
156 opts := DataListOptions{}
157
158 list := NewDataList(source, opts)
159 if list.opts.Output == nil {
160 t.Error("Output should default to os.Stdout")
161 }
162 if list.opts.Input == nil {
163 t.Error("Input should default to os.Stdin")
164 }
165 if list.opts.Title != "Items" {
166 t.Error("Title should default to 'Items'")
167 }
168 if list.opts.ItemRenderer == nil {
169 t.Error("ItemRenderer should have a default")
170 }
171 })
172
173 t.Run("custom options", func(t *testing.T) {
174 var buf bytes.Buffer
175 source := &MockListSource{items: createMockItems()}
176 opts := DataListOptions{
177 Output: &buf,
178 Static: true,
179 Title: "Test List",
180 ShowSearch: true,
181 Searchable: true,
182 ViewHandler: func(item ListItem) string {
183 return fmt.Sprintf("Viewing: %s", item.GetTitle())
184 },
185 ItemRenderer: func(item ListItem, selected bool) string {
186 prefix := " "
187 if selected {
188 prefix = "> "
189 }
190 return fmt.Sprintf("%s%s", prefix, item.GetTitle())
191 },
192 }
193
194 list := NewDataList(source, opts)
195 if list.opts.Output != &buf {
196 t.Error("Custom output not set")
197 }
198 if !list.opts.Static {
199 t.Error("Static mode not set")
200 }
201 if list.opts.Title != "Test List" {
202 t.Error("Custom title not set")
203 }
204 if !list.opts.ShowSearch {
205 t.Error("ShowSearch not set")
206 }
207 if !list.opts.Searchable {
208 t.Error("Searchable not set")
209 }
210 })
211 })
212
213 t.Run("Static Mode", func(t *testing.T) {
214 t.Run("successful static display", func(t *testing.T) {
215 var buf bytes.Buffer
216 source := &MockListSource{items: createMockItems()}
217
218 list := NewDataList(source, DataListOptions{
219 Output: &buf,
220 Static: true,
221 Title: "Test List",
222 })
223
224 err := list.Browse(context.Background())
225 if err != nil {
226 t.Fatalf("Browse failed: %v", err)
227 }
228
229 output := buf.String()
230 if !strings.Contains(output, "Test List") {
231 t.Error("Title not displayed")
232 }
233 if !strings.Contains(output, "First Item") {
234 t.Error("First item not displayed")
235 }
236 if !strings.Contains(output, "Second Item") {
237 t.Error("Second item not displayed")
238 }
239 })
240
241 t.Run("static display with no items", func(t *testing.T) {
242 var buf bytes.Buffer
243 source := &MockListSource{items: []ListItem{}}
244
245 list := NewDataList(source, DataListOptions{
246 Output: &buf,
247 Static: true,
248 })
249
250 err := list.Browse(context.Background())
251 if err != nil {
252 t.Fatalf("Browse failed: %v", err)
253 }
254
255 output := buf.String()
256 if !strings.Contains(output, "No items found") {
257 t.Error("No items message not displayed")
258 }
259 })
260
261 t.Run("static display with load error", func(t *testing.T) {
262 var buf bytes.Buffer
263 source := &MockListSource{
264 loadError: errors.New("connection failed"),
265 }
266
267 list := NewDataList(source, DataListOptions{
268 Output: &buf,
269 Static: true,
270 })
271
272 err := list.Browse(context.Background())
273 if err == nil {
274 t.Fatal("Expected error, got nil")
275 }
276
277 output := buf.String()
278 if !strings.Contains(output, "Error: connection failed") {
279 t.Error("Error message not displayed")
280 }
281 })
282
283 t.Run("static display with filters", func(t *testing.T) {
284 var buf bytes.Buffer
285 source := &MockListSource{items: createMockItems()}
286
287 list := NewDataList(source, DataListOptions{
288 Output: &buf,
289 Static: true,
290 })
291
292 opts := ListOptions{
293 Filters: map[string]any{
294 "title": "First Item",
295 },
296 }
297
298 err := list.BrowseWithOptions(context.Background(), opts)
299 if err != nil {
300 t.Fatalf("Browse failed: %v", err)
301 }
302
303 output := buf.String()
304 if !strings.Contains(output, "First Item") {
305 t.Error("Filtered item not displayed")
306 }
307 if strings.Contains(output, "Second Item") {
308 t.Error("Non-matching item should be filtered out")
309 }
310 })
311 })
312
313 t.Run("Model", func(t *testing.T) {
314 t.Run("initial model state", func(t *testing.T) {
315 model := dataListModel{
316 opts: DataListOptions{
317 Title: "Test",
318 },
319 loading: true,
320 }
321
322 if model.selected != 0 {
323 t.Error("Initial selected should be 0")
324 }
325 if model.viewing {
326 t.Error("Initial viewing should be false")
327 }
328 if model.searching {
329 t.Error("Initial searching should be false")
330 }
331 if !model.loading {
332 t.Error("Initial loading should be true")
333 }
334 })
335
336 t.Run("load items command", func(t *testing.T) {
337 source := &MockListSource{items: createMockItems()}
338
339 model := dataListModel{
340 source: source,
341 keys: DefaultDataListKeys(),
342 listOpts: ListOptions{},
343 }
344
345 cmd := model.loadItems()
346 if cmd == nil {
347 t.Fatal("loadItems should return a command")
348 }
349
350 msg := cmd()
351 switch msg := msg.(type) {
352 case listLoadedMsg:
353 items := []ListItem(msg)
354 if len(items) != 3 {
355 t.Errorf("Expected 3 items, got %d", len(items))
356 }
357 case listErrorMsg:
358 t.Fatalf("Unexpected error: %v", error(msg))
359 default:
360 t.Fatalf("Unexpected message type: %T", msg)
361 }
362 })
363
364 t.Run("load items with error", func(t *testing.T) {
365 source := &MockListSource{
366 loadError: errors.New("load failed"),
367 }
368
369 model := dataListModel{
370 source: source,
371 listOpts: ListOptions{},
372 }
373
374 cmd := model.loadItems()
375 msg := cmd()
376
377 switch msg := msg.(type) {
378 case listErrorMsg:
379 err := error(msg)
380 if !strings.Contains(err.Error(), "load failed") {
381 t.Errorf("Expected load error, got: %v", err)
382 }
383 default:
384 t.Fatalf("Expected listErrorMsg, got: %T", msg)
385 }
386 })
387
388 t.Run("search items command", func(t *testing.T) {
389 source := &MockListSource{items: createMockItems()}
390
391 model := dataListModel{
392 source: source,
393 listOpts: ListOptions{},
394 }
395
396 cmd := model.searchItems("First")
397 if cmd == nil {
398 t.Fatal("searchItems should return a command")
399 }
400
401 msg := cmd()
402 switch msg := msg.(type) {
403 case listLoadedMsg:
404 items := []ListItem(msg)
405 if len(items) != 1 {
406 t.Errorf("Expected 1 search result, got %d", len(items))
407 }
408 if items[0].GetTitle() != "First Item" {
409 t.Error("Search should return matching item")
410 }
411 case listErrorMsg:
412 t.Fatalf("Unexpected error: %v", error(msg))
413 default:
414 t.Fatalf("Unexpected message type: %T", msg)
415 }
416 })
417
418 t.Run("search items with error", func(t *testing.T) {
419 source := &MockListSource{
420 items: createMockItems(),
421 searchError: errors.New("search failed"),
422 }
423
424 model := dataListModel{
425 source: source,
426 listOpts: ListOptions{},
427 }
428
429 cmd := model.searchItems("test")
430 msg := cmd()
431
432 switch msg := msg.(type) {
433 case listErrorMsg:
434 err := error(msg)
435 if !strings.Contains(err.Error(), "search failed") {
436 t.Errorf("Expected search error, got: %v", err)
437 }
438 default:
439 t.Fatalf("Expected listErrorMsg, got: %T", msg)
440 }
441 })
442
443 t.Run("view item command", func(t *testing.T) {
444 viewHandler := func(item ListItem) string {
445 return fmt.Sprintf("Viewing: %s", item.GetTitle())
446 }
447
448 model := dataListModel{
449 opts: DataListOptions{
450 ViewHandler: viewHandler,
451 },
452 }
453
454 item := createMockItems()[0]
455 cmd := model.viewItem(item)
456 msg := cmd()
457
458 switch msg := msg.(type) {
459 case listViewMsg:
460 content := string(msg)
461 if !strings.Contains(content, "Viewing: First Item") {
462 t.Error("View content not formatted correctly")
463 }
464 default:
465 t.Fatalf("Expected listViewMsg, got: %T", msg)
466 }
467 })
468 })
469
470 t.Run("Key Handling", func(t *testing.T) {
471 source := &MockListSource{items: createMockItems()}
472
473 t.Run("navigation keys", func(t *testing.T) {
474 model := dataListModel{
475 source: source,
476 items: createMockItems(),
477 selected: 1,
478 keys: DefaultDataListKeys(),
479 opts: DataListOptions{},
480 }
481
482 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
483 if m, ok := newModel.(dataListModel); ok {
484 if m.selected != 0 {
485 t.Errorf("Up key should move selection to 0, got %d", m.selected)
486 }
487 }
488
489 model.selected = 1
490 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
491 if m, ok := newModel.(dataListModel); ok {
492 if m.selected != 2 {
493 t.Errorf("Down key should move selection to 2, got %d", m.selected)
494 }
495 }
496 })
497
498 t.Run("boundary conditions", func(t *testing.T) {
499 model := dataListModel{
500 source: source,
501 items: createMockItems(),
502 selected: 0,
503 keys: DefaultDataListKeys(),
504 opts: DataListOptions{},
505 }
506
507 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
508 if m, ok := newModel.(dataListModel); ok {
509 if m.selected != 0 {
510 t.Error("Up key at top should not change selection")
511 }
512 }
513
514 model.selected = 2
515 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
516 if m, ok := newModel.(dataListModel); ok {
517 if m.selected != 2 {
518 t.Error("Down key at bottom should not change selection")
519 }
520 }
521 })
522
523 t.Run("search key", func(t *testing.T) {
524 model := dataListModel{
525 source: source,
526 keys: DefaultDataListKeys(),
527 opts: DataListOptions{
528 ShowSearch: true,
529 },
530 }
531
532 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
533 if m, ok := newModel.(dataListModel); ok {
534 if !m.searching {
535 t.Error("Search key should enable search mode")
536 }
537 if m.searchQuery != "" {
538 t.Error("Search query should be empty initially")
539 }
540 }
541 })
542
543 t.Run("search mode input", func(t *testing.T) {
544 model := dataListModel{
545 source: source,
546 keys: DefaultDataListKeys(),
547 searching: true,
548 opts: DataListOptions{
549 Searchable: true,
550 },
551 }
552
553 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
554 if m, ok := newModel.(dataListModel); ok {
555 if m.searchQuery != "a" {
556 t.Errorf("Expected search query 'a', got '%s'", m.searchQuery)
557 }
558 }
559
560 model.searchQuery = "ab"
561 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("backspace")})
562 if m, ok := newModel.(dataListModel); ok {
563 if m.searchQuery != "a" {
564 t.Errorf("Backspace should remove last character, got '%s'", m.searchQuery)
565 }
566 }
567
568 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("esc")})
569 if m, ok := newModel.(dataListModel); ok {
570 if m.searching {
571 t.Error("Escape should exit search mode")
572 }
573 }
574 })
575
576 t.Run("view key with handler", func(t *testing.T) {
577 viewHandler := func(item ListItem) string {
578 return "test view"
579 }
580
581 model := dataListModel{
582 source: source,
583 items: createMockItems(),
584 keys: DefaultDataListKeys(),
585 opts: DataListOptions{
586 ViewHandler: viewHandler,
587 },
588 }
589
590 _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("v")})
591 if cmd == nil {
592 t.Error("View key should return command when handler is set")
593 }
594 })
595
596 t.Run("refresh key", func(t *testing.T) {
597 model := dataListModel{
598 source: source,
599 keys: DefaultDataListKeys(),
600 opts: DataListOptions{},
601 }
602
603 newModel, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
604 if cmd == nil {
605 t.Error("Refresh key should return command")
606 }
607 if m, ok := newModel.(dataListModel); ok {
608 if !m.loading {
609 t.Error("Refresh should set loading to true")
610 }
611 }
612 })
613
614 t.Run("help mode", func(t *testing.T) {
615 model := dataListModel{
616 keys: DefaultDataListKeys(),
617 showingHelp: true,
618 opts: DataListOptions{},
619 }
620
621 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
622 if m, ok := newModel.(dataListModel); ok {
623 if m.selected != 0 {
624 t.Error("Navigation should be ignored in help mode")
625 }
626 }
627
628 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
629 if m, ok := newModel.(dataListModel); ok {
630 if m.showingHelp {
631 t.Error("Help key should exit help mode")
632 }
633 }
634 })
635
636 t.Run("viewing mode", func(t *testing.T) {
637 model := dataListModel{
638 keys: DefaultDataListKeys(),
639 viewing: true,
640 viewContent: "test content",
641 opts: DataListOptions{},
642 }
643
644 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
645 if m, ok := newModel.(dataListModel); ok {
646 if m.viewing {
647 t.Error("Quit should exit viewing mode")
648 }
649 if m.viewContent != "" {
650 t.Error("Quit should clear view content")
651 }
652 }
653 })
654 })
655
656 t.Run("View", func(t *testing.T) {
657 source := &MockListSource{items: createMockItems()}
658
659 t.Run("normal view", func(t *testing.T) {
660 model := dataListModel{
661 source: source,
662 items: createMockItems(),
663 keys: DefaultDataListKeys(),
664 help: help.New(),
665 opts: DataListOptions{
666 Title: "Test List",
667 ItemRenderer: defaultItemRenderer,
668 },
669 }
670
671 view := model.View()
672 if !strings.Contains(view, "Test List") {
673 t.Error("Title not displayed")
674 }
675 if !strings.Contains(view, "First Item") {
676 t.Error("Item data not displayed")
677 }
678 if !strings.Contains(view, "> ") {
679 t.Error("Selection indicator not displayed")
680 }
681 })
682
683 t.Run("loading view", func(t *testing.T) {
684 model := dataListModel{
685 loading: true,
686 opts: DataListOptions{Title: "Test"},
687 }
688
689 view := model.View()
690 if !strings.Contains(view, "Loading...") {
691 t.Error("Loading message not displayed")
692 }
693 })
694
695 t.Run("error view", func(t *testing.T) {
696 model := dataListModel{
697 err: errors.New("test error"),
698 opts: DataListOptions{Title: "Test"},
699 }
700
701 view := model.View()
702 if !strings.Contains(view, "Error: test error") {
703 t.Error("Error message not displayed")
704 }
705 })
706
707 t.Run("empty items view", func(t *testing.T) {
708 model := dataListModel{
709 items: []ListItem{},
710 opts: DataListOptions{Title: "Test"},
711 }
712
713 view := model.View()
714 if !strings.Contains(view, "No items found") {
715 t.Error("Empty message not displayed")
716 }
717 })
718
719 t.Run("search mode view", func(t *testing.T) {
720 model := dataListModel{
721 searching: true,
722 searchQuery: "test",
723 opts: DataListOptions{Title: "Test"},
724 }
725
726 view := model.View()
727 if !strings.Contains(view, "Search: test") {
728 t.Error("Search query not displayed")
729 }
730 if !strings.Contains(view, "Press Enter to search") {
731 t.Error("Search instructions not displayed")
732 }
733 })
734
735 t.Run("viewing mode", func(t *testing.T) {
736 vp := viewport.New(80, 20)
737 vp.SetContent("# Test Content\nDetails here")
738
739 model := dataListModel{
740 viewing: true,
741 viewContent: "# Test Content\nDetails here",
742 viewViewport: vp,
743 opts: DataListOptions{},
744 }
745
746 view := model.View()
747 if !strings.Contains(view, "# Test Content") {
748 t.Error("View content not displayed")
749 }
750 if !strings.Contains(view, "q/esc: back") {
751 t.Error("Return instructions not displayed")
752 }
753 })
754
755 t.Run("search in title", func(t *testing.T) {
756 model := dataListModel{
757 items: createMockItems(),
758 searchQuery: "First",
759 keys: DefaultDataListKeys(),
760 help: help.New(),
761 opts: DataListOptions{
762 Title: "Test",
763 ItemRenderer: defaultItemRenderer,
764 },
765 }
766
767 view := model.View()
768 if !strings.Contains(view, "Search: First") {
769 t.Error("Search query should be displayed in title")
770 }
771 })
772
773 t.Run("custom item renderer", func(t *testing.T) {
774 customRenderer := func(item ListItem, selected bool) string {
775 if selected {
776 return fmt.Sprintf("*** %s ***", item.GetTitle())
777 }
778 return fmt.Sprintf(" %s", item.GetTitle())
779 }
780
781 model := dataListModel{
782 items: createMockItems(),
783 keys: DefaultDataListKeys(),
784 help: help.New(),
785 opts: DataListOptions{
786 ItemRenderer: customRenderer,
787 },
788 }
789
790 view := model.View()
791 if !strings.Contains(view, "*** First Item ***") {
792 t.Error("Custom renderer not applied for selected item")
793 }
794 })
795 })
796
797 t.Run("Update", func(t *testing.T) {
798 source := &MockListSource{items: createMockItems()}
799
800 t.Run("list loaded message", func(t *testing.T) {
801 model := dataListModel{
802 source: source,
803 loading: true,
804 opts: DataListOptions{},
805 }
806
807 items := createMockItems()[:2]
808 newModel, _ := model.Update(listLoadedMsg(items))
809
810 if m, ok := newModel.(dataListModel); ok {
811 if len(m.items) != 2 {
812 t.Errorf("Expected 2 items, got %d", len(m.items))
813 }
814 if m.loading {
815 t.Error("Loading should be set to false")
816 }
817 }
818 })
819
820 t.Run("selected index adjustment", func(t *testing.T) {
821 model := dataListModel{
822 selected: 5,
823 opts: DataListOptions{},
824 }
825
826 items := createMockItems()[:2]
827 newModel, _ := model.Update(listLoadedMsg(items))
828
829 if m, ok := newModel.(dataListModel); ok {
830 if m.selected != 1 {
831 t.Errorf("Selected should be adjusted to 1, got %d", m.selected)
832 }
833 }
834 })
835
836 t.Run("list view message", func(t *testing.T) {
837 model := dataListModel{
838 opts: DataListOptions{},
839 }
840
841 content := "Test view content"
842 newModel, _ := model.Update(listViewMsg(content))
843
844 if m, ok := newModel.(dataListModel); ok {
845 if !m.viewing {
846 t.Error("Viewing mode should be activated")
847 }
848 if m.viewContent != content {
849 t.Error("View content not set correctly")
850 }
851 }
852 })
853
854 t.Run("list error message", func(t *testing.T) {
855 model := dataListModel{
856 loading: true,
857 opts: DataListOptions{},
858 }
859
860 testErr := errors.New("test error")
861 newModel, _ := model.Update(listErrorMsg(testErr))
862
863 if m, ok := newModel.(dataListModel); ok {
864 if m.err == nil {
865 t.Error("Error should be set")
866 }
867 if m.err.Error() != "test error" {
868 t.Errorf("Expected 'test error', got %v", m.err)
869 }
870 if m.loading {
871 t.Error("Loading should be set to false on error")
872 }
873 }
874 })
875
876 t.Run("list count message", func(t *testing.T) {
877 model := dataListModel{
878 opts: DataListOptions{},
879 }
880
881 count := 42
882 newModel, _ := model.Update(listCountMsg(count))
883
884 if m, ok := newModel.(dataListModel); ok {
885 if m.totalCount != count {
886 t.Errorf("Expected count %d, got %d", count, m.totalCount)
887 }
888 }
889 })
890 })
891
892 t.Run("Default Keys", func(t *testing.T) {
893 keys := DefaultDataListKeys()
894
895 if len(keys.Numbers) != 9 {
896 t.Errorf("Expected 9 number bindings, got %d", len(keys.Numbers))
897 }
898
899 if keys.Actions == nil {
900 t.Error("Actions map should be initialized")
901 }
902 })
903
904 t.Run("Actions", func(t *testing.T) {
905 t.Run("action key handling", func(t *testing.T) {
906 actionCalled := false
907 action := ListAction{
908 Key: "d",
909 Description: "delete",
910 Handler: func(item ListItem) tea.Cmd {
911 actionCalled = true
912 return nil
913 },
914 }
915
916 keys := DefaultDataListKeys()
917 keys.Actions["d"] = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete"))
918
919 model := dataListModel{
920 source: &MockListSource{items: createMockItems()},
921 items: createMockItems(),
922 keys: keys,
923 opts: DataListOptions{
924 Actions: []ListAction{action},
925 },
926 }
927
928 _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")})
929 if cmd != nil {
930 cmd()
931 }
932
933 if !actionCalled {
934 t.Error("Action handler should be called")
935 }
936 })
937 })
938
939 t.Run("Default Item Renderer", func(t *testing.T) {
940 item := createMockItems()[0]
941
942 t.Run("unselected item", func(t *testing.T) {
943 result := defaultItemRenderer(item, false)
944 if !strings.HasPrefix(result, " ") {
945 t.Error("Unselected item should have ' ' prefix")
946 }
947 if !strings.Contains(result, "First Item") {
948 t.Error("Item title should be displayed")
949 }
950 if !strings.Contains(result, "Description of first item") {
951 t.Error("Item description should be displayed")
952 }
953 })
954
955 t.Run("selected item", func(t *testing.T) {
956 result := defaultItemRenderer(item, true)
957 if !strings.HasPrefix(result, "> ") {
958 t.Error("Selected item should have '> ' prefix")
959 }
960 })
961
962 t.Run("item without description", func(t *testing.T) {
963 itemWithoutDesc := NewMockItem(1, "Test", "", "filter")
964 result := defaultItemRenderer(itemWithoutDesc, false)
965 if strings.Contains(result, " - ") {
966 t.Error("Item without description should not have separator")
967 }
968 })
969 })
970}