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}