cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 970 lines 24 kB view raw
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}