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 "strings"
7 "testing"
8 "time"
9
10 "github.com/charmbracelet/bubbles/help"
11 "github.com/charmbracelet/bubbles/textinput"
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14 "github.com/stormlightlabs/noteleaf/internal/repo"
15)
16
17type mockTaskRepo struct {
18 tasks map[int64]*models.Task
19 updated []*models.Task
20}
21
22func (m *mockTaskRepo) List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) {
23 var result []*models.Task
24 for _, task := range m.tasks {
25 result = append(result, task)
26 }
27 return result, nil
28}
29
30func (m *mockTaskRepo) Update(ctx context.Context, task *models.Task) error {
31 m.updated = append(m.updated, task)
32 if existing, exists := m.tasks[task.ID]; exists {
33 *existing = *task
34 }
35 return nil
36}
37
38func createTestTaskEditModel(task *models.Task) taskEditModel {
39 now := time.Now()
40 if task.Entry.IsZero() {
41 task.Entry = now
42 }
43 if task.Modified.IsZero() {
44 task.Modified = now
45 }
46
47 repo := &mockTaskRepo{tasks: map[int64]*models.Task{task.ID: task}}
48
49 model := taskEditModel{
50 task: task,
51 originalTask: task,
52 repo: repo,
53 opts: TaskEditOptions{Output: &bytes.Buffer{}, Width: 80, Height: 24},
54 keys: taskEditKeys,
55 help: help.New(),
56
57 mode: fieldNavigation,
58 currentField: 0,
59 priorityMode: priorityModeText,
60
61 fields: []string{"Description", "Status", "Priority", "Project"},
62 }
63
64 model.descInput = textinput.New()
65 model.descInput.SetValue(task.Description)
66 model.projectInput = textinput.New()
67 model.projectInput.SetValue(task.Project)
68
69 for i, status := range statusOptions {
70 if status == task.Status {
71 model.statusIndex = i
72 break
73 }
74 }
75
76 model.updatePriorityIndex()
77
78 return model
79}
80
81func TestTaskEditor(t *testing.T) {
82 t.Run("Creation", func(t *testing.T) {
83 task := &models.Task{
84 ID: 1,
85 Description: "Test task",
86 Status: models.StatusTodo,
87 Priority: models.PriorityHigh,
88 Project: "test-project",
89 }
90
91 repo := &mockTaskRepo{tasks: map[int64]*models.Task{1: task}}
92 editor := NewTaskEditor(task, repo, TaskEditOptions{Width: 80, Height: 24})
93
94 if editor.task != task {
95 t.Error("Task should be set correctly")
96 }
97
98 if editor.repo != repo {
99 t.Error("Repository should be set correctly")
100 }
101
102 if editor.opts.Width != 80 {
103 t.Errorf("Expected width 80, got %d", editor.opts.Width)
104 }
105 })
106
107 t.Run("Default Options", func(t *testing.T) {
108 task := &models.Task{ID: 1}
109 repo := &mockTaskRepo{}
110 editor := NewTaskEditor(task, repo, TaskEditOptions{})
111
112 if editor.opts.Width != 80 {
113 t.Errorf("Expected default width 80, got %d", editor.opts.Width)
114 }
115
116 if editor.opts.Height != 24 {
117 t.Errorf("Expected default height 24, got %d", editor.opts.Height)
118 }
119 })
120}
121
122func TestTaskEditModel(t *testing.T) {
123 t.Run("Init", func(t *testing.T) {
124 task := &models.Task{
125 ID: 1,
126 Description: "Test task",
127 Status: models.StatusInProgress,
128 Priority: models.PriorityMedium,
129 }
130
131 model := createTestTaskEditModel(task)
132 cmd := model.Init()
133 if cmd == nil {
134 t.Error("Init should return a command")
135 }
136 })
137
138 t.Run("Field Navigation", func(t *testing.T) {
139 task := &models.Task{ID: 1, Description: "Test task", Status: models.StatusTodo}
140 model := createTestTaskEditModel(task)
141
142 if model.currentField != 0 {
143 t.Errorf("Expected initial field 0, got %d", model.currentField)
144 }
145
146 msg := tea.KeyMsg{Type: tea.KeyDown}
147 updatedModel, _ := model.Update(msg)
148 model = updatedModel.(taskEditModel)
149
150 if model.currentField != 1 {
151 t.Errorf("Expected field 1 after down, got %d", model.currentField)
152 }
153
154 msg = tea.KeyMsg{Type: tea.KeyUp}
155 updatedModel, _ = model.Update(msg)
156 model = updatedModel.(taskEditModel)
157
158 if model.currentField != 0 {
159 t.Errorf("Expected field 0 after up, got %d", model.currentField)
160 }
161 })
162
163 t.Run("Status Picker", func(t *testing.T) {
164 task := &models.Task{ID: 1, Description: "Test task", Status: models.StatusTodo}
165 model := createTestTaskEditModel(task)
166 model.currentField = 1
167
168 msg := tea.KeyMsg{Type: tea.KeyEnter}
169 updatedModel, _ := model.Update(msg)
170 model = updatedModel.(taskEditModel)
171
172 if model.mode != statusPicker {
173 t.Errorf("Expected statusPicker mode, got %d", model.mode)
174 }
175
176 msg = tea.KeyMsg{Type: tea.KeyDown}
177 updatedModel, _ = model.Update(msg)
178 model = updatedModel.(taskEditModel)
179
180 if model.statusIndex != 1 {
181 t.Errorf("Expected status index 1, got %d", model.statusIndex)
182 }
183
184 msg = tea.KeyMsg{Type: tea.KeyEnter}
185 updatedModel, _ = model.Update(msg)
186 model = updatedModel.(taskEditModel)
187
188 if model.task.Status != statusOptions[1] {
189 t.Errorf("Expected status %s, got %s", statusOptions[1], model.task.Status)
190 }
191
192 if model.mode != fieldNavigation {
193 t.Errorf("Expected fieldNavigation mode after selection, got %d", model.mode)
194 }
195 })
196
197 t.Run("Priority Picker", func(t *testing.T) {
198 task := &models.Task{ID: 1, Description: "Test task", Priority: ""}
199 model := createTestTaskEditModel(task)
200 model.currentField = 2
201
202 msg := tea.KeyMsg{Type: tea.KeyEnter}
203 updatedModel, _ := model.Update(msg)
204 model = updatedModel.(taskEditModel)
205
206 if model.mode != priorityPicker {
207 t.Errorf("Expected priorityPicker mode, got %d", model.mode)
208 }
209
210 msg = tea.KeyMsg{Type: tea.KeyDown}
211 updatedModel, _ = model.Update(msg)
212 model = updatedModel.(taskEditModel)
213
214 if model.priorityIndex != 1 {
215 t.Errorf("Expected priority index 1, got %d", model.priorityIndex)
216 }
217
218 msg = tea.KeyMsg{Type: tea.KeyEnter}
219 updatedModel, _ = model.Update(msg)
220 model = updatedModel.(taskEditModel)
221
222 expectedPriority := textPriorityOptions[1]
223 if model.task.Priority != expectedPriority {
224 t.Errorf("Expected priority %s, got %s", expectedPriority, model.task.Priority)
225 }
226 })
227
228 t.Run("Priority Mode Switch", func(t *testing.T) {
229 task := &models.Task{ID: 1, Priority: models.PriorityHigh}
230 model := createTestTaskEditModel(task)
231 model.mode = priorityPicker
232
233 if model.priorityMode != priorityModeText {
234 t.Errorf("Expected text priority mode initially, got %d", model.priorityMode)
235 }
236
237 msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}
238 updatedModel, _ := model.Update(msg)
239 model = updatedModel.(taskEditModel)
240
241 if model.priorityMode != priorityModeNumeric {
242 t.Errorf("Expected numeric priority mode, got %d", model.priorityMode)
243 }
244
245 msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}
246 updatedModel, _ = model.Update(msg)
247 model = updatedModel.(taskEditModel)
248
249 if model.priorityMode != priorityModeLegacy {
250 t.Errorf("Expected legacy priority mode, got %d", model.priorityMode)
251 }
252
253 msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}
254 updatedModel, _ = model.Update(msg)
255 model = updatedModel.(taskEditModel)
256
257 if model.priorityMode != priorityModeText {
258 t.Errorf("Expected text priority mode after full cycle, got %d", model.priorityMode)
259 }
260 })
261
262 t.Run("TextInput", func(t *testing.T) {
263 task := &models.Task{ID: 1, Description: "Original description", Project: "original-project"}
264
265 model := createTestTaskEditModel(task)
266 model.currentField = 0
267
268 msg := tea.KeyMsg{Type: tea.KeyEnter}
269 updatedModel, _ := model.Update(msg)
270 model = updatedModel.(taskEditModel)
271
272 if model.mode != textInput {
273 t.Errorf("Expected textInput mode, got %d", model.mode)
274 }
275
276 msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}
277 updatedModel, _ = model.Update(msg)
278 model = updatedModel.(taskEditModel)
279
280 msg = tea.KeyMsg{Type: tea.KeyEnter}
281 updatedModel, _ = model.Update(msg)
282 model = updatedModel.(taskEditModel)
283
284 if model.mode != fieldNavigation {
285 t.Errorf("Expected fieldNavigation mode after text input, got %d", model.mode)
286 }
287
288 expected := "Original descriptionX"
289 if model.task.Description != expected {
290 t.Errorf("Expected description %s, got %s", expected, model.task.Description)
291 }
292 })
293
294 t.Run("Help", func(t *testing.T) {
295 task := &models.Task{ID: 1}
296 model := createTestTaskEditModel(task)
297
298 msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}
299 updatedModel, _ := model.Update(msg)
300 model = updatedModel.(taskEditModel)
301
302 if !model.showingHelp {
303 t.Error("Expected help to be shown")
304 }
305
306 msg = tea.KeyMsg{Type: tea.KeyEsc}
307 updatedModel, _ = model.Update(msg)
308 model = updatedModel.(taskEditModel)
309
310 if model.showingHelp {
311 t.Error("Expected help to be hidden")
312 }
313 })
314
315 t.Run("Save", func(t *testing.T) {
316 task := &models.Task{ID: 1}
317 model := createTestTaskEditModel(task)
318 msg := tea.KeyMsg{Type: tea.KeyCtrlS}
319 updatedModel, cmd := model.Update(msg)
320 model = updatedModel.(taskEditModel)
321
322 if !model.saved {
323 t.Error("Expected saved flag to be set")
324 }
325
326 if cmd == nil {
327 t.Error("Expected quit command after save")
328 }
329 })
330
331 t.Run("Cancel", func(t *testing.T) {
332 task := &models.Task{ID: 1}
333 model := createTestTaskEditModel(task)
334 msg := tea.KeyMsg{Type: tea.KeyCtrlC}
335 updatedModel, cmd := model.Update(msg)
336 model = updatedModel.(taskEditModel)
337
338 if !model.cancelled {
339 t.Error("Expected cancelled flag to be set")
340 }
341
342 if cmd == nil {
343 t.Error("Expected quit command after cancel")
344 }
345 })
346
347 t.Run("View", func(t *testing.T) {
348 task := &models.Task{
349 ID: 1,
350 Description: "Test task",
351 Status: models.StatusTodo,
352 Priority: models.PriorityHigh,
353 Project: "test-project",
354 }
355
356 model := createTestTaskEditModel(task)
357 view := model.View()
358
359 if !strings.Contains(view, "Edit Task") {
360 t.Error("View should contain title")
361 }
362
363 if !strings.Contains(view, "Test task") {
364 t.Error("View should contain task description")
365 }
366
367 if !strings.Contains(view, "test-project") {
368 t.Error("View should contain project")
369 }
370 })
371
372 t.Run("Status Picker View", func(t *testing.T) {
373 task := &models.Task{ID: 1, Status: models.StatusTodo}
374 model := createTestTaskEditModel(task)
375 model.mode = statusPicker
376
377 view := model.View()
378
379 if !strings.Contains(view, "Select Status:") {
380 t.Error("Status picker should show selection prompt")
381 }
382
383 for _, status := range statusOptions {
384 if !strings.Contains(view, status) {
385 t.Errorf("Status picker should contain %s", status)
386 }
387 }
388 })
389
390 t.Run("Priority Picker View", func(t *testing.T) {
391 task := &models.Task{ID: 1, Priority: ""}
392 model := createTestTaskEditModel(task)
393 model.mode = priorityPicker
394 model.priorityMode = priorityModeText
395
396 view := model.View()
397
398 if !strings.Contains(view, "Select Priority") {
399 t.Error("Priority picker should show selection prompt")
400 }
401
402 if !strings.Contains(view, "Text") {
403 t.Error("Priority picker should show current mode")
404 }
405 })
406
407 t.Run("KeyBindings", func(t *testing.T) {
408 keyMap := taskEditKeys
409
410 if keyMap.Up.Keys()[0] != "up" {
411 t.Error("Up key binding should be defined")
412 }
413
414 if keyMap.StatusEdit.Keys()[0] != "s" {
415 t.Error("Status edit key binding should be 's'")
416 }
417
418 if keyMap.Priority.Keys()[0] != "p" {
419 t.Error("Priority key binding should be 'p'")
420 }
421
422 if keyMap.PriorityMode.Keys()[0] != "m" {
423 t.Error("Priority mode key binding should be 'm'")
424 }
425 })
426}
427
428func TestUpdatePriorityIndex(t *testing.T) {
429 testCases := []struct {
430 priority string
431 mode priorityMode
432 expectedIdx int
433 }{
434 {models.PriorityHigh, priorityModeText, 3},
435 {models.PriorityMedium, priorityModeText, 2},
436 {models.PriorityLow, priorityModeText, 1},
437 {"", priorityModeText, 0},
438 {"3", priorityModeNumeric, 3},
439 {"A", priorityModeLegacy, 1},
440 {"unknown", priorityModeText, 0},
441 }
442
443 for _, tc := range testCases {
444 task := &models.Task{ID: 1, Priority: tc.priority}
445 model := createTestTaskEditModel(task)
446 model.priorityMode = tc.mode
447 model.updatePriorityIndex()
448
449 if model.priorityIndex != tc.expectedIdx {
450 t.Errorf("Priority %s in mode %d should have index %d, got %d",
451 tc.priority, tc.mode, tc.expectedIdx, model.priorityIndex)
452 }
453 }
454}
455
456func TestRenderStatusField(t *testing.T) {
457 task := &models.Task{ID: 1, Status: models.StatusInProgress}
458 model := createTestTaskEditModel(task)
459
460 result := model.renderStatusField()
461 if !strings.Contains(result, models.StatusInProgress) {
462 t.Error("Status field should contain the status")
463 }
464
465 model.mode = statusPicker
466 result = model.renderStatusField()
467 if !strings.Contains(result, models.StatusTodo) || !strings.Contains(result, models.StatusDone) {
468 t.Error("Status picker should show status legend")
469 }
470}
471
472func TestRenderPriorityField(t *testing.T) {
473 task := &models.Task{ID: 1, Priority: models.PriorityMedium}
474 model := createTestTaskEditModel(task)
475 result := model.renderPriorityField()
476 if !strings.Contains(result, models.PriorityMedium) {
477 t.Error("Priority field should contain the priority")
478 }
479
480 model.mode = priorityPicker
481 model.priorityMode = priorityModeNumeric
482 result = model.renderPriorityField()
483 if !strings.Contains(result, "Numeric") {
484 t.Error("Priority picker should show current mode")
485 }
486}
487
488// TestUncoveredPriorityModes tests all priority mode switch cases
489func TestUncoveredPriorityModes(t *testing.T) {
490 t.Run("Priority Mode Display Strings", func(t *testing.T) {
491 task := &models.Task{ID: 1, Priority: models.PriorityHigh}
492 model := createTestTaskEditModel(task)
493 model.mode = priorityPicker
494
495 // Test numeric mode
496 model.priorityMode = priorityModeNumeric
497 result := model.renderPriorityPicker()
498 if !strings.Contains(result, "Numeric") {
499 t.Error("Priority picker should show Numeric mode")
500 }
501
502 // Test legacy mode
503 model.priorityMode = priorityModeLegacy
504 result = model.renderPriorityPicker()
505 if !strings.Contains(result, "Legacy") {
506 t.Error("Priority picker should show Legacy mode")
507 }
508 })
509
510 t.Run("Priority Display Type Switches", func(t *testing.T) {
511 tests := []struct {
512 name string
513 priority string
514 expectedType string
515 }{
516 {"Numeric Priority", "3", "numeric"},
517 {"Legacy Priority A", "A", "legacy"},
518 {"Legacy Priority E", "E", "legacy"},
519 }
520
521 for _, tt := range tests {
522 t.Run(tt.name, func(t *testing.T) {
523 task := &models.Task{ID: 1, Priority: tt.priority}
524
525 displayType := GetPriorityDisplayType(task.Priority)
526 if displayType != tt.expectedType {
527 t.Errorf("Expected display type %s for priority %s, got %s (task:%v)", tt.expectedType, tt.priority, displayType, task)
528 }
529 })
530 }
531 })
532}
533
534func TestUncoveredKeyboardNavigation(t *testing.T) {
535 t.Run("Status Edit Key Binding", func(t *testing.T) {
536 task := &models.Task{ID: 1, Status: models.StatusTodo}
537 model := createTestTaskEditModel(task)
538
539 msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}
540 updatedModel, _ := model.Update(msg)
541 model = updatedModel.(taskEditModel)
542
543 if model.mode != statusPicker {
544 t.Error("Expected status picker mode when 's' key is pressed")
545 }
546 })
547
548 t.Run("Priority Edit Key Binding", func(t *testing.T) {
549 task := &models.Task{ID: 1}
550 model := createTestTaskEditModel(task)
551
552 msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}
553 updatedModel, _ := model.Update(msg)
554 model = updatedModel.(taskEditModel)
555
556 if model.mode != priorityPicker {
557 t.Error("Expected priority picker mode when 'p' key is pressed")
558 }
559 })
560
561 t.Run("Window Resize Handling", func(t *testing.T) {
562 task := &models.Task{ID: 1}
563 model := createTestTaskEditModel(task)
564
565 msg := tea.WindowSizeMsg{Width: 120, Height: 40}
566 updatedModel, _ := model.Update(msg)
567 model = updatedModel.(taskEditModel)
568
569 if model.opts.Width != 120 {
570 t.Errorf("Expected width to be updated to 120, got %d", model.opts.Width)
571 }
572 })
573
574 t.Run("Escape Key in Status Picker", func(t *testing.T) {
575 task := &models.Task{ID: 1}
576 model := createTestTaskEditModel(task)
577 model.mode = statusPicker
578
579 msg := tea.KeyMsg{Type: tea.KeyEsc}
580 updatedModel, _ := model.Update(msg)
581 model = updatedModel.(taskEditModel)
582
583 if model.mode != fieldNavigation {
584 t.Error("Expected to return to field navigation when escape is pressed in status picker")
585 }
586 })
587
588 t.Run("Escape Key in Priority Picker", func(t *testing.T) {
589 task := &models.Task{ID: 1}
590 model := createTestTaskEditModel(task)
591 model.mode = priorityPicker
592
593 msg := tea.KeyMsg{Type: tea.KeyEsc}
594 updatedModel, _ := model.Update(msg)
595 model = updatedModel.(taskEditModel)
596
597 if model.mode != fieldNavigation {
598 t.Error("Expected to return to field navigation when escape is pressed in priority picker")
599 }
600 })
601
602 t.Run("Navigation Keys in Status Picker", func(t *testing.T) {
603 task := &models.Task{ID: 1}
604 model := createTestTaskEditModel(task)
605 model.mode = statusPicker
606 model.statusIndex = 0
607
608 // Test down/right navigation
609 msg := tea.KeyMsg{Type: tea.KeyDown}
610 updatedModel, _ := model.Update(msg)
611 model = updatedModel.(taskEditModel)
612
613 if model.statusIndex != 1 {
614 t.Errorf("Expected status index to be 1, got %d", model.statusIndex)
615 }
616
617 // Test up/left navigation
618 msg = tea.KeyMsg{Type: tea.KeyUp}
619 updatedModel, _ = model.Update(msg)
620 model = updatedModel.(taskEditModel)
621
622 if model.statusIndex != 0 {
623 t.Errorf("Expected status index to be 0, got %d", model.statusIndex)
624 }
625 })
626
627 t.Run("Navigation Keys in Priority Picker", func(t *testing.T) {
628 task := &models.Task{ID: 1}
629 model := createTestTaskEditModel(task)
630 model.mode = priorityPicker
631 model.priorityIndex = 0
632
633 // Test down/right navigation
634 msg := tea.KeyMsg{Type: tea.KeyDown}
635 updatedModel, _ := model.Update(msg)
636 model = updatedModel.(taskEditModel)
637
638 if model.priorityIndex != 1 {
639 t.Errorf("Expected priority index to be 1, got %d", model.priorityIndex)
640 }
641
642 // Test up/left navigation
643 msg = tea.KeyMsg{Type: tea.KeyUp}
644 updatedModel, _ = model.Update(msg)
645 model = updatedModel.(taskEditModel)
646
647 if model.priorityIndex != 0 {
648 t.Errorf("Expected priority index to be 0, got %d", model.priorityIndex)
649 }
650 })
651}
652
653func TestUncoveredFieldSwitches(t *testing.T) {
654 t.Run("Field Entry Switch Cases", func(t *testing.T) {
655 task := &models.Task{ID: 1, Description: "Test", Project: "TestProject"}
656 model := createTestTaskEditModel(task)
657
658 model.currentField = 3
659 model.mode = textInput
660
661 msg := tea.KeyMsg{Type: tea.KeyEnter}
662 updatedModel, _ := model.Update(msg)
663 model = updatedModel.(taskEditModel)
664
665 if model.mode != fieldNavigation {
666 t.Error("Expected to return to field navigation after entering project field")
667 }
668 })
669
670 t.Run("Field Update Switch Cases", func(t *testing.T) {
671 task := &models.Task{ID: 1, Description: "Test", Project: "TestProject"}
672 model := createTestTaskEditModel(task)
673 model.currentField = 3
674 model.mode = textInput
675
676 model.projectInput.SetValue("Updated Project")
677
678 msg := tea.KeyMsg{Type: tea.KeyEnter}
679 updatedModel, _ := model.Update(msg)
680 model = updatedModel.(taskEditModel)
681
682 if model.task.Project != "Updated Project" {
683 t.Errorf("Expected project to be updated to 'Updated Project', got %s", model.task.Project)
684 }
685 })
686}
687
688func TestTaskFieldAccessors(t *testing.T) {
689 t.Run("Task Field Value Extraction", func(t *testing.T) {
690 now := time.Now()
691 task := &models.Task{
692 ID: 1,
693 Description: "Test",
694 Tags: []string{"tag1", "tag2"},
695 Due: &now,
696 Entry: now,
697 Start: &now,
698 End: &now,
699 }
700
701 tests := []struct {
702 field string
703 expected any
704 }{
705 {"due", task.Due},
706 {"entry", task.Entry},
707 {"start", task.Start},
708 {"end", task.End},
709 }
710
711 for _, tt := range tests {
712 t.Run(tt.field, func(t *testing.T) {
713 result := getTaskFieldValue(task, tt.field)
714 if result != tt.expected {
715 t.Errorf("Expected %v for field %s, got %v", tt.expected, tt.field, result)
716 }
717 })
718 }
719 })
720}
721
722func getTaskFieldValue(task *models.Task, field string) any {
723 switch field {
724 case "description":
725 return task.Description
726 case "status":
727 return task.Status
728 case "priority":
729 return task.Priority
730 case "project":
731 return task.Project
732 case "tags":
733 return task.Tags
734 case "due":
735 return task.Due
736 case "entry":
737 return task.Entry
738 case "start":
739 return task.Start
740 case "end":
741 return task.End
742 default:
743 return nil
744 }
745}