cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: add read command for articles with glamour based markdown rendering

+600 -99
+18
cmd/commands.go
··· 479 479 } 480 480 root.AddCommand(viewCmd) 481 481 482 + readCmd := &cobra.Command{ 483 + Use: "read <id>", 484 + Short: "Read article content with formatted markdown", 485 + Long: `Read the full markdown content of an article with beautiful formatting. 486 + 487 + This displays the complete article content using syntax highlighting and proper formatting.`, 488 + Args: cobra.ExactArgs(1), 489 + RunE: func(cmd *cobra.Command, args []string) error { 490 + if articleID, err := parseID("article", args); err != nil { 491 + return err 492 + } else { 493 + defer c.handler.Close() 494 + return c.handler.Read(cmd.Context(), articleID) 495 + } 496 + }, 497 + } 498 + root.AddCommand(readCmd) 499 + 482 500 removeCmd := &cobra.Command{ 483 501 Use: "remove <id>", 484 502 Short: "Remove article and associated files",
+18
cmd/commands_test.go
··· 734 734 } 735 735 }) 736 736 737 + t.Run("read command with non-existent article ID", func(t *testing.T) { 738 + cmd := NewArticleCommand(handler).Create() 739 + cmd.SetArgs([]string{"read", "999"}) 740 + err := cmd.Execute() 741 + if err == nil { 742 + t.Error("expected article read command to fail with non-existent ID") 743 + } 744 + }) 745 + 746 + t.Run("read command with non-numeric ID", func(t *testing.T) { 747 + cmd := NewArticleCommand(handler).Create() 748 + cmd.SetArgs([]string{"read", "invalid"}) 749 + err := cmd.Execute() 750 + if err == nil { 751 + t.Error("expected article read command to fail with non-numeric ID") 752 + } 753 + }) 754 + 737 755 t.Run("remove command with non-existent article ID", func(t *testing.T) { 738 756 cmd := NewArticleCommand(handler).Create() 739 757 cmd.SetArgs([]string{"remove", "999"})
+30 -39
docs/testing.md
··· 1 1 # Testing Documentation 2 2 3 - This document outlines the testing patterns and practices used in the noteleaf application. 3 + This document outlines the testing patterns and practices used in the `noteleaf` application. 4 4 5 - ## Testing Principles 5 + ## Overview 6 6 7 - The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library `testing` package and avoid mock frameworks or assertion libraries. This keeps dependencies minimal and tests readable using standard Go patterns. 7 + The codebase follows Go's standard testing practices without external libraries. Tests use only the standard library package and avoid mock frameworks or assertion libraries. This is to keep dependencies minimal and tests readable using standard Go patterns. 8 8 9 - ## Test File Organization 9 + ### Organization 10 10 11 - Test files follow the standard Go convention of `*_test.go` naming. Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. 11 + Each package contains its own test files alongside the source code. Test files are organized by functionality and mirror the structure of the source code they test. 12 12 13 - ## Testing Patterns 13 + ## Patterns 14 14 15 15 ### Handler Creation Pattern 16 16 ··· 30 30 31 31 Tests use `t.Fatal` for setup errors that prevent test execution and `t.Error` for test assertion failures. Fatal errors stop test execution while errors allow tests to continue checking other conditions. 32 32 33 + ### Context Cancellation Testing Pattern 34 + 35 + Error case testing frequently uses context cancellation to simulate database and network failures. The pattern creates a context, immediately cancels it, then calls the function under test to verify error handling. This provides a reliable way to test error paths without requiring complex mock setups or external failure injection. 36 + 33 37 ### Command Structure Testing 34 38 35 39 Command group tests verify cobra command structure including use strings, aliases, short descriptions, and subcommand presence. Tests check that commands are properly configured without executing their logic. ··· 38 42 39 43 Tests verify interface compliance using compile-time checks with blank identifier assignments. This ensures structs implement expected interfaces without runtime overhead. 40 44 41 - ```go 42 - var _ CommandGroup = NewTaskCommands(handler) 43 - ``` 44 - 45 45 ## Test Organization Patterns 46 46 47 - ### Single Root Test Pattern 47 + ### Single Root Test 48 48 49 - The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. 49 + The preferred test organization pattern uses a single root test function with nested subtests using `t.Run`. This provides clear hierarchical organization and allows running specific test sections while maintaining shared setup and context. This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections, consistent test structure across the codebase, and shared setup that can be inherited by subtests. 50 50 51 - ```go 52 - func TestCommandGroup(t *testing.T) { 53 - t.Run("Interface Implementations", func(t *testing.T) { 54 - // Test interface compliance 55 - }) 51 + ### Integration vs Unit Testing 56 52 57 - t.Run("Create", func(t *testing.T) { 58 - t.Run("TaskCommand", func(t *testing.T) { 59 - // Test task command creation 60 - }) 61 - t.Run("MovieCommand", func(t *testing.T) { 62 - // Test movie command creation 63 - }) 64 - }) 65 - } 66 - ``` 53 + The codebase emphasizes integration testing over heavy mocking by using real handlers and services to verify actual behavior rather than mocked interactions. The goal is to catch integration issues while maintaining test reliability. 67 54 68 - This pattern offers several advantages: clear test hierarchy with logical grouping, ability to run specific test sections with `go test -run TestCommandGroup/Create/TaskCommand`, consistent test structure across the codebase, and shared setup that can be inherited by subtests. 55 + ### Static Output 69 56 70 - ### Integration vs Unit Testing 57 + UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. 71 58 72 - The codebase emphasizes integration testing over heavy mocking. Tests use real handlers and services to verify actual behavior rather than mocked interactions. This approach catches integration issues while maintaining test reliability. 73 - 74 - ### Static Output Testing 59 + ### Standard Output Redirection 75 60 76 - UI components support static output modes for testing. Tests capture output using bytes.Buffer and verify content using string contains checks rather than exact string matching for better test maintainability. 61 + For testing functions that write to stdout, tests use a pipe redirection pattern with goroutines to capture output. The pattern saves the original stdout, redirects to a pipe, captures output in a separate goroutine, and restores stdout after the test. This ensures clean output capture without interfering with the testing framework. 77 62 78 - ## Test Utilities 63 + ## Utilities 79 64 80 - ### Helper Functions 65 + ### Helpers 81 66 82 67 Test files include helper functions for creating test data and finding elements in collections. These utilities reduce code duplication and improve test readability. 83 68 84 - ### Mock Data Creation 69 + ### Mock Data 85 70 86 - Tests create realistic mock data using factory functions that return properly initialized structs with sensible defaults. This approach provides consistent test data across different test cases. 71 + Tests create realistic mock data using factory functions (powered by faker) that return properly initialized structs with sensible defaults. 87 72 88 73 ## Testing CLI Commands 89 74 ··· 101 86 102 87 The single root test pattern allows for efficient resource management where setup costs can be amortized across multiple related test cases. 103 88 104 - ## Best Practices Summary 89 + ## Errors 90 + 91 + Error coverage follows a systematic approach to identify and test failure scenarios: 105 92 106 - Use factory functions for test handler creation with proper cleanup patterns. Organize tests using single root test functions with nested subtests for clear hierarchy. Manage resources with cleanup functions returned by factory methods. Prefer integration testing over mocking for real-world behavior validation. Verify interface compliance at compile time within dedicated subtests. Focus command tests on structure verification rather than execution testing. Leverage the single root test pattern for logical grouping and selective test execution. Use realistic test data with factory functions for consistent test scenarios. 93 + 1. **Context Cancellation** - Primary method for testing database and network timeout scenarios 94 + 2. **Invalid Input** - Malformed data, empty inputs, boundary conditions 95 + 3. **Resource Exhaustion** - Database connection failures, memory limits 96 + 4. **Constraint Violations** - Duplicate keys, foreign key failures 97 + 5. **State Validation** - Testing functions with invalid system states
+25
internal/handlers/articles.go
··· 267 267 return nil 268 268 } 269 269 270 + // Read displays an article's content with formatted markdown rendering 271 + func (h *ArticleHandler) Read(ctx context.Context, id int64) error { 272 + article, err := h.repos.Articles.Get(ctx, id) 273 + if err != nil { 274 + return fmt.Errorf("failed to get article: %w", err) 275 + } 276 + 277 + if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) { 278 + return fmt.Errorf("markdown file not found: %s", article.MarkdownPath) 279 + } 280 + 281 + content, err := os.ReadFile(article.MarkdownPath) 282 + if err != nil { 283 + return fmt.Errorf("failed to read markdown file: %w", err) 284 + } 285 + 286 + if rendered, err := renderMarkdown(string(content)); err != nil { 287 + return err 288 + } else { 289 + fmt.Print(rendered) 290 + return nil 291 + } 292 + 293 + } 294 + 270 295 // TODO: Try to get from config first (could be added later) 271 296 // For now, use default ~/Documents/Leaf/ 272 297 func (h *ArticleHandler) getStorageDirectory() (string, error) {
+49 -9
internal/handlers/articles_test.go
··· 329 329 }) 330 330 }) 331 331 332 + t.Run("Read", func(t *testing.T) { 333 + t.Run("read renders article successfully", func(t *testing.T) { 334 + helper := NewArticleTestHelper(t) 335 + ctx := context.Background() 336 + id := helper.CreateTestArticle(t, "https://example.com/read", "Read Test Article", "Test Author", "2024-01-01") 337 + err := helper.Read(ctx, id) 338 + Expect.AssertNoError(t, err, "Read should succeed with valid article ID") 339 + }) 340 + 341 + t.Run("handles non-existent article", func(t *testing.T) { 342 + helper := NewArticleTestHelper(t) 343 + ctx := context.Background() 344 + err := helper.Read(ctx, 99999) 345 + Expect.AssertError(t, err, "failed to get article", "Read should fail with non-existent article ID") 346 + }) 347 + 348 + t.Run("handles missing markdown file", func(t *testing.T) { 349 + helper := NewArticleTestHelper(t) 350 + ctx := context.Background() 351 + 352 + article := &models.Article{ 353 + URL: "https://example.com/missing-md", 354 + Title: "Missing Markdown Article", 355 + Author: "Test Author", 356 + Date: "2024-01-01", 357 + MarkdownPath: "/non/existent/path.md", 358 + HTMLPath: "/some/existent/path.html", 359 + Created: time.Now(), 360 + Modified: time.Now(), 361 + } 362 + 363 + id, err := helper.repos.Articles.Create(ctx, article) 364 + if err != nil { 365 + t.Fatalf("Failed to create article with missing markdown file: %v", err) 366 + } 367 + 368 + err = helper.Read(ctx, id) 369 + Expect.AssertError(t, err, "markdown file not found", "Read should fail when markdown file is missing") 370 + }) 371 + 372 + t.Run("handles database error", func(t *testing.T) { 373 + helper := NewArticleTestHelper(t) 374 + ctx := context.Background() 375 + helper.db.Exec("DROP TABLE articles") 376 + err := helper.Read(ctx, 1) 377 + Expect.AssertError(t, err, "failed to get article", "Read should fail when database is corrupted") 378 + }) 379 + }) 380 + 332 381 t.Run("Remove", func(t *testing.T) { 333 382 t.Run("removes article successfully", func(t *testing.T) { 334 383 helper := NewArticleTestHelper(t) 335 384 ctx := context.Background() 336 - 337 385 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 338 - 339 386 Expect.AssertArticleExists(t, helper, id) 340 387 341 388 err := helper.Remove(ctx, id) 342 389 Expect.AssertNoError(t, err, "Remove should succeed") 343 - 344 390 Expect.AssertArticleNotExists(t, helper, id) 345 391 }) 346 392 347 393 t.Run("handles non-existent article", func(t *testing.T) { 348 394 helper := NewArticleTestHelper(t) 349 395 ctx := context.Background() 350 - 351 396 err := helper.Remove(ctx, 99999) 352 397 Expect.AssertError(t, err, "failed to get article", "Remove should fail with non-existent article ID") 353 398 }) ··· 379 424 t.Run("handles database error", func(t *testing.T) { 380 425 helper := NewArticleTestHelper(t) 381 426 ctx := context.Background() 382 - 383 427 id := helper.CreateTestArticle(t, "https://example.com/db-error", "DB Error Test", "Author", "2024-01-01") 384 428 385 429 helper.db.Exec("DROP TABLE articles") ··· 392 436 t.Run("Help", func(t *testing.T) { 393 437 t.Run("shows supported domains", func(t *testing.T) { 394 438 helper := NewArticleTestHelper(t) 395 - 396 439 err := helper.Help() 397 440 Expect.AssertNoError(t, err, "Help should succeed") 398 441 }) ··· 419 462 t.Run("Close", func(t *testing.T) { 420 463 t.Run("closes successfully", func(t *testing.T) { 421 464 helper := NewArticleTestHelper(t) 422 - 423 465 err := helper.Close() 424 466 Expect.AssertNoError(t, err, "Close should succeed") 425 467 }) ··· 427 469 t.Run("handles nil database gracefully", func(t *testing.T) { 428 470 helper := NewArticleTestHelper(t) 429 471 helper.db = nil 430 - 431 472 err := helper.Close() 432 473 Expect.AssertNoError(t, err, "Close should succeed with nil database") 433 474 }) ··· 436 477 t.Run("getStorageDirectory", func(t *testing.T) { 437 478 t.Run("returns storage directory successfully", func(t *testing.T) { 438 479 helper := NewArticleTestHelper(t) 439 - 440 480 dir, err := helper.getStorageDirectory() 441 481 Expect.AssertNoError(t, err, "getStorageDirectory should succeed") 442 482
+14
internal/handlers/books_test.go
··· 485 485 t.Errorf("Close failed: %v", err) 486 486 } 487 487 }) 488 + 489 + t.Run("handles service close gracefully", func(t *testing.T) { 490 + _, cleanup := setupBookTest(t) 491 + defer cleanup() 492 + 493 + handler, err := NewBookHandler() 494 + if err != nil { 495 + t.Fatalf("NewBookHandler failed: %v", err) 496 + } 497 + 498 + if err = handler.Close(); err != nil { 499 + t.Errorf("Close should succeed: %v", err) 500 + } 501 + }) 488 502 }) 489 503 490 504 t.Run("Print", func(t *testing.T) {
+33 -43
internal/handlers/notes.go
··· 8 8 "path/filepath" 9 9 "strings" 10 10 11 - "github.com/charmbracelet/glamour" 12 11 "github.com/stormlightlabs/noteleaf/internal/models" 13 12 "github.com/stormlightlabs/noteleaf/internal/repo" 14 13 "github.com/stormlightlabs/noteleaf/internal/store" ··· 275 274 return "" 276 275 } 277 276 278 - func defaultOpenInEditor(editor, filePath string) error { 279 - cmd := exec.Command(editor, filePath) 280 - cmd.Stdin = os.Stdin 281 - cmd.Stdout = os.Stdout 282 - cmd.Stderr = os.Stderr 283 - return cmd.Run() 284 - } 285 - 286 277 func (h *NoteHandler) openInEditor(editor, filePath string) error { 287 278 if h.openInEditorFunc != nil { 288 279 return h.openInEditorFunc(editor, filePath) 289 280 } 290 - return defaultOpenInEditor(editor, filePath) 281 + return openInDefaultEditor(editor, filePath) 291 282 } 292 283 293 284 func (h *NoteHandler) parseNoteContent(content string) (title, noteContent string, tags []string) { ··· 324 315 return title, noteContent, tags 325 316 } 326 317 327 - func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 328 - var content strings.Builder 329 - 330 - if !strings.Contains(note.Content, "# "+note.Title) { 331 - content.WriteString("# " + note.Title + "\n\n") 332 - } 333 - 334 - content.WriteString(note.Content) 335 - 336 - if len(note.Tags) > 0 { 337 - if !strings.HasSuffix(note.Content, "\n") { 338 - content.WriteString("\n") 339 - } 340 - content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 341 - } 342 - 343 - return content.String() 344 - } 345 - 346 318 // View displays a note with formatted markdown content 347 319 func (h *NoteHandler) View(ctx context.Context, id int64) error { 348 320 note, err := h.repos.Notes.Get(ctx, id) ··· 350 322 return fmt.Errorf("failed to get note: %w", err) 351 323 } 352 324 353 - renderer, err := glamour.NewTermRenderer( 354 - glamour.WithAutoStyle(), 355 - glamour.WithWordWrap(80), 356 - ) 357 - if err != nil { 358 - return fmt.Errorf("failed to create markdown renderer: %w", err) 359 - } 360 - 361 325 content := h.formatNoteForView(note) 362 - rendered, err := renderer.Render(content) 363 - if err != nil { 364 - return fmt.Errorf("failed to render markdown: %w", err) 326 + if rendered, err := renderMarkdown(content); err != nil { 327 + return err 328 + } else { 329 + fmt.Print(rendered) 330 + return nil 365 331 } 366 - 367 - fmt.Print(rendered) 368 - return nil 369 332 } 370 333 371 334 // List opens either an interactive TUI browser for navigating and viewing notes or a static list ··· 430 393 431 394 return content.String() 432 395 } 396 + 397 + func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 398 + var content strings.Builder 399 + 400 + if !strings.Contains(note.Content, "# "+note.Title) { 401 + content.WriteString("# " + note.Title + "\n\n") 402 + } 403 + 404 + content.WriteString(note.Content) 405 + 406 + if len(note.Tags) > 0 { 407 + if !strings.HasSuffix(note.Content, "\n") { 408 + content.WriteString("\n") 409 + } 410 + content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 411 + } 412 + 413 + return content.String() 414 + } 415 + 416 + func openInDefaultEditor(editor, filePath string) error { 417 + cmd := exec.Command(editor, filePath) 418 + cmd.Stdin = os.Stdin 419 + cmd.Stdout = os.Stdout 420 + cmd.Stderr = os.Stderr 421 + return cmd.Run() 422 + }
+200 -8
internal/handlers/notes_test.go
··· 8 8 "runtime" 9 9 "strings" 10 10 "testing" 11 + "time" 11 12 12 13 "github.com/stormlightlabs/noteleaf/internal/models" 13 14 "github.com/stormlightlabs/noteleaf/internal/store" ··· 717 718 }) 718 719 719 720 t.Run("Close", func(t *testing.T) { 720 - testHandler, err := NewNoteHandler() 721 - if err != nil { 722 - t.Fatalf("Failed to create test handler: %v", err) 723 - } 721 + t.Run("closes handler resources successfully", func(t *testing.T) { 722 + testHandler, err := NewNoteHandler() 723 + if err != nil { 724 + t.Fatalf("Failed to create test handler: %v", err) 725 + } 726 + 727 + if err = testHandler.Close(); err != nil { 728 + t.Errorf("Close should succeed: %v", err) 729 + } 730 + }) 731 + 732 + t.Run("handles nil database", func(t *testing.T) { 733 + testHandler, err := NewNoteHandler() 734 + if err != nil { 735 + t.Fatalf("Failed to create test handler: %v", err) 736 + } 737 + testHandler.db = nil 724 738 725 - err = testHandler.Close() 726 - if err != nil { 727 - t.Errorf("Close should succeed: %v", err) 728 - } 739 + if err = testHandler.Close(); err != nil { 740 + t.Errorf("Close should succeed with nil database: %v", err) 741 + } 742 + }) 729 743 }) 730 744 731 745 t.Run("Helper Methods", func(t *testing.T) { ··· 868 882 869 883 err := handler.createInteractive(ctx) 870 884 Expect.AssertError(t, err, "failed to read edited content", "createInteractive should fail when temp file is deleted") 885 + }) 886 + }) 887 + 888 + t.Run("CreateWithOptions", func(t *testing.T) { 889 + ctx := context.Background() 890 + 891 + t.Run("creates note successfully without editor prompt", func(t *testing.T) { 892 + handler := NewHandlerTestHelper(t) 893 + err := handler.CreateWithOptions(ctx, "Test Note", "Test content", "", false, false) 894 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed") 895 + }) 896 + 897 + t.Run("creates note successfully with editor prompt disabled", func(t *testing.T) { 898 + handler := NewHandlerTestHelper(t) 899 + err := handler.CreateWithOptions(ctx, "Another Test Note", "More content", "", false, false) 900 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed") 901 + }) 902 + 903 + t.Run("handles database error during creation", func(t *testing.T) { 904 + handler := NewHandlerTestHelper(t) 905 + cancelCtx, cancel := context.WithCancel(ctx) 906 + cancel() 907 + 908 + err := handler.CreateWithOptions(cancelCtx, "Test Note", "Test content", "", false, false) 909 + Expect.AssertError(t, err, "failed to create note", "CreateWithOptions should fail with cancelled context") 910 + }) 911 + 912 + t.Run("creates note with empty content", func(t *testing.T) { 913 + handler := NewHandlerTestHelper(t) 914 + err := handler.CreateWithOptions(ctx, "Empty Content Note", "", "", false, false) 915 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed with empty content") 916 + }) 917 + 918 + t.Run("creates note with empty title", func(t *testing.T) { 919 + handler := NewHandlerTestHelper(t) 920 + err := handler.CreateWithOptions(ctx, "", "Content without title", "", false, false) 921 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed with empty title") 922 + }) 923 + 924 + t.Run("handles editor prompt with no editor available", func(t *testing.T) { 925 + handler := NewHandlerTestHelper(t) 926 + envHelper := NewEnvironmentTestHelper() 927 + defer envHelper.RestoreEnv() 928 + 929 + envHelper.UnsetEnv("EDITOR") 930 + envHelper.SetEnv("PATH", "") 931 + 932 + err := handler.CreateWithOptions(ctx, "Test Note", "Test content", "", false, true) 933 + Expect.AssertNoError(t, err, "CreateWithOptions should succeed even when no editor is available") 934 + }) 935 + }) 936 + 937 + t.Run("formatNoteForView", func(t *testing.T) { 938 + t.Run("formats note with title and content", func(t *testing.T) { 939 + note := &models.Note{ 940 + Title: "Test Note", 941 + Content: "This is test content", 942 + Tags: []string{}, 943 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 944 + Modified: time.Date(2023, 1, 2, 11, 0, 0, 0, time.UTC), 945 + } 946 + 947 + result := handler.formatNoteForView(note) 948 + 949 + if !strings.Contains(result, "# Test Note") { 950 + t.Error("Formatted note should contain title") 951 + } 952 + if !strings.Contains(result, "This is test content") { 953 + t.Error("Formatted note should contain content") 954 + } 955 + if !strings.Contains(result, "**Created:**") { 956 + t.Error("Formatted note should contain created timestamp") 957 + } 958 + if !strings.Contains(result, "**Modified:**") { 959 + t.Error("Formatted note should contain modified timestamp") 960 + } 961 + }) 962 + 963 + t.Run("formats note with tags", func(t *testing.T) { 964 + note := &models.Note{ 965 + Title: "Tagged Note", 966 + Content: "Content with tags", 967 + Tags: []string{"work", "important", "personal"}, 968 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 969 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 970 + } 971 + 972 + result := handler.formatNoteForView(note) 973 + 974 + if !strings.Contains(result, "**Tags:**") { 975 + t.Error("Formatted note should contain tags section") 976 + } 977 + if !strings.Contains(result, "`work`") { 978 + t.Error("Formatted note should contain work tag") 979 + } 980 + if !strings.Contains(result, "`important`") { 981 + t.Error("Formatted note should contain important tag") 982 + } 983 + if !strings.Contains(result, "`personal`") { 984 + t.Error("Formatted note should contain personal tag") 985 + } 986 + }) 987 + 988 + t.Run("formats note with no tags", func(t *testing.T) { 989 + note := &models.Note{ 990 + Title: "Untagged Note", 991 + Content: "Content without tags", 992 + Tags: []string{}, 993 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 994 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 995 + } 996 + 997 + result := handler.formatNoteForView(note) 998 + 999 + if strings.Contains(result, "**Tags:**") { 1000 + t.Error("Formatted note should not contain tags section when no tags exist") 1001 + } 1002 + }) 1003 + 1004 + t.Run("handles content with existing title", func(t *testing.T) { 1005 + note := &models.Note{ 1006 + Title: "Note Title", 1007 + Content: "# Duplicate Title\nContent after title", 1008 + Tags: []string{}, 1009 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1010 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1011 + } 1012 + 1013 + result := handler.formatNoteForView(note) 1014 + 1015 + if !strings.Contains(result, "Content after title") { 1016 + t.Error("Formatted note should contain content after duplicate title removal") 1017 + } 1018 + contentLines := strings.Split(result, "\n") 1019 + duplicateTitleCount := 0 1020 + for _, line := range contentLines { 1021 + if strings.Contains(line, "# Duplicate Title") { 1022 + duplicateTitleCount++ 1023 + } 1024 + } 1025 + if duplicateTitleCount > 0 { 1026 + t.Error("Formatted note should not contain duplicate title from content") 1027 + } 1028 + }) 1029 + 1030 + t.Run("handles empty content", func(t *testing.T) { 1031 + note := &models.Note{ 1032 + Title: "Empty Content Note", 1033 + Content: "", 1034 + Tags: []string{}, 1035 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1036 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1037 + } 1038 + 1039 + result := handler.formatNoteForView(note) 1040 + 1041 + if !strings.Contains(result, "# Empty Content Note") { 1042 + t.Error("Formatted note should contain title even with empty content") 1043 + } 1044 + if !strings.Contains(result, "---") { 1045 + t.Error("Formatted note should contain separator") 1046 + } 1047 + }) 1048 + 1049 + t.Run("handles content with only title line", func(t *testing.T) { 1050 + note := &models.Note{ 1051 + Title: "Single Line", 1052 + Content: "# Single Line", 1053 + Tags: []string{}, 1054 + Created: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1055 + Modified: time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC), 1056 + } 1057 + 1058 + result := handler.formatNoteForView(note) 1059 + 1060 + if !strings.Contains(result, "# Single Line") { 1061 + t.Error("Formatted note should contain title") 1062 + } 871 1063 }) 872 1064 }) 873 1065 }
+21
internal/handlers/shared.go
··· 1 + package handlers 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/charmbracelet/glamour" 7 + ) 8 + 9 + func renderMarkdown(content string) (string, error) { 10 + renderer, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(80)) 11 + if err != nil { 12 + return "", fmt.Errorf("failed to create markdown renderer: %w", err) 13 + } 14 + 15 + rendered, err := renderer.Render(content) 16 + if err != nil { 17 + return "", fmt.Errorf("failed to render markdown: %w", err) 18 + } 19 + 20 + return rendered, nil 21 + }
+82
internal/handlers/shared_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + type renderMarkdownTC struct { 9 + name string 10 + content string 11 + err bool 12 + contains []string 13 + } 14 + 15 + func TestRenderMarkdown(t *testing.T) { 16 + tt := []renderMarkdownTC{ 17 + {name: "simple text", content: "Hello, world!", err: false, contains: []string{"Hello, world!"}}, 18 + {name: "markdown heading", content: "# Main Title", err: false, contains: []string{"Main Title"}}, 19 + {name: "markdown with emphasis", content: "This is **bold** and *italic* text", err: false, contains: []string{"bold", "italic"}}, 20 + {name: "markdown list", content: "- Item 1\n- Item 2\n- Item 3", err: false, contains: []string{"Item 1", "Item 2", "Item 3"}}, 21 + {name: "code block", content: "```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```", err: false, contains: []string{"main", "fmt.Println"}}, 22 + {name: "empty string", content: "", err: false, contains: []string{}}, 23 + {name: "only whitespace", content: " \n\t \n ", err: false, contains: []string{}}, 24 + {name: "mixed content", content: "# Title\n\nSome **bold** text and a [link](https://example.com)\n\n- List item", err: false, 25 + contains: []string{"Title", "bold", "example.com", "List item"}}} 26 + 27 + for _, tt := range tt { 28 + t.Run(tt.name, func(t *testing.T) { 29 + result, err := renderMarkdown(tt.content) 30 + if tt.err && err == nil { 31 + t.Fatalf("expected error, got nil") 32 + } 33 + 34 + if err != nil { 35 + t.Fatalf("unexpected error: %v", err) 36 + } 37 + 38 + for _, want := range tt.contains { 39 + if strings.Contains(result, want) { 40 + continue 41 + } 42 + t.Fatalf("result should contain %q, got:\n%s", want, result) 43 + } 44 + }) 45 + } 46 + 47 + t.Run("WordWrap", func(t *testing.T) { 48 + text := strings.Repeat("This is a very long line that should be wrapped at 80 characters. ", 5) 49 + result, err := renderMarkdown(text) 50 + if err != nil { 51 + t.Fatalf("unexpected error: %v", err) 52 + } 53 + 54 + lines := strings.Split(result, "\n") 55 + for i, line := range lines { 56 + cleaned := removeANSI(line) 57 + if len(cleaned) > 85 { 58 + t.Fatalf("Line at index %d is too long (%d chars): %q", i, len(cleaned), cleaned) 59 + } 60 + } 61 + }) 62 + } 63 + 64 + func removeANSI(s string) string { 65 + result := "" 66 + inEscapeCh := false 67 + for i := 0; i < len(s); i++ { 68 + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { 69 + inEscapeCh = true 70 + i++ 71 + continue 72 + } 73 + if inEscapeCh { 74 + if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') { 75 + inEscapeCh = false 76 + } 77 + continue 78 + } 79 + result += string(s[i]) 80 + } 81 + return result 82 + }
+110
internal/handlers/tasks_test.go
··· 1141 1141 }) 1142 1142 }) 1143 1143 }) 1144 + 1145 + t.Run("ListContexts", func(t *testing.T) { 1146 + _, cleanup := setupTaskTest(t) 1147 + defer cleanup() 1148 + 1149 + ctx := context.Background() 1150 + 1151 + handler, err := NewTaskHandler() 1152 + if err != nil { 1153 + t.Fatalf("Failed to create handler: %v", err) 1154 + } 1155 + defer handler.Close() 1156 + 1157 + tasks := []*models.Task{ 1158 + { 1159 + UUID: uuid.New().String(), 1160 + Description: "Task with context 1", 1161 + Status: "pending", 1162 + Context: "test-context", 1163 + }, 1164 + { 1165 + UUID: uuid.New().String(), 1166 + Description: "Task with context 2", 1167 + Status: "pending", 1168 + Context: "work-context", 1169 + }, 1170 + { 1171 + UUID: uuid.New().String(), 1172 + Description: "Task without context", 1173 + Status: "pending", 1174 + }, 1175 + } 1176 + 1177 + for _, task := range tasks { 1178 + _, err := handler.repos.Tasks.Create(ctx, task) 1179 + if err != nil { 1180 + t.Fatalf("Failed to create task: %v", err) 1181 + } 1182 + } 1183 + 1184 + t.Run("lists contexts in static mode", func(t *testing.T) { 1185 + err := handler.ListContexts(ctx, true) 1186 + if err != nil { 1187 + t.Errorf("ListContexts static mode failed: %v", err) 1188 + } 1189 + }) 1190 + 1191 + t.Run("lists contexts in interactive mode (falls back to static)", func(t *testing.T) { 1192 + err := handler.ListContexts(ctx, false) 1193 + if err != nil { 1194 + t.Errorf("ListContexts interactive mode failed: %v", err) 1195 + } 1196 + }) 1197 + 1198 + t.Run("lists contexts with todoTxt flag true", func(t *testing.T) { 1199 + err := handler.ListContexts(ctx, true, true) 1200 + if err != nil { 1201 + t.Errorf("ListContexts with todoTxt=true failed: %v", err) 1202 + } 1203 + }) 1204 + 1205 + t.Run("lists contexts with todoTxt flag false", func(t *testing.T) { 1206 + err := handler.ListContexts(ctx, true, false) 1207 + if err != nil { 1208 + t.Errorf("ListContexts with todoTxt=false failed: %v", err) 1209 + } 1210 + }) 1211 + 1212 + t.Run("handles database error in static mode", func(t *testing.T) { 1213 + cancelCtx, cancel := context.WithCancel(ctx) 1214 + cancel() 1215 + 1216 + err := handler.ListContexts(cancelCtx, true) 1217 + if err == nil { 1218 + t.Error("Expected error with cancelled context in static mode") 1219 + } 1220 + if !strings.Contains(err.Error(), "failed to list tasks for contexts") { 1221 + t.Errorf("Expected specific error message, got: %v", err) 1222 + } 1223 + }) 1224 + 1225 + t.Run("handles database error in interactive mode", func(t *testing.T) { 1226 + cancelCtx, cancel := context.WithCancel(ctx) 1227 + cancel() 1228 + 1229 + err := handler.ListContexts(cancelCtx, false) 1230 + if err == nil { 1231 + t.Error("Expected error with cancelled context in interactive mode") 1232 + } 1233 + if !strings.Contains(err.Error(), "failed to list tasks for contexts") { 1234 + t.Errorf("Expected specific error message, got: %v", err) 1235 + } 1236 + }) 1237 + 1238 + t.Run("returns no contexts when none exist", func(t *testing.T) { 1239 + _, cleanup_ := setupTaskTest(t) 1240 + defer cleanup_() 1241 + 1242 + handler_, err := NewTaskHandler() 1243 + if err != nil { 1244 + t.Fatalf("Failed to create handler: %v", err) 1245 + } 1246 + defer handler_.Close() 1247 + 1248 + err = handler_.ListContexts(ctx, true) 1249 + if err != nil { 1250 + t.Errorf("ListContexts with no contexts failed: %v", err) 1251 + } 1252 + }) 1253 + }) 1144 1254 }