cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 665 lines 17 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 createMockNote() *models.Note { 17 now := time.Now() 18 publishedAt := now.Add(-24 * time.Hour) 19 rkey := "test-rkey-123" 20 cid := "test-cid-456" 21 22 return &models.Note{ 23 ID: 1, 24 Title: "Test Publication", 25 Content: "# Test Publication\n\nThis is the content of the test publication.", 26 Tags: []string{"test", "publication"}, 27 Archived: false, 28 Created: now.Add(-48 * time.Hour), 29 Modified: now.Add(-1 * time.Hour), 30 LeafletRKey: &rkey, 31 LeafletCID: &cid, 32 PublishedAt: &publishedAt, 33 IsDraft: false, 34 } 35} 36 37func createDraftNote() *models.Note { 38 note := createMockNote() 39 note.IsDraft = true 40 note.PublishedAt = nil 41 note.LeafletRKey = nil 42 note.LeafletCID = nil 43 return note 44} 45 46func createMinimalNote() *models.Note { 47 now := time.Now() 48 return &models.Note{ 49 ID: 2, 50 Title: "Minimal Note", 51 Content: "Simple content without markdown heading.", 52 Created: now, 53 Modified: now, 54 IsDraft: true, 55 } 56} 57 58func TestPublicationView(t *testing.T) { 59 t.Run("View Options", func(t *testing.T) { 60 note := createMockNote() 61 62 t.Run("default options", func(t *testing.T) { 63 opts := PublicationViewOptions{} 64 pv := NewPublicationView(note, opts) 65 66 if pv.opts.Output == nil { 67 t.Error("Output should default to os.Stdout") 68 } 69 if pv.opts.Input == nil { 70 t.Error("Input should default to os.Stdin") 71 } 72 if pv.opts.Width != 80 { 73 t.Errorf("Width should default to 80, got %d", pv.opts.Width) 74 } 75 if pv.opts.Height != 24 { 76 t.Errorf("Height should default to 24, got %d", pv.opts.Height) 77 } 78 }) 79 80 t.Run("custom options", func(t *testing.T) { 81 var buf bytes.Buffer 82 opts := PublicationViewOptions{ 83 Output: &buf, 84 Static: true, 85 Width: 100, 86 Height: 30, 87 } 88 pv := NewPublicationView(note, opts) 89 90 if pv.opts.Output != &buf { 91 t.Error("Custom output not set") 92 } 93 if !pv.opts.Static { 94 t.Error("Static mode not set") 95 } 96 if pv.opts.Width != 100 { 97 t.Error("Custom width not set") 98 } 99 if pv.opts.Height != 30 { 100 t.Error("Custom height not set") 101 } 102 }) 103 }) 104 105 t.Run("New", func(t *testing.T) { 106 note := createMockNote() 107 108 t.Run("creates publication view correctly", func(t *testing.T) { 109 opts := PublicationViewOptions{Width: 60, Height: 20} 110 pv := NewPublicationView(note, opts) 111 112 if pv.note != note { 113 t.Error("Note not set correctly") 114 } 115 if pv.opts.Width != 60 { 116 t.Error("Width not set correctly") 117 } 118 if pv.opts.Height != 20 { 119 t.Error("Height not set correctly") 120 } 121 }) 122 }) 123 124 t.Run("Static Mode", func(t *testing.T) { 125 t.Run("published note display", func(t *testing.T) { 126 note := createMockNote() 127 var buf bytes.Buffer 128 129 pv := NewPublicationView(note, PublicationViewOptions{ 130 Output: &buf, 131 Static: true, 132 }) 133 134 err := pv.Show(context.Background()) 135 if err != nil { 136 t.Fatalf("Show failed: %v", err) 137 } 138 139 output := buf.String() 140 141 if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") { 142 t.Error("Note title not displayed") 143 } 144 if !strings.Contains(output, "published") { 145 t.Error("Published status not displayed") 146 } 147 if !strings.Contains(output, "Published:") { 148 t.Error("Published date not displayed") 149 } 150 if !strings.Contains(output, "Modified:") { 151 t.Error("Modified date not displayed") 152 } 153 if !strings.Contains(output, "RKey:") { 154 t.Error("RKey not displayed") 155 } 156 if !strings.Contains(output, "tes") || !strings.Contains(output, "123") { 157 t.Error("RKey value not displayed") 158 } 159 if !strings.Contains(output, "CID:") { 160 t.Error("CID not displayed") 161 } 162 if !strings.Contains(output, "tes") || !strings.Contains(output, "456") { 163 t.Error("CID value not displayed") 164 } 165 if !strings.Contains(output, "This") || !strings.Contains(output, "content") { 166 t.Error("Note content not displayed") 167 } 168 }) 169 170 t.Run("draft note display", func(t *testing.T) { 171 note := createDraftNote() 172 var buf bytes.Buffer 173 174 pv := NewPublicationView(note, PublicationViewOptions{ 175 Output: &buf, 176 Static: true, 177 }) 178 179 err := pv.Show(context.Background()) 180 if err != nil { 181 t.Fatalf("Show failed: %v", err) 182 } 183 184 output := buf.String() 185 186 if !strings.Contains(output, "draft") { 187 t.Error("Draft status not displayed") 188 } 189 if strings.Contains(output, "Published:") { 190 t.Error("Published date should not be displayed for draft") 191 } 192 if strings.Contains(output, "RKey:") { 193 t.Error("RKey should not be displayed for draft") 194 } 195 if strings.Contains(output, "CID:") { 196 t.Error("CID should not be displayed for draft") 197 } 198 }) 199 200 t.Run("minimal note display", func(t *testing.T) { 201 note := createMinimalNote() 202 var buf bytes.Buffer 203 204 pv := NewPublicationView(note, PublicationViewOptions{ 205 Output: &buf, 206 Static: true, 207 }) 208 209 err := pv.Show(context.Background()) 210 if err != nil { 211 t.Fatalf("Show failed: %v", err) 212 } 213 214 output := buf.String() 215 216 if !strings.Contains(output, "Minimal") || !strings.Contains(output, "Note") { 217 t.Error("Note title not displayed") 218 } 219 if !strings.Contains(output, "Simple") || !strings.Contains(output, "content") { 220 t.Error("Note content not displayed") 221 } 222 if !strings.Contains(output, "Modified:") { 223 t.Error("Modified date not displayed") 224 } 225 }) 226 }) 227 228 t.Run("Build Markdown", func(t *testing.T) { 229 t.Run("builds markdown for published note", func(t *testing.T) { 230 note := createMockNote() 231 markdown := buildPublicationMarkdown(note) 232 233 expectedStrings := []string{ 234 "# Test Publication", 235 "**Status:** published", 236 "**Published:**", 237 "**Modified:**", 238 "**RKey:**", 239 "**CID:**", 240 "---", 241 "This is the content", 242 } 243 244 for _, expected := range expectedStrings { 245 if !strings.Contains(markdown, expected) { 246 t.Errorf("Expected markdown '%s' not found in output", expected) 247 } 248 } 249 }) 250 251 t.Run("builds markdown for draft note", func(t *testing.T) { 252 note := createDraftNote() 253 markdown := buildPublicationMarkdown(note) 254 255 if !strings.Contains(markdown, "**Status:** draft") { 256 t.Error("Draft status not in markdown") 257 } 258 if strings.Contains(markdown, "**Published:**") { 259 t.Error("Published date should not be in draft markdown") 260 } 261 if strings.Contains(markdown, "**RKey:**") { 262 t.Error("RKey should not be in draft markdown") 263 } 264 if strings.Contains(markdown, "**CID:**") { 265 t.Error("CID should not be in draft markdown") 266 } 267 }) 268 269 t.Run("handles content with markdown heading", func(t *testing.T) { 270 note := createMockNote() 271 markdown := buildPublicationMarkdown(note) 272 273 headingCount := strings.Count(markdown, "# Test Publication") 274 if headingCount != 1 { 275 t.Errorf("Expected 1 heading, found %d (content heading should be stripped)", headingCount) 276 } 277 }) 278 279 t.Run("handles content without markdown heading", func(t *testing.T) { 280 note := createMinimalNote() 281 markdown := buildPublicationMarkdown(note) 282 283 if !strings.Contains(markdown, "Simple content") { 284 t.Error("Content without heading should be included as-is") 285 } 286 }) 287 }) 288 289 t.Run("Format Content", func(t *testing.T) { 290 t.Run("formats content with glamour", func(t *testing.T) { 291 note := createMockNote() 292 content, err := formatPublicationContent(note) 293 294 if err != nil { 295 t.Fatalf("formatPublicationContent failed: %v", err) 296 } 297 298 if len(content) == 0 { 299 t.Error("Formatted content should not be empty") 300 } 301 }) 302 303 t.Run("includes note title in formatted content", func(t *testing.T) { 304 note := createMockNote() 305 content, err := formatPublicationContent(note) 306 307 if err != nil { 308 t.Fatalf("formatPublicationContent failed: %v", err) 309 } 310 311 if !strings.Contains(content, "Test") || !strings.Contains(content, "Publication") { 312 t.Error("Formatted content should include note title") 313 } 314 }) 315 }) 316 317 t.Run("Model", func(t *testing.T) { 318 note := createMockNote() 319 320 t.Run("initial model state", func(t *testing.T) { 321 model := publicationViewModel{ 322 note: note, 323 opts: PublicationViewOptions{Width: 80, Height: 24}, 324 } 325 326 if model.showingHelp { 327 t.Error("Initial showingHelp should be false") 328 } 329 if model.note != note { 330 t.Error("Note not set correctly") 331 } 332 }) 333 334 t.Run("key handling - help toggle", func(t *testing.T) { 335 vp := viewport.New(80, 20) 336 model := publicationViewModel{ 337 note: note, 338 viewport: vp, 339 keys: publicationViewKeys, 340 help: help.New(), 341 ready: true, 342 } 343 344 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) 345 if m, ok := newModel.(publicationViewModel); 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.(publicationViewModel); 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 := publicationViewModel{ 363 note: note, 364 viewport: vp, 365 keys: publicationViewKeys, 366 help: help.New(), 367 ready: true, 368 } 369 370 quitKeys := []string{"q", "esc"} 371 for _, key := range quitKeys { 372 _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 373 if cmd == nil { 374 t.Errorf("Key %s should return quit command", key) 375 } 376 } 377 }) 378 379 t.Run("viewport navigation", func(t *testing.T) { 380 vp := viewport.New(80, 20) 381 longContent := strings.Repeat("Line of content\n", 50) 382 vp.SetContent(longContent) 383 384 model := publicationViewModel{ 385 note: note, 386 viewport: vp, 387 keys: publicationViewKeys, 388 help: help.New(), 389 ready: true, 390 } 391 392 initialOffset := model.viewport.YOffset 393 394 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) 395 if m, ok := newModel.(publicationViewModel); ok { 396 if m.viewport.YOffset <= initialOffset { 397 t.Error("Down key should scroll viewport down") 398 } 399 } 400 401 model.viewport.ScrollDown(5) 402 initialOffset = model.viewport.YOffset 403 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) 404 if m, ok := newModel.(publicationViewModel); ok { 405 if m.viewport.YOffset >= initialOffset { 406 t.Error("Up key should scroll viewport up") 407 } 408 } 409 }) 410 411 t.Run("page navigation", func(t *testing.T) { 412 vp := viewport.New(80, 20) 413 longContent := strings.Repeat("Line of content\n", 100) 414 vp.SetContent(longContent) 415 416 model := publicationViewModel{ 417 note: note, 418 viewport: vp, 419 keys: publicationViewKeys, 420 help: help.New(), 421 ready: true, 422 } 423 424 initialOffset := model.viewport.YOffset 425 426 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) 427 if m, ok := newModel.(publicationViewModel); ok { 428 if m.viewport.YOffset <= initialOffset { 429 t.Error("Page down key should scroll viewport down") 430 } 431 } 432 }) 433 434 t.Run("top and bottom navigation", func(t *testing.T) { 435 vp := viewport.New(80, 20) 436 longContent := strings.Repeat("Line of content\n", 100) 437 vp.SetContent(longContent) 438 439 model := publicationViewModel{ 440 note: note, 441 viewport: vp, 442 keys: publicationViewKeys, 443 help: help.New(), 444 ready: true, 445 } 446 447 model.viewport.ScrollDown(50) 448 449 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) 450 if m, ok := newModel.(publicationViewModel); ok { 451 if m.viewport.YOffset != 0 { 452 t.Error("Top key should scroll to top") 453 } 454 } 455 456 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) 457 if m, ok := newModel.(publicationViewModel); ok { 458 if m.viewport.YOffset == 0 { 459 t.Error("Bottom key should scroll to bottom") 460 } 461 } 462 }) 463 464 t.Run("window size message handling", func(t *testing.T) { 465 vp := viewport.New(80, 20) 466 model := publicationViewModel{ 467 note: note, 468 viewport: vp, 469 keys: publicationViewKeys, 470 help: help.New(), 471 opts: PublicationViewOptions{Static: false}, 472 } 473 474 newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 475 if m, ok := newModel.(publicationViewModel); ok { 476 if m.viewport.Width != 98 { 477 t.Errorf("Viewport width should be 98 (100-2), got %d", m.viewport.Width) 478 } 479 expectedHeight := 30 - 6 480 if m.viewport.Height != expectedHeight { 481 t.Errorf("Viewport height should be %d, got %d", expectedHeight, m.viewport.Height) 482 } 483 if !m.ready { 484 t.Error("Model should be ready after window size message") 485 } 486 } 487 }) 488 489 t.Run("static mode ignores window resize", func(t *testing.T) { 490 vp := viewport.New(80, 20) 491 model := publicationViewModel{ 492 note: note, 493 viewport: vp, 494 keys: publicationViewKeys, 495 help: help.New(), 496 opts: PublicationViewOptions{Static: true}, 497 ready: true, 498 } 499 500 newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 501 if m, ok := newModel.(publicationViewModel); ok { 502 if m.viewport.Width != 80 { 503 t.Error("Static mode should not resize viewport width") 504 } 505 if m.viewport.Height != 20 { 506 t.Error("Static mode should not resize viewport height") 507 } 508 } 509 }) 510 }) 511 512 t.Run("View Model", func(t *testing.T) { 513 note := createMockNote() 514 515 t.Run("normal view with published note", func(t *testing.T) { 516 vp := viewport.New(80, 20) 517 content, _ := formatPublicationContent(note) 518 vp.SetContent(content) 519 520 model := publicationViewModel{ 521 note: note, 522 viewport: vp, 523 keys: publicationViewKeys, 524 help: help.New(), 525 ready: true, 526 } 527 528 view := model.View() 529 530 if !strings.Contains(view, "Test Publication") { 531 t.Error("Note title not displayed in view") 532 } 533 if !strings.Contains(view, "published") { 534 t.Error("Published status not displayed in view") 535 } 536 }) 537 538 t.Run("normal view with draft note", func(t *testing.T) { 539 draft := createDraftNote() 540 vp := viewport.New(80, 20) 541 content, _ := formatPublicationContent(draft) 542 vp.SetContent(content) 543 544 model := publicationViewModel{ 545 note: draft, 546 viewport: vp, 547 keys: publicationViewKeys, 548 help: help.New(), 549 ready: true, 550 } 551 552 view := model.View() 553 554 if !strings.Contains(view, "draft") { 555 t.Error("Draft status not displayed in view") 556 } 557 }) 558 559 t.Run("help view", func(t *testing.T) { 560 vp := viewport.New(80, 20) 561 model := publicationViewModel{ 562 note: note, 563 viewport: vp, 564 keys: publicationViewKeys, 565 help: help.New(), 566 showingHelp: true, 567 ready: true, 568 } 569 570 view := model.View() 571 572 if !strings.Contains(view, "scroll") { 573 t.Error("Help view should contain scroll instructions") 574 } 575 }) 576 577 t.Run("initializing view", func(t *testing.T) { 578 vp := viewport.New(80, 20) 579 model := publicationViewModel{ 580 note: note, 581 viewport: vp, 582 keys: publicationViewKeys, 583 help: help.New(), 584 ready: false, 585 } 586 587 view := model.View() 588 589 if !strings.Contains(view, "Initializing") { 590 t.Error("Not ready state should show initializing message") 591 } 592 }) 593 }) 594 595 t.Run("Key Bindings", func(t *testing.T) { 596 t.Run("short help bindings", func(t *testing.T) { 597 bindings := publicationViewKeys.ShortHelp() 598 if len(bindings) != 5 { 599 t.Errorf("Expected 5 short help bindings, got %d", len(bindings)) 600 } 601 }) 602 603 t.Run("full help bindings", func(t *testing.T) { 604 bindings := publicationViewKeys.FullHelp() 605 if len(bindings) != 3 { 606 t.Errorf("Expected 3 rows of full help bindings, got %d", len(bindings)) 607 } 608 }) 609 }) 610 611 t.Run("Integration", func(t *testing.T) { 612 t.Run("creates and displays publication view", func(t *testing.T) { 613 note := createMockNote() 614 var buf bytes.Buffer 615 616 pv := NewPublicationView(note, PublicationViewOptions{ 617 Output: &buf, 618 Static: true, 619 Width: 80, 620 Height: 24, 621 }) 622 623 if pv == nil { 624 t.Fatal("NewPublicationView returned nil") 625 } 626 627 err := pv.Show(context.Background()) 628 if err != nil { 629 t.Fatalf("Show failed: %v", err) 630 } 631 632 output := buf.String() 633 if len(output) == 0 { 634 t.Error("No output generated") 635 } 636 637 if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") { 638 t.Error("Note title not displayed") 639 } 640 if !strings.Contains(output, "This") || !strings.Contains(output, "content") { 641 t.Error("Note content not displayed") 642 } 643 }) 644 645 t.Run("creates publication view for draft", func(t *testing.T) { 646 draft := createDraftNote() 647 var buf bytes.Buffer 648 649 pv := NewPublicationView(draft, PublicationViewOptions{ 650 Output: &buf, 651 Static: true, 652 }) 653 654 err := pv.Show(context.Background()) 655 if err != nil { 656 t.Fatalf("Show failed: %v", err) 657 } 658 659 output := buf.String() 660 if !strings.Contains(output, "draft") { 661 t.Error("Draft status not displayed") 662 } 663 }) 664 }) 665}