Strengthen CLI action preflight and coverage
This commit is contained in:
@@ -318,7 +318,7 @@ func TestCompareCommandLeavesPreExecutionFailuresUnstructured(t *testing.T) {
|
||||
|
||||
func TestCompareHelpIncludesCommand(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") {
|
||||
if err := (Runner{}).Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || !strings.Contains(stdout.String(), "weatherreporter compare REPORT") || !strings.Contains(stdout.String(), "--profile PROFILE") || !strings.Contains(stdout.String(), "--date YYYY-MM-DD") || !strings.Contains(stdout.String(), "Suppress action summaries and routine batch status output.") {
|
||||
t.Fatalf("help/error = %q/%v", stdout.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
141
internal/cli/generate_test.go
Normal file
141
internal/cli/generate_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestGenerateInputFailuresDoNotConstructOrExecute(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "MissingReport", args: nil, wantErr: "generate requires a report name"},
|
||||
{name: "UnknownReport", args: []string{"unknown"}, wantErr: "unknown generate report"},
|
||||
{name: "MissingDailyDate", args: []string{"daily", "--config", filepath.Join(t.TempDir(), "missing.yml")}, wantErr: "generate daily requires --date YYYY-MM-DD"},
|
||||
{name: "MalformedDailyDate", args: []string{"daily", "--date", "not-a-date", "--config", configPath}},
|
||||
{name: "MalformedTodayDate", args: []string{"today", "--date", "not-a-date", "--config", configPath}},
|
||||
{name: "UnexpectedArgument", args: []string{"today", "extra"}},
|
||||
{name: "UnsupportedFlag", args: []string{"today", "--out-dir", "reports"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return &factoryExecutor{}, nil
|
||||
},
|
||||
generateDetailed: func(context.Context, app.GenerateRequest) (*app.ReportResult, error) {
|
||||
applicationCalls++
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), append([]string{"generate"}, tt.args...), &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil")
|
||||
}
|
||||
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Run() error = %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
if factoryCalls != 0 || applicationCalls != 0 || stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("factory/application calls/output = %d/%d/%q/%q", factoryCalls, applicationCalls, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCommandProjectsActionResults(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
failure := errors.New("prompt execution failed")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
quiet bool
|
||||
actionErr error
|
||||
}{
|
||||
{name: "Success"},
|
||||
{name: "Failure", actionErr: failure},
|
||||
{name: "QuietFailure", quiet: true, actionErr: failure},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
executor := &factoryExecutor{}
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return executor, nil
|
||||
},
|
||||
generateDetailed: func(_ context.Context, req app.GenerateRequest) (*app.ReportResult, error) {
|
||||
applicationCalls++
|
||||
if req.Executor != executor {
|
||||
t.Fatal("generate request did not receive the constructed executor")
|
||||
}
|
||||
return generatedReportResult(), tt.actionErr
|
||||
},
|
||||
}
|
||||
args := []string{"generate", "daily", "--date", "2026-05-29", "--config", configPath}
|
||||
if tt.quiet {
|
||||
args = append(args, "--quiet")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), args, &stdout, &stderr)
|
||||
if !errors.Is(err, tt.actionErr) || factoryCalls != 1 || applicationCalls != 1 || stderr.Len() != 0 {
|
||||
t.Fatalf("error/calls/stderr = %v/%d/%d/%q", err, factoryCalls, applicationCalls, stderr.String())
|
||||
}
|
||||
if tt.quiet {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("quiet stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var summary generateSummary
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
wantStatus, wantError := summaryStatusSucceeded, ""
|
||||
if tt.actionErr != nil {
|
||||
wantStatus, wantError = summaryStatusFailed, tt.actionErr.Error()
|
||||
}
|
||||
if summary.Command != commandGenerate || summary.Status != wantStatus || summary.Error != wantError || summary.OutputPath != "/reports/daily.md" {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func actionConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func generatedReportResult() *app.ReportResult {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
return &app.ReportResult{
|
||||
ReportID: report.Daily, ReportName: "Daily Report", PromptID: "weather.daily_generated_text", PromptVersion: "2.0.0",
|
||||
RunID: "daily-20260529", GeneratedAt: generatedAt, Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{Start: generatedAt, End: generatedAt.Add(24 * time.Hour)},
|
||||
ProfileID: "weather-light", BackendID: "local", ModelName: "weather-model", ValidationStatus: promptexec.ValidationPassed,
|
||||
OutputPath: "/reports/daily.md",
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,10 @@ Options:
|
||||
--out PATH Write the generated Markdown report to PATH.
|
||||
--llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH.
|
||||
--profile PROFILE Select a prompt profile for compare; repeat for every profile.
|
||||
--date YYYY-MM-DD Required for generate/compare daily; optional for generate/compare today.
|
||||
--out-dir PATH Write generated Markdown reports beneath PATH for run commands, or select the exact comparison directory.
|
||||
--replace Authorize replacement of a recognized comparison bundle.
|
||||
--quiet Suppress successful action output.
|
||||
--quiet Suppress action summaries and routine batch status output.
|
||||
`
|
||||
|
||||
type Runner struct {
|
||||
@@ -48,6 +49,7 @@ type Runner struct {
|
||||
ExecutorFactory ExecutorFactory
|
||||
Version string
|
||||
WorkingDir string
|
||||
generateDetailed func(context.Context, app.GenerateRequest) (*app.ReportResult, error)
|
||||
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
|
||||
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error)
|
||||
}
|
||||
@@ -86,7 +88,11 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := app.GenerateDetailed(ctx, req)
|
||||
generateDetailed := r.generateDetailed
|
||||
if generateDetailed == nil {
|
||||
generateDetailed = app.GenerateDetailed
|
||||
}
|
||||
result, err := generateDetailed(ctx, req)
|
||||
if result != nil {
|
||||
summary := newGenerateSummary(result, err)
|
||||
if encodeErr := writeActionResult(stdout, stderr, summary, outputOptions{Quiet: opts.Quiet}, nil); encodeErr != nil {
|
||||
@@ -192,6 +198,9 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
if reportKind == app.ReportDaily && opts.Date == "" {
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||
}
|
||||
cfg, err := config.Load(config.LoadOptions{
|
||||
Path: opts.ConfigPath,
|
||||
Units: opts.Units,
|
||||
@@ -200,14 +209,34 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
now := r.Clock.Now()
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: reportKind,
|
||||
LLMDebugDir: opts.LLMDebugDir,
|
||||
Now: now,
|
||||
}
|
||||
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
case app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(now, location)
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workingDir, err := r.workingDir()
|
||||
if err != nil {
|
||||
@@ -217,36 +246,12 @@ func (r Runner) resolveGenerateAction(args []string) (app.GenerateRequest, commo
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
|
||||
req := app.GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: reportKind,
|
||||
WorkingDir: workingDir,
|
||||
OutputPath: outputPath,
|
||||
LLMDebugDir: opts.LLMDebugDir,
|
||||
Now: r.Clock.Now(),
|
||||
Executor: executor,
|
||||
}
|
||||
|
||||
switch reportKind {
|
||||
case app.ReportDaily:
|
||||
if opts.Date == "" {
|
||||
return app.GenerateRequest{}, commonOptions{}, fmt.Errorf("generate daily requires --date YYYY-MM-DD")
|
||||
}
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
case app.ReportToday:
|
||||
if opts.Date == "" {
|
||||
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
|
||||
} else {
|
||||
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
}
|
||||
req.WorkingDir, req.OutputPath = workingDir, outputPath
|
||||
executor, err := r.promptExecutor(cfg.Promptkit)
|
||||
if err != nil {
|
||||
return app.GenerateRequest{}, commonOptions{}, err
|
||||
}
|
||||
req.Executor = executor
|
||||
|
||||
return req, opts.commonOptions, nil
|
||||
}
|
||||
@@ -373,7 +378,7 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions,
|
||||
fs.SetOutput(io.Discard)
|
||||
opts := generateOptions{}
|
||||
addCommonFlags(fs, &opts.commonOptions, true)
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
}
|
||||
@@ -392,7 +397,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
|
||||
opts := commonOptions{}
|
||||
addCommonFlags(fs, &opts, false)
|
||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "generated Markdown report directory")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return commonOptions{}, err
|
||||
}
|
||||
@@ -409,7 +414,7 @@ func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptio
|
||||
addCommonFlags(fs, &opts.commonOptions, false)
|
||||
fs.StringVar(&opts.OutputDir, "out-dir", "", "comparison bundle directory")
|
||||
fs.BoolVar(&opts.Replace, "replace", false, "replace a recognized comparison bundle")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output")
|
||||
fs.BoolVar(&opts.Quiet, "quiet", false, "suppress action summaries and routine batch status output")
|
||||
fs.Var(&opts.ProfileIDs, "profile", "prompt profile ID")
|
||||
if report == app.ReportDaily || report == app.ReportToday {
|
||||
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
|
||||
|
||||
@@ -193,6 +193,66 @@ func TestRunActionReturnsFailureForBatchNotificationFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandProjectsSuccessAndReportFailure(t *testing.T) {
|
||||
configPath := actionConfigPath(t)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
result *app.BatchResult
|
||||
wantStatus string
|
||||
wantError bool
|
||||
statusLine string
|
||||
}{
|
||||
{
|
||||
name: "Success",
|
||||
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Succeeded: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "succeeded", OutputPath: "/reports/today.md"}}},
|
||||
wantStatus: summaryStatusSucceeded,
|
||||
statusLine: `report=today status=succeeded output="/reports/today.md"`,
|
||||
},
|
||||
{
|
||||
name: "ReportFailure",
|
||||
result: &app.BatchResult{Batch: app.BatchMorning, Total: 1, Failed: 1, Reports: []app.BatchReportResult{{ReportID: "today", Status: "failed", Error: "prompt execution failed"}}},
|
||||
wantStatus: summaryStatusFailed,
|
||||
wantError: true,
|
||||
statusLine: `report=today status=failed error="prompt execution failed"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
factoryCalls, applicationCalls := 0, 0
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
factoryCalls++
|
||||
return &factoryExecutor{}, nil
|
||||
},
|
||||
runBatchDetailed: func(context.Context, app.BatchRequest) (*app.BatchResult, error) {
|
||||
applicationCalls++
|
||||
return tt.result, nil
|
||||
},
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runner.Run(context.Background(), []string{"run", "morning", "--config", configPath}, &stdout, &stderr)
|
||||
if factoryCalls != 1 || applicationCalls != 1 || !strings.Contains(stderr.String(), tt.statusLine) {
|
||||
t.Fatalf("calls/stderr = %d/%d/%q", factoryCalls, applicationCalls, stderr.String())
|
||||
}
|
||||
if tt.wantError {
|
||||
var batchErr app.BatchError
|
||||
if !errors.As(err, &batchErr) {
|
||||
t.Fatalf("Run() error = %v, want BatchError", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
var summary batchSummary
|
||||
if err := json.Unmarshal(stdout.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("decode summary: %v", err)
|
||||
}
|
||||
if summary.Command != commandRun || summary.Status != tt.wantStatus || (summary.Error != "") != tt.wantError {
|
||||
t.Fatalf("summary = %#v", summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectCommandIsUnknownAndAbsentFromHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := (Runner{}).Run(context.Background(), []string{"inspect", "reports"}, &stdout, &stderr)
|
||||
|
||||
Reference in New Issue
Block a user