test: add CLI help formatting tests and update coverage documentation
- Add `internal/cli/help_test.go` with comprehensive test suite
- Test `shouldStyle()` for NO_COLOR environment variable handling
- Test `renderFlag()` for short/long flag formatting
- Test `getOptionValue()` for type/default value extraction
- Test `maxFlagWidth()` for column width calculation
- Test `formatSection()` with and without color styling
- Test `BuildStyledHelp()` for full help output structure
- Test `WriteHelp()` for writing help to writer
- Update `docs/TESTING.md` coverage goals with specific percentages
- Add coverage for `cli` (95%), `config` (82%), `aggregator` (33%),
etc.
- Add note about `defaults` and `style` packages (no testable
statements)
- Include low-coverage packages (`llm`, `runtime`, `tui`, `progress`,
`tokeniser`, `cmd`)
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Thu, 23 Apr 2026 18:51:09 +0200
2 files changed,
318 insertions(+),
9 deletions(-)
M
docs/TESTING.md
→
docs/TESTING.md
@@ -301,15 +301,21 @@ Current coverage status:
| Package | Coverage | Notes | |---------|----------|-------| -| `cli` | High | All argument types tested | -| `config` | High | All config operations tested | -| `defaults` | High | All defaults tested | -| `aggregator` | Medium | Network calls require mocks | -| `output` | High | All formatters tested | -| `processor` | High | All filtering/sorting tested | -| `llm` | Low | Requires API mocking | -| `runtime` | Low | Integration tests only | -| `tui` | None | Requires terminal interaction | +| `cli` | 95% | All argument types and help formatting tested | +| `config` | 82% | Most config operations tested | +| `aggregator` | 33% | Network calls require advanced mocks | +| `output` | 93% | All formatters tested | +| `processor` | 76% | All filtering/sorting tested | +| `llm` | 0% | Requires advanced API mocking | +| `runtime` | 0% | Integration tests only | +| `tui` | 0% | Requires terminal interaction | +| `progress` | 0% | Interface only | +| `tokeniser` | 0% | Depends on library internals | +| `cmd` | 0% | Entry point | + +> [!NOTE] +> `defaults` and `style` have no testable statements; they contain only +> constant declarations or subjective parameters. ---
A
internal/cli/help_test.go
@@ -0,0 +1,303 @@
+package cli + +import ( + "os" + "strings" + "testing" + + "llm_aggregator/internal/style" +) + +func TestShouldStyle(t *testing.T) { + tests := []struct { + name string + noColorVal string + want bool + }{ + {"color enabled", "", true}, + {"color disabled via NO_COLOR", "1", false}, + {"color disabled via arbitrary value", "true", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", tt.noColorVal) + defer os.Setenv("NO_COLOR", old) + + got := shouldStyle() + if got != tt.want { + t.Errorf("shouldStyle() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRenderFlag(t *testing.T) { + tests := []struct { + name string + short string + long string + want string + }{ + { + name: "with short and long flag", + short: "-f", + long: "--feeds-file", + want: "-f, --feeds-file", + }, + { + name: "long flag only", + short: "", + long: "--version", + want: "--version", + }, + { + name: "short and long with multiple dashes", + short: "-n", + long: "--max-articles-per-feed", + want: "-n, --max-articles-per-feed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := renderFlag(tt.short, tt.long) + if got != tt.want { + t.Errorf("renderFlag(%q, %q) = %q, want %q", tt.short, tt.long, got, tt.want) + } + }) + } +} + +func TestGetOptionValue(t *testing.T) { + tests := []struct { + name string + opt HelpOption + want string + }{ + { + name: "type with default", + opt: HelpOption{Type: "int", Default: "10"}, + want: "int (default: 10)", + }, + { + name: "type without default", + opt: HelpOption{Type: "string"}, + want: "string", + }, + { + name: "default without type", + opt: HelpOption{Default: "deepseek-chat"}, + want: "deepseek-chat", + }, + { + name: "neither type nor default", + opt: HelpOption{}, + want: "", + }, + { + name: "choice type with default", + opt: HelpOption{Type: "text|markdown|json", Default: "text"}, + want: "text|markdown|json (default: text)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getOptionValue(tt.opt) + if got != tt.want { + t.Errorf("getOptionValue() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMaxFlagWidth(t *testing.T) { + old := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", old) + + sections := []HelpSection{ + { + Title: "Required", + Options: []HelpOption{ + {Short: "-f", Name: "--feeds-file"}, + {Short: "-p", Name: "--prompt"}, + }, + }, + { + Title: "Optional", + Options: []HelpOption{ + {Name: "--max-articles-per-feed"}, + {Name: "--max-total-articles"}, + }, + }, + } + + got := maxFlagWidth(sections) + // "--max-articles-per-feed" is 23 chars + if got != 23 { + t.Errorf("maxFlagWidth() = %d, want 24", got) + } +} + +func TestFormatSection(t *testing.T) { + // Test with NO_COLOR to avoid lipgloss dependency in assertions + old := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", old) + + section := HelpSection{ + Title: "Test Section", + Options: []HelpOption{ + { + Short: "-f", + Name: "--feeds-file", + Description: "Path to feeds file", + Default: "feeds.txt", + Required: true, + }, + { + Short: "-v", + Name: "--verbose", + Description: "Enable verbose output", + }, + }, + } + + output := formatSection(section, 16, 0) + + if !strings.Contains(output, "Test Section") { + t.Error("formatSection missing section title") + } + if !strings.Contains(output, "-f, --feeds-file") { + t.Error("formatSection missing short+long flag") + } + if !strings.Contains(output, "feeds.txt") { + t.Error("formatSection missing default value") + } + if !strings.Contains(output, "[required]") { + t.Error("formatSection missing required marker") + } + if !strings.Contains(output, "Path to feeds file") { + t.Error("formatSection missing description") + } + if !strings.Contains(output, "--verbose") { + t.Error("formatSection missing long-only flag") + } +} + +func TestFormatSectionWithStyling(t *testing.T) { + // Test with color enabled + old := os.Getenv("NO_COLOR") + os.Unsetenv("NO_COLOR") + defer os.Setenv("NO_COLOR", old) + + // Suppress the lipgloss warning during test + _ = style.NoColor + + section := HelpSection{ + Title: "Styled Section", + Options: []HelpOption{ + { + Short: "-o", + Name: "--output", + Description: "Output format", + Type: "text|json|markdown", + Default: "text", + }, + }, + } + + output := formatSection(section, 20, 0) + + if !strings.Contains(output, "Styled Section") { + t.Error("formatSection missing section title") + } + if !strings.Contains(output, "text|json|markdown") { + t.Error("formatSection missing type with default") + } +} + +func TestBuildStyledHelp(t *testing.T) { + // Test without color + old := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", old) + + args := &Args{FeedsFile: "/tmp/feeds.txt", Prompt: "Summarise"} + output := BuildStyledHelp(args) + + if !strings.Contains(output, "llm_aggregator") { + t.Error("BuildStyledHelp missing program name") + } + if !strings.Contains(output, "Required Arguments") { + t.Error("BuildStyledHelp missing Required Arguments section") + } + if !strings.Contains(output, "--feeds-file") { + t.Error("BuildStyledHelp missing feeds-file flag") + } + if !strings.Contains(output, "--prompt") { + t.Error("BuildStyledHelp missing prompt flag") + } + if !strings.Contains(output, "Examples:") { + t.Error("BuildStyledHelp missing Examples section") + } +} + +func TestBuildStyledHelpWithColor(t *testing.T) { + old := os.Getenv("NO_COLOR") + os.Unsetenv("NO_COLOR") + defer os.Setenv("NO_COLOR", old) + + args := &Args{FeedsFile: "/tmp/feeds.txt", Prompt: "Summarise"} + output := BuildStyledHelp(args) + + // Should contain all expected sections even with styling + if !strings.Contains(output, "Required Arguments") { + t.Error("BuildStyledHelp missing Required Arguments") + } + if !strings.Contains(output, "LLM API Options") { + t.Error("BuildStyledHelp missing LLM API Options") + } + if !strings.Contains(output, "Output Options") { + t.Error("BuildStyledHelp missing Output Options") + } +} + +func TestWriteHelp(t *testing.T) { + args := &Args{FeedsFile: "/tmp/feeds.txt", Prompt: "Summarise"} + + old := os.Getenv("NO_COLOR") + os.Setenv("NO_COLOR", "1") + defer os.Setenv("NO_COLOR", old) + + tmpFile, err := os.CreateTemp("", "help_test_*.txt") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + w, err := os.OpenFile(tmpFile.Name(), os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("Failed to open temp file for write: %v", err) + } + defer w.Close() + + WriteHelp(args, w) + + content, err := os.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to read temp file: %v", err) + } + + if !strings.Contains(string(content), "llm_aggregator") { + t.Error("WriteHelp did not write expected content") + } +} + +// Note: ParseArgs help/version branches (lines 86-95) call os.Exit +// and cannot be tested without refactoring to use an interface or +// callback for exit behavior.