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