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/viewport"
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14)
15
16func createMockTask() *models.Task {
17 now := time.Now()
18 due := now.Add(24 * time.Hour)
19 start := now.Add(-2 * time.Hour)
20
21 return &models.Task{
22 ID: 1,
23 UUID: "test-uuid-123",
24 Description: "Test task description",
25 Status: "pending",
26 Priority: "high",
27 Project: "test-project",
28 Tags: []string{"urgent", "test"},
29 Entry: now.Add(-24 * time.Hour),
30 Modified: now.Add(-1 * time.Hour),
31 Due: &due,
32 Start: &start,
33 Annotations: []string{"First annotation", "Second annotation"},
34 }
35}
36
37func createCompletedMockTask() *models.Task {
38 now := time.Now()
39 task := createMockTask()
40 task.Status = "completed"
41 task.End = &now
42 return task
43}
44
45func TestTaskView(t *testing.T) {
46 t.Run("View Options", func(t *testing.T) {
47 task := createMockTask()
48
49 t.Run("default options", func(t *testing.T) {
50 opts := TaskViewOptions{}
51 tv := NewTaskView(task, opts)
52
53 if tv.opts.Output == nil {
54 t.Error("Output should default to os.Stdout")
55 }
56 if tv.opts.Input == nil {
57 t.Error("Input should default to os.Stdin")
58 }
59 if tv.opts.Width != 80 {
60 t.Errorf("Width should default to 80, got %d", tv.opts.Width)
61 }
62 if tv.opts.Height != 24 {
63 t.Errorf("Height should default to 24, got %d", tv.opts.Height)
64 }
65 })
66
67 t.Run("custom options", func(t *testing.T) {
68 var buf bytes.Buffer
69 opts := TaskViewOptions{
70 Output: &buf,
71 Static: true,
72 Width: 100,
73 Height: 30,
74 }
75 tv := NewTaskView(task, opts)
76
77 if tv.opts.Output != &buf {
78 t.Error("Custom output not set")
79 }
80 if !tv.opts.Static {
81 t.Error("Static mode not set")
82 }
83 if tv.opts.Width != 100 {
84 t.Error("Custom width not set")
85 }
86 if tv.opts.Height != 30 {
87 t.Error("Custom height not set")
88 }
89 })
90 })
91
92 t.Run("New", func(t *testing.T) {
93 task := createMockTask()
94
95 t.Run("creates task view correctly", func(t *testing.T) {
96 opts := TaskViewOptions{Width: 60, Height: 20}
97 tv := NewTaskView(task, opts)
98
99 if tv.task != task {
100 t.Error("Task not set correctly")
101 }
102 if tv.opts.Width != 60 {
103 t.Error("Width not set correctly")
104 }
105 if tv.opts.Height != 20 {
106 t.Error("Height not set correctly")
107 }
108 })
109 })
110
111 t.Run("Static Mode", func(t *testing.T) {
112 t.Run("basic task display", func(t *testing.T) {
113 task := createMockTask()
114 var buf bytes.Buffer
115
116 tv := NewTaskView(task, TaskViewOptions{
117 Output: &buf,
118 Static: true,
119 })
120
121 err := tv.Show(context.Background())
122 if err != nil {
123 t.Fatalf("Show failed: %v", err)
124 }
125
126 output := buf.String()
127
128 if !strings.Contains(output, "Task 1") {
129 t.Error("Task title not displayed")
130 }
131
132 if !strings.Contains(output, "test-uuid-123") {
133 t.Error("UUID not displayed")
134 }
135 if !strings.Contains(output, "Test task description") {
136 t.Error("Description not displayed")
137 }
138 if !strings.Contains(output, "Pending") {
139 t.Error("Status not displayed with title case")
140 }
141 if !strings.Contains(output, "High") {
142 t.Error("Priority not displayed with title case")
143 }
144 if !strings.Contains(output, "test-project") {
145 t.Error("Project not displayed")
146 }
147 if !strings.Contains(output, "urgent, test") {
148 t.Error("Tags not displayed correctly")
149 }
150
151 if !strings.Contains(output, "Dates:") {
152 t.Error("Dates section not displayed")
153 }
154 if !strings.Contains(output, "Created:") {
155 t.Error("Created date not displayed")
156 }
157 if !strings.Contains(output, "Modified:") {
158 t.Error("Modified date not displayed")
159 }
160 if !strings.Contains(output, "Due:") {
161 t.Error("Due date not displayed")
162 }
163 if !strings.Contains(output, "Started:") {
164 t.Error("Start date not displayed")
165 }
166
167 if !strings.Contains(output, "Annotations:") {
168 t.Error("Annotations section not displayed")
169 }
170 if !strings.Contains(output, "First annotation") {
171 t.Error("First annotation not displayed")
172 }
173 if !strings.Contains(output, "Second annotation") {
174 t.Error("Second annotation not displayed")
175 }
176 })
177
178 t.Run("completed task display", func(t *testing.T) {
179 task := createCompletedMockTask()
180 var buf bytes.Buffer
181
182 tv := NewTaskView(task, TaskViewOptions{
183 Output: &buf,
184 Static: true,
185 })
186
187 err := tv.Show(context.Background())
188 if err != nil {
189 t.Fatalf("Show failed: %v", err)
190 }
191
192 output := buf.String()
193
194 if !strings.Contains(output, "Completed") {
195 t.Error("Completed status not displayed with title case")
196 }
197 if !strings.Contains(output, "Completed:") {
198 t.Error("Completion date not displayed")
199 }
200 })
201
202 t.Run("minimal task display", func(t *testing.T) {
203 now := time.Now()
204 task := &models.Task{
205 ID: 2,
206 UUID: "minimal-uuid",
207 Description: "Minimal task",
208 Status: "pending",
209 Entry: now,
210 Modified: now,
211 }
212
213 var buf bytes.Buffer
214 tv := NewTaskView(task, TaskViewOptions{
215 Output: &buf,
216 Static: true,
217 })
218
219 err := tv.Show(context.Background())
220 if err != nil {
221 t.Fatalf("Show failed: %v", err)
222 }
223
224 output := buf.String()
225
226 if !strings.Contains(output, "Task 2") {
227 t.Error("Task title not displayed")
228 }
229 if !strings.Contains(output, "minimal-uuid") {
230 t.Error("UUID not displayed")
231 }
232 if !strings.Contains(output, "Minimal task") {
233 t.Error("Description not displayed")
234 }
235
236 // Should not contain optional fields
237 if strings.Contains(output, "Priority:") {
238 t.Error("Priority should not be displayed for minimal task")
239 }
240 if strings.Contains(output, "Project:") {
241 t.Error("Project should not be displayed for minimal task")
242 }
243 if strings.Contains(output, "Tags:") {
244 t.Error("Tags should not be displayed for minimal task")
245 }
246 if strings.Contains(output, "Annotations:") {
247 t.Error("Annotations should not be displayed for minimal task")
248 }
249 })
250 })
251
252 t.Run("Format Content", func(t *testing.T) {
253 t.Run("formats task content correctly", func(t *testing.T) {
254 task := createMockTask()
255 content := formatTaskContent(task)
256
257 expectedStrings := []string{
258 "UUID: test-uuid-123",
259 "Description: Test task description",
260 "Status: Pending",
261 "Priority: High",
262 "Project: test-project",
263 "Tags: urgent, test",
264 "Dates:",
265 "Created:",
266 "Modified:",
267 "Due:",
268 "Started:",
269 "Annotations:",
270 "1. First annotation",
271 "2. Second annotation",
272 }
273
274 for _, expected := range expectedStrings {
275 if !strings.Contains(content, expected) {
276 t.Errorf("Expected content '%s' not found in formatted output", expected)
277 }
278 }
279 })
280
281 t.Run("handles empty optional fields", func(t *testing.T) {
282 now := time.Now()
283 task := &models.Task{
284 ID: 1,
285 UUID: "test-uuid",
286 Description: "Test description",
287 Status: "pending",
288 Entry: now,
289 Modified: now,
290 }
291
292 content := formatTaskContent(task)
293
294 if strings.Contains(content, "Priority:") {
295 t.Error("Priority should not appear when empty")
296 }
297 if strings.Contains(content, "Project:") {
298 t.Error("Project should not appear when empty")
299 }
300 if strings.Contains(content, "Tags:") {
301 t.Error("Tags should not appear when empty")
302 }
303 if strings.Contains(content, "Annotations:") {
304 t.Error("Annotations should not appear when empty")
305 }
306 if strings.Contains(content, "Due:") {
307 t.Error("Due date should not appear when nil")
308 }
309 if strings.Contains(content, "Started:") {
310 t.Error("Start date should not appear when nil")
311 }
312 if strings.Contains(content, "Completed:") {
313 t.Error("End date should not appear when nil")
314 }
315 })
316 })
317
318 t.Run("Model", func(t *testing.T) {
319 task := createMockTask()
320
321 t.Run("initial model state", func(t *testing.T) {
322 vp := viewport.New(80, 20)
323 vp.SetContent(formatTaskContent(task))
324
325 model := taskViewModel{task: task, opts: TaskViewOptions{Width: 80, Height: 24}}
326
327 if model.showingHelp {
328 t.Error("Initial showingHelp should be false")
329 }
330 if model.task != task {
331 t.Error("Task not set correctly")
332 }
333 })
334
335 t.Run("key handling - help toggle", func(t *testing.T) {
336 vp := viewport.New(80, 20)
337 model := taskViewModel{
338 task: task,
339 viewport: vp,
340 keys: taskViewKeys,
341 help: help.New(),
342 }
343
344 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
345 if m, ok := newModel.(taskViewModel); ok {
346 if !m.showingHelp {
347 t.Error("Help key should show help")
348 }
349 }
350
351 model.showingHelp = true
352 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
353 if m, ok := newModel.(taskViewModel); ok {
354 if m.showingHelp {
355 t.Error("Help key should exit help when already showing")
356 }
357 }
358 })
359
360 t.Run("key handling - quit and back", func(t *testing.T) {
361 vp := viewport.New(80, 20)
362 model := taskViewModel{
363 task: task,
364 viewport: vp,
365 keys: taskViewKeys,
366 help: help.New(),
367 }
368
369 quitKeys := []string{"q", "esc"}
370 for _, key := range quitKeys {
371 _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
372 if cmd == nil {
373 t.Errorf("Key %s should return quit command", key)
374 }
375 }
376 })
377
378 t.Run("viewport navigation", func(t *testing.T) {
379 vp := viewport.New(80, 20)
380 longContent := strings.Repeat("Line of content\n", 50) // Create content longer than viewport
381 vp.SetContent(longContent)
382
383 model := taskViewModel{
384 task: task,
385 viewport: vp,
386 keys: taskViewKeys,
387 help: help.New(),
388 }
389
390 initialOffset := model.viewport.YOffset
391
392 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
393 if m, ok := newModel.(taskViewModel); ok {
394 if m.viewport.YOffset <= initialOffset {
395 t.Error("Down key should scroll viewport down")
396 }
397 }
398
399 model.viewport.ScrollDown(5)
400 initialOffset = model.viewport.YOffset
401 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
402 if m, ok := newModel.(taskViewModel); ok {
403 if m.viewport.YOffset >= initialOffset {
404 t.Error("Up key should scroll viewport up")
405 }
406 }
407 })
408 })
409
410 t.Run("View Model", func(t *testing.T) {
411 task := createMockTask()
412
413 t.Run("normal view", func(t *testing.T) {
414 vp := viewport.New(80, 20)
415 vp.SetContent(formatTaskContent(task))
416
417 model := taskViewModel{
418 task: task,
419 viewport: vp,
420 keys: taskViewKeys,
421 help: help.New(),
422 }
423
424 view := model.View()
425
426 if !strings.Contains(view, "Task 1") {
427 t.Error("Task title not displayed in view")
428 }
429 if !strings.Contains(view, "help") {
430 t.Error("Help information not displayed")
431 }
432 })
433
434 t.Run("help view", func(t *testing.T) {
435 vp := viewport.New(80, 20)
436 model := taskViewModel{
437 task: task,
438 viewport: vp,
439 keys: taskViewKeys,
440 help: help.New(),
441 showingHelp: true,
442 }
443
444 view := model.View()
445
446 if !strings.Contains(view, "scroll") {
447 t.Error("Help view should contain scroll instructions")
448 }
449 })
450 })
451
452 t.Run("Integration", func(t *testing.T) {
453 t.Run("creates and displays task view", func(t *testing.T) {
454 task := createMockTask()
455 var buf bytes.Buffer
456
457 tv := NewTaskView(task, TaskViewOptions{
458 Output: &buf,
459 Static: true,
460 Width: 80,
461 Height: 24,
462 })
463
464 if tv == nil {
465 t.Fatal("NewTaskView returned nil")
466 }
467
468 err := tv.Show(context.Background())
469 if err != nil {
470 t.Fatalf("Show failed: %v", err)
471 }
472
473 output := buf.String()
474 if len(output) == 0 {
475 t.Error("No output generated")
476 }
477
478 if !strings.Contains(output, task.Description) {
479 t.Error("Task description not displayed")
480 }
481 if !strings.Contains(output, task.UUID) {
482 t.Error("Task UUID not displayed")
483 }
484 })
485 })
486}