From fdb73928f9ed3e2e0798dbb659f778376dc840a3 Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Sun, 5 Jul 2026 15:29:43 +0100 Subject: [PATCH 1/5] test: address peer-review findings across all modules - cmd/jive-encoder: add tests for Hugo PostEncode and CollectMetadata; propagate frontmatter and missing audio errors to exit code; document date handling; standardise helpers - internal/encoder: standardise Close() error handling; extract setDictOptions helper; add format assertions; relocate test fixtures to temp directory - internal/artwork: replace draw.Over with draw.Src; add GIF test; tighten output bounds; document design choice - internal/cli: deduplicate colour literals; add warning colour; add help tests for positional args; capture write errors in help printer - internal/ui: fix double frame-tick loop; add table tests for speed and format helpers; align elapsed time to live base - .github/workflows/builder.yml: pin govulncheck to v1.0.4; enable CGO_ENABLED=1 in build, test, and coverage jobs Signed-off-by: Martin Wimpress --- .github/workflows/builder.yml | 8 +- AGENTS.md | 5 + cmd/jive-encoder/hugo.go | 29 ++-- cmd/jive-encoder/hugo_test.go | 277 +++++++++++++++++++++++++++++++ cmd/jive-encoder/main.go | 14 +- cmd/jive-encoder/standalone.go | 7 +- cmd/jive-encoder/workflow.go | 2 +- internal/artwork/artwork.go | 6 +- internal/artwork/artwork_test.go | 46 ++++- internal/cli/colours.go | 5 +- internal/cli/help.go | 4 +- internal/cli/help_golden_test.go | 102 ++++++++++++ internal/cli/styles.go | 2 +- internal/encoder/encoder.go | 5 + internal/encoder/encoder_test.go | 93 +++++++++-- internal/encoder/output.go | 48 +++--- internal/ui/encode.go | 14 ++ internal/ui/encode_test.go | 164 ++++++++++++++++++ internal/ui/views.go | 6 +- 19 files changed, 776 insertions(+), 61 deletions(-) create mode 100644 internal/cli/help_golden_test.go diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5149936..5a6525e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -58,6 +58,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: @@ -97,6 +99,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: @@ -139,7 +143,7 @@ jobs: comment-summary-in-pr: on-failure - name: Run govulncheck - uses: golang/govulncheck-action@master + uses: golang/govulncheck-action@v1.0.4 with: repo-checkout: false go-version-file: go.mod @@ -193,6 +197,8 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + env: + CGO_ENABLED: 1 steps: - uses: actions/checkout@v7 with: diff --git a/AGENTS.md b/AGENTS.md index 10979c3..92fc0a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,11 @@ third_party/ffmpeg-statigo/ # Git submodule: FFmpeg 8.1 static bindings - **Shell:** Fish (interactive), bash (scripts) - **CGO required:** Set `CGO_ENABLED=1` for builds +## CI and Dependencies + +- **Release checksums:** GitHub attaches SHA256 sums to all release artifacts automatically. Do not hand-roll a checksum step. +- **Dependabot Nix:** Dependabot supports the `nix` ecosystem and updates `flake.lock`. The entry in `.github/dependabot.yml` is expected. + ## FFmpeg-Statigo Submodule This project uses [`ffmpeg-statigo`](https://github.com/linuxmatters/ffmpeg-statigo) for FFmpeg 8.1 static bindings: diff --git a/cmd/jive-encoder/hugo.go b/cmd/jive-encoder/hugo.go index 3dc8249..2f268b3 100644 --- a/cmd/jive-encoder/hugo.go +++ b/cmd/jive-encoder/hugo.go @@ -62,6 +62,9 @@ func (h *HugoWorkflow) CollectMetadata() (encoder.Metadata, string, error) { episodeTitle := metadata.Title artist := HugoDefaultArtist comment := HugoDefaultComment + // Frontmatter supplies a parsed time.Time, so format it to the muxer's + // YYYY-MM tag string. Standalone mode instead passes --date through raw + // (see standalone.go), because there the user types the exact tag value. date := encoder.FormatDateForID3(metadata.Date) if h.opts.Artist != "" { @@ -129,29 +132,33 @@ func (h *HugoWorkflow) PostEncode(stats *encoder.FileStats) error { // A mismatch and a missing value both need confirmation, but each gets its // own prompt wording so the user knows which case they are approving. if needsUpdate { - promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nUpdate frontmatter with new values? [y/N]: ", stats.DurationString, stats.FileSizeBytes) + return promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nUpdate frontmatter with new values? [y/N]: ", stats.DurationString, stats.FileSizeBytes) } else if h.hugoMetadata.PodcastDuration == "" || h.hugoMetadata.PodcastBytes == 0 { - promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nAdd podcast_duration and podcast_bytes to frontmatter? [y/N]: ", stats.DurationString, stats.FileSizeBytes) + return promptAndUpdateFrontmatter(h.opts.EpisodeMD, "\nAdd podcast_duration and podcast_bytes to frontmatter? [y/N]: ", stats.DurationString, stats.FileSizeBytes) } return nil } -// promptAndUpdateFrontmatter prompts the user and updates the frontmatter with podcast stats -func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes int64) { +// promptAndUpdateFrontmatter prompts the user and updates the frontmatter with +// podcast stats. A write failure is returned so the caller can exit non-zero; +// declining the prompt is not an error. +func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes int64) error { fmt.Print(promptMsg) var response string _, _ = fmt.Scanln(&response) - if strings.ToLower(strings.TrimSpace(response)) == "y" { - if err := encoder.UpdateFrontmatter(markdownPath, duration, bytes); err != nil { - cli.PrintError(fmt.Sprintf("Failed to update frontmatter: %v", err)) - } else { - cli.PrintSuccess("Frontmatter updated successfully") - } - } else { + if strings.ToLower(strings.TrimSpace(response)) != "y" { cli.PrintInfo("Frontmatter not updated") + return nil + } + + if err := encoder.UpdateFrontmatter(markdownPath, duration, bytes); err != nil { + return fmt.Errorf("failed to update frontmatter: %w", err) } + + cli.PrintSuccess("Frontmatter updated successfully") + return nil } // Ensure HugoWorkflow implements Workflow at compile time. diff --git a/cmd/jive-encoder/hugo_test.go b/cmd/jive-encoder/hugo_test.go index 1f7e90d..f9b640e 100644 --- a/cmd/jive-encoder/hugo_test.go +++ b/cmd/jive-encoder/hugo_test.go @@ -1,10 +1,14 @@ package main import ( + "bytes" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/linuxmatters/jive-encoder/internal/encoder" ) // TestHugoWorkflowValidate tests Hugo mode validation of episode markdown arguments. @@ -84,6 +88,279 @@ func TestHugoWorkflowValidate(t *testing.T) { }) } +// TestHugoWorkflowCollectMetadata covers flag-over-frontmatter precedence, +// episode validation, date formatting, and cover-path resolution. +func TestHugoWorkflowCollectMetadata(t *testing.T) { + t.Run("frontmatter values with Hugo defaults", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nDate: 2024-03-15\nepisode_image: ./cover.png\n") + if err := os.WriteFile(filepath.Join(filepath.Dir(mdPath), "cover.png"), []byte("img"), 0o644); err != nil { + t.Fatalf("write cover fixture: %v", err) + } + + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath}} + meta, cover, err := wf.CollectMetadata() + if err != nil { + t.Fatalf("CollectMetadata() unexpected error: %v", err) + } + + if meta.EpisodeNumber != "67" { + t.Errorf("EpisodeNumber = %q; want 67", meta.EpisodeNumber) + } + if meta.Title != "The Show" { + t.Errorf("Title = %q; want The Show", meta.Title) + } + if meta.Artist != HugoDefaultArtist { + t.Errorf("Artist = %q; want %q", meta.Artist, HugoDefaultArtist) + } + if meta.Album != HugoDefaultArtist { + t.Errorf("Album = %q; want %q (falls back to artist)", meta.Album, HugoDefaultArtist) + } + if meta.Comment != HugoDefaultComment { + t.Errorf("Comment = %q; want %q", meta.Comment, HugoDefaultComment) + } + if meta.Date != "2024-03" { + t.Errorf("Date = %q; want 2024-03 (formatted via FormatDateForID3)", meta.Date) + } + if filepath.Base(cover) != "cover.png" { + t.Errorf("cover = %q; want a resolved cover.png path", cover) + } + }) + + t.Run("flags override frontmatter and defaults", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nDate: 2024-03-15\nepisode_image: ./cover.png\n") + flagCover := filepath.Join(t.TempDir(), "flag-cover.png") + + wf := &HugoWorkflow{opts: CLIOptions{ + EpisodeMD: mdPath, + Num: "99", + Title: "Override Title", + Artist: "Override Artist", + Album: "Override Album", + Comment: "https://override.example", + Date: "2020-01-02", + Cover: flagCover, + }} + meta, cover, err := wf.CollectMetadata() + if err != nil { + t.Fatalf("CollectMetadata() unexpected error: %v", err) + } + + if meta.EpisodeNumber != "99" { + t.Errorf("EpisodeNumber = %q; want 99", meta.EpisodeNumber) + } + if meta.Title != "Override Title" { + t.Errorf("Title = %q; want Override Title", meta.Title) + } + if meta.Artist != "Override Artist" { + t.Errorf("Artist = %q; want Override Artist", meta.Artist) + } + if meta.Album != "Override Album" { + t.Errorf("Album = %q; want Override Album", meta.Album) + } + if meta.Comment != "https://override.example" { + t.Errorf("Comment = %q; want https://override.example", meta.Comment) + } + if meta.Date != "2020-01-02" { + t.Errorf("Date = %q; want raw --date 2020-01-02", meta.Date) + } + if cover != flagCover { + t.Errorf("cover = %q; want flag cover %q", cover, flagCover) + } + }) + + t.Run("rejects invalid episode override", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./cover.png\n") + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath, Num: "-5"}} + + _, _, err := wf.CollectMetadata() + if err == nil { + t.Fatal("CollectMetadata() expected error for invalid episode number, got nil") + } + if !strings.Contains(err.Error(), "invalid episode number") { + t.Errorf("error %q does not contain %q", err.Error(), "invalid episode number") + } + }) + + t.Run("reports cover resolution failure", func(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./missing.png\n") + wf := &HugoWorkflow{opts: CLIOptions{EpisodeMD: mdPath}} + + _, _, err := wf.CollectMetadata() + if err == nil { + t.Fatal("CollectMetadata() expected error for missing cover art, got nil") + } + if !strings.Contains(err.Error(), "resolve cover art") { + t.Errorf("error %q does not contain %q", err.Error(), "resolve cover art") + } + }) +} + +// TestHugoWorkflowPostEncode covers the mismatch, missing-value, and no-change +// branches that decide whether the user is prompted to update frontmatter. +func TestHugoWorkflowPostEncode(t *testing.T) { + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + tests := []struct { + name string + duration string + bytes int64 + input string + wantPrompt string // substring expected in the prompt; "" means no prompt + }{ + { + name: "no change when values match", + duration: "00:20:00", + bytes: 2048, + input: "", + wantPrompt: "", + }, + { + name: "duration mismatch prompts for update", + duration: "00:10:00", + bytes: 2048, + input: "n\n", + wantPrompt: "Update frontmatter with new values?", + }, + { + name: "byte size mismatch prompts for update", + duration: "00:20:00", + bytes: 1024, + input: "n\n", + wantPrompt: "Update frontmatter with new values?", + }, + { + name: "missing values prompt to add", + duration: "", + bytes: 0, + input: "n\n", + wantPrompt: "Add podcast_duration and podcast_bytes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: filepath.Join(t.TempDir(), "episode.md")}, + hugoMetadata: &encoder.EpisodeMetadata{PodcastDuration: tt.duration, PodcastBytes: tt.bytes}, + } + + var err error + out := captureStdio(t, tt.input, func() { err = wf.PostEncode(stats) }) + if err != nil { + t.Fatalf("PostEncode() unexpected error: %v", err) + } + + if tt.wantPrompt == "" { + if strings.Contains(out, "frontmatter") { + t.Errorf("PostEncode() unexpectedly prompted: %q", out) + } + return + } + if !strings.Contains(out, tt.wantPrompt) { + t.Errorf("PostEncode() prompt %q does not contain %q", out, tt.wantPrompt) + } + }) + } +} + +// TestHugoWorkflowPostEncodeWritesFrontmatter verifies the accept path writes +// the calculated stats back into the markdown file. +func TestHugoWorkflowPostEncodeWritesFrontmatter(t *testing.T) { + mdPath := writeHugoFixture(t, "episode: \"67\"\ntitle: The Show\nepisode_image: ./cover.png\n") + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: mdPath}, + hugoMetadata: &encoder.EpisodeMetadata{}, + } + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + var err error + captureStdio(t, "y\n", func() { err = wf.PostEncode(stats) }) + if err != nil { + t.Fatalf("PostEncode() unexpected error: %v", err) + } + + updated, readErr := os.ReadFile(mdPath) + if readErr != nil { + t.Fatalf("read updated markdown: %v", readErr) + } + if !strings.Contains(string(updated), "podcast_duration") { + t.Errorf("frontmatter not updated:\n%s", updated) + } +} + +// TestHugoWorkflowPostEncodePropagatesWriteFailure verifies a failed frontmatter +// write returns an error so the process exits non-zero. +func TestHugoWorkflowPostEncodePropagatesWriteFailure(t *testing.T) { + wf := &HugoWorkflow{ + opts: CLIOptions{EpisodeMD: filepath.Join(t.TempDir(), "missing.md")}, + hugoMetadata: &encoder.EpisodeMetadata{}, + } + stats := &encoder.FileStats{DurationString: "00:20:00", FileSizeBytes: 2048} + + var err error + captureStdio(t, "y\n", func() { err = wf.PostEncode(stats) }) + if err == nil { + t.Fatal("PostEncode() expected error when the frontmatter write fails, got nil") + } +} + +// writeHugoFixture writes a markdown file with the given frontmatter body +// (between --- delimiters) and returns its path. +func writeHugoFixture(t *testing.T, frontmatter string) string { + t.Helper() + + mdPath := filepath.Join(t.TempDir(), "episode.md") + body := "---\n" + frontmatter + "---\nBody\n" + if err := os.WriteFile(mdPath, []byte(body), 0o644); err != nil { + t.Fatalf("write markdown fixture: %v", err) + } + + return mdPath +} + +// captureStdio feeds input to the prompt's stdin and returns whatever fn writes +// to os.Stdout. The cli.Print* helpers hold the original stdout captured at +// package init, so only the raw prompt text is captured here. +func captureStdio(t *testing.T, input string, fn func()) string { + t.Helper() + + inR, inW, err := os.Pipe() + if err != nil { + t.Fatalf("create stdin pipe: %v", err) + } + origStdin := os.Stdin + os.Stdin = inR + go func() { + _, _ = inW.WriteString(input) + _ = inW.Close() + }() + + outR, outW, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + origStdout := os.Stdout + os.Stdout = outW + + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, outR) + done <- buf.String() + }() + + fn() + + os.Stdout = origStdout + _ = outW.Close() + captured := <-done + + os.Stdin = origStdin + _ = inR.Close() + + return captured +} + func existingMarkdownArgument(t *testing.T, path string) string { t.Helper() diff --git a/cmd/jive-encoder/main.go b/cmd/jive-encoder/main.go index b5468de..7b767fc 100644 --- a/cmd/jive-encoder/main.go +++ b/cmd/jive-encoder/main.go @@ -46,9 +46,12 @@ var CLI struct { OutputPath string `help:"Output file or directory path"` // Encoding options - Format string `help:"Output format: mp3, aac, or opus" enum:"mp3,opus,aac" default:"mp3"` - Stereo bool `help:"Encode as stereo at the format's stereo bitrate (default: mono)"` - Version bool `help:"Show version information"` + Format string `help:"Output format: mp3, aac, or opus" enum:"mp3,opus,aac" default:"mp3"` + Stereo bool `help:"Encode as stereo at the format's stereo bitrate (default: mono)"` + // Version is hand-checked in run() rather than using kong.VersionFlag so the + // styled cli.PrintVersion output (title banner + version) is preserved; + // kong.VersionFlag prints only the bare version string and exits. + Version bool `help:"Show version information"` } // detectMode reports whether the invocation is a Hugo or Standalone workflow. @@ -311,9 +314,12 @@ func run() int { return 0 } + // --help and --version already exited above; a bare invocation with no audio + // file is a usage error, so print usage and exit non-zero. if CLI.AudioFile == "" { + cli.PrintError("audio file argument is required") _ = ctx.PrintUsage(false) - return 0 + return 1 } mode := detectMode(CLI.EpisodeMD) diff --git a/cmd/jive-encoder/standalone.go b/cmd/jive-encoder/standalone.go index 60a073f..1875b61 100644 --- a/cmd/jive-encoder/standalone.go +++ b/cmd/jive-encoder/standalone.go @@ -48,8 +48,11 @@ func (s *StandaloneWorkflow) CollectMetadata() (encoder.Metadata, string, error) Title: s.opts.Title, Artist: s.opts.Artist, Album: album, - Date: s.opts.Date, - Comment: s.opts.Comment, + // --date is passed through raw: the user types the exact tag string. + // Hugo mode instead formats a parsed time.Time via FormatDateForID3 + // (see hugo.go), because its date comes from frontmatter as a timestamp. + Date: s.opts.Date, + Comment: s.opts.Comment, } return tags, s.opts.Cover, nil diff --git a/cmd/jive-encoder/workflow.go b/cmd/jive-encoder/workflow.go index ad8b8ef..7b18a18 100644 --- a/cmd/jive-encoder/workflow.go +++ b/cmd/jive-encoder/workflow.go @@ -34,7 +34,7 @@ func resolveAlbum(album, artist string) string { // printPodcastStats displays the common podcast statistics shared by every workflow. func printPodcastStats(stats *encoder.FileStats) { - fmt.Println("\nPodcast statistics:") + cli.PrintInfo("Podcast statistics:") cli.PrintLabelValue("• podcast_duration:", stats.DurationString) cli.PrintLabelValue("• podcast_bytes:", fmt.Sprintf("%d", stats.FileSizeBytes)) } diff --git a/internal/artwork/artwork.go b/internal/artwork/artwork.go index 40271ee..a50a9c7 100644 --- a/internal/artwork/artwork.go +++ b/internal/artwork/artwork.go @@ -46,6 +46,8 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { var targetSize int var needsScaling bool + // No lower sanity bound is deliberate: any square smaller than 1400 upscales + // to 1400, so even a 1x1 input is accepted and blown up to spec. switch { case width < 1400: targetSize = 1400 @@ -65,7 +67,9 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { dst := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize)) // Bilinear matches the scaler used by Jivefire thumbnail generation. - draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + // draw.Src writes the resized pixels straight into the fresh + // destination, the cheaper choice for a full-frame resize. + draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) src = dst } diff --git a/internal/artwork/artwork_test.go b/internal/artwork/artwork_test.go index 0bd7824..4da326c 100644 --- a/internal/artwork/artwork_test.go +++ b/internal/artwork/artwork_test.go @@ -4,6 +4,7 @@ import ( "bytes" "image" "image/color" + "image/gif" "image/jpeg" "image/png" "io" @@ -376,6 +377,36 @@ func TestScaleCoverArt_OutOfSpecJPEG(t *testing.T) { } } +// TestScaleCoverArt_OutOfSpecGIF tests that an undersized GIF decodes, scales up +// and re-encodes to PNG +func TestScaleCoverArt_OutOfSpecGIF(t *testing.T) { + tmpDir := t.TempDir() + testImagePath := filepath.Join(tmpDir, "test.gif") + + if err := createTestGIF(testImagePath, 800, 800); err != nil { + t.Fatalf("Failed to create test GIF: %v", err) + } + + scaledData, err := ScaleCoverArt(testImagePath) + if err != nil { + t.Fatalf("ScaleCoverArt failed: %v", err) + } + + if !bytes.HasPrefix(scaledData, pngMagic) { + t.Errorf("Expected output to start with PNG magic header, got % x", scaledData[:min(len(scaledData), 8)]) + } + + decodedImg, err := png.Decode(bytes.NewReader(scaledData)) + if err != nil { + t.Fatalf("Failed to decode scaled image: %v", err) + } + + bounds := decodedImg.Bounds() + if bounds.Dx() != 1400 || bounds.Dy() != 1400 { + t.Errorf("Expected 1400x1400, got %dx%d", bounds.Dx(), bounds.Dy()) + } +} + // TestScaleCoverArt_EdgeCases tests edge case sizes func TestScaleCoverArt_EdgeCases(t *testing.T) { tests := []struct { @@ -460,9 +491,11 @@ func TestScaleCoverArt_OutputDataSize(t *testing.T) { t.Errorf("Output data too small: %d bytes (expected > 1KB)", len(scaledData)) } - // Verify it's not excessively large (should be reasonable for a 1400x1400 PNG) - if len(scaledData) > 50*1024*1024 { - t.Errorf("Output data too large: %d bytes", len(scaledData)) + // A PNG of this content cannot exceed its uncompressed RGBA pixel data, so + // 1400x1400x4 bytes is a tight upper bound that still catches a gross regression. + maxSize := 1400 * 1400 * 4 + if len(scaledData) > maxSize { + t.Errorf("Output data too large: %d bytes (expected <= %d)", len(scaledData), maxSize) } } @@ -519,6 +552,13 @@ func createTestJPEG(path string, width, height int) error { }) } +// Helper function to create test GIF images +func createTestGIF(path string, width, height int) error { + return createTestImage(path, width, height, func(w io.Writer, img image.Image) error { + return gif.Encode(w, img, nil) + }) +} + func createTestImage(path string, width, height int, encode func(io.Writer, image.Image) error) error { img := image.NewRGBA(image.Rect(0, 0, width, height)) diff --git a/internal/cli/colours.go b/internal/cli/colours.go index c47c503..27441b8 100644 --- a/internal/cli/colours.go +++ b/internal/cli/colours.go @@ -12,9 +12,10 @@ var ( TextColor = lipgloss.Color("#FFFFFF") // White ErrorColor = lipgloss.Color("#DA70D6") // Orchid SecondaryColor = lipgloss.Color("#9370DB") // Medium purple - BorderColor = lipgloss.Color("#00BFFF") // Deep sky blue + WarningColor = lipgloss.Color("#FFA500") // Orange + BorderColor = PrimaryColor // Deep sky blue // Gradient colours. GradientIndigo = lipgloss.Color("#4B0082") // Deep indigo - GradientWhite = lipgloss.Color("#E0E0E0") // Light grey + GradientWhite = HighlightColor // Light grey ) diff --git a/internal/cli/help.go b/internal/cli/help.go index ca7089f..9e6fa52 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -102,8 +102,8 @@ func StyledHelpPrinter(options kong.HelpOptions, ctx *kong.Context) error { sb.WriteString("\n") // Degrade colour for non-TTY output, honouring NO_COLOR and TERM w := newColourWriter(ctx.Stdout) - fmt.Fprint(w, sb.String()) - return nil + _, err := fmt.Fprint(w, sb.String()) + return err } // argument represents a CLI argument diff --git a/internal/cli/help_golden_test.go b/internal/cli/help_golden_test.go new file mode 100644 index 0000000..d63238b --- /dev/null +++ b/internal/cli/help_golden_test.go @@ -0,0 +1,102 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/alecthomas/kong" +) + +// posFixture is a CLI with positional arguments, exercising the +// Model.Positional/Summary() path in getArguments and StyledHelpPrinter. +type posFixture struct { + Audio string `arg:"" optional:"" help:"Audio file to encode."` + Episode string `arg:"" optional:"" help:"Episode markdown file."` + Format string `help:"Output format." default:"mp3"` +} + +// newPositionalContext builds a kong context whose stdout is buf, so the +// styled help output can be captured. NO_COLOR keeps the output plain. +func newPositionalContext(t *testing.T, buf *bytes.Buffer) *kong.Context { + t.Helper() + t.Setenv("NO_COLOR", "1") + + var fixture posFixture + parser, err := kong.New(&fixture, + kong.Name("jive-encoder"), + kong.Description("Encode podcast audio."), + kong.Exit(func(int) {}), + kong.Writers(buf, &bytes.Buffer{}), + ) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + + ctx, err := parser.Parse([]string{}) + if err != nil { + t.Fatalf("parser.Parse() error = %v", err) + } + return ctx +} + +func TestGetArgumentsPositional(t *testing.T) { + args := getArguments(newPositionalContext(t, &bytes.Buffer{})) + + want := []argument{ + {name: "[