Add comparison command request parsing

This commit is contained in:
2026-08-02 05:56:31 +00:00
parent d39e42de30
commit b489c56a48
3 changed files with 360 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) {
@@ -102,6 +103,33 @@ func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
}
}
func TestCompareDetailedLeavesDestinationWhenCollectionOrPreparationFails(t *testing.T) {
collectionErr := errors.New("weather collection failed")
for _, test := range []struct {
name string
collector *generationCollector
}{
{name: "collection", collector: &generationCollector{err: collectionErr}},
{name: "preparation", collector: &generationCollector{bundle: &weatherdata.Bundle{}}},
} {
t.Run(test.name, func(t *testing.T) {
workingDir := t.TempDir()
executor := &generationExecutor{}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: test.collector, Executor: executor,
})
if err == nil || result == nil || executor.executeCalls != 0 || result.ManifestPath != "" || result.DataPackagePath != "" {
t.Fatalf("CompareDetailed() result/error/executor = %#v/%v/%#v", result, err, executor)
}
if _, statErr := os.Stat(filepath.Join(workingDir, "comparison-daily-2026-05-29")); !os.IsNotExist(statErr) {
t.Fatalf("comparison destination stat error = %v", statErr)
}
})
}
}
func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) {
bundle := generationBundle(t)
executor := &generationExecutor{executeErrors: map[string]error{

View File

@@ -0,0 +1,204 @@
package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestParseComparisonFlagsPreservesProfileOrder(t *testing.T) {
opts, err := parseComparisonFlags(app.ReportDaily, []string{
"--profile", "weather-light", "--profile=weather-balanced", "--profile", "weather-deep",
"--date", "2026-05-29", "--out-dir", "reports", "--replace", "--quiet",
})
if err != nil {
t.Fatalf("parseComparisonFlags() error = %v", err)
}
wantProfiles := []string{"weather-light", "weather-balanced", "weather-deep"}
if !reflect.DeepEqual([]string(opts.ProfileIDs), wantProfiles) || opts.Date != "2026-05-29" || opts.OutputDir != "reports" || !opts.Replace || !opts.Quiet {
t.Fatalf("options = %#v", opts)
}
}
func TestResolveComparisonActionBuildsExplicitRequest(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n units: metric\n timezone: America/Chicago\noutput:\n directory: configured-reports\npromptkit:\n profile: configured-default\n")
clock := timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}
var factoryConfig PromptExecutorConfig
factoryCalls := 0
executor := &factoryExecutor{}
runner := Runner{
Clock: clock, WorkingDir: workingDir,
ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
factoryConfig = value
return executor, nil
},
}
req, opts, err := runner.resolveComparisonAction([]string{
"daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep",
"--out-dir", "bundles/../comparison", "--replace", "--llm-debug-dir", "/tmp/debug", "--units", "imperial", "--tz", "America/New_York", "--config", configPath,
})
if err != nil {
t.Fatalf("resolveComparisonAction() error = %v", err)
}
if factoryCalls != 1 || req.Executor != executor || req.Clock != clock || req.WorkingDir != workingDir || req.OutputDir != filepath.Join(workingDir, "comparison") || !req.Replace || req.LLMDebugDir != "/tmp/debug" {
t.Fatalf("request/factory calls = %#v/%#v/%d", req, factoryConfig, factoryCalls)
}
if got, want := req.ProfileIDs, []string{"weather-light", "weather-deep"}; !reflect.DeepEqual(got, want) {
t.Fatalf("profile IDs = %#v, want %#v", got, want)
}
if req.Config.WeatherAPI.Units != "imperial" || req.Config.WeatherAPI.Timezone != "America/New_York" || req.Config.Output.Directory != "configured-reports" || factoryConfig.Profile != "" || opts.Quiet {
t.Fatalf("configuration/options/factory config = %#v/%#v/%#v", req.Config, opts, factoryConfig)
}
location, loadErr := time.LoadLocation("America/New_York")
if loadErr != nil || req.Date.Location().String() != location.String() || req.Date.Format("2006-01-02") != "2026-05-29" {
t.Fatalf("date/location = %v/%v", req.Date, loadErr)
}
}
func TestResolveComparisonActionMatchesReportDatePolicies(t *testing.T) {
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n timezone: America/Chicago\n")
runner := comparisonRunner(t, t.TempDir())
for _, test := range []struct {
name string
args []string
wantDay string
wantErr bool
}{
{name: "daily requires date", args: []string{"daily", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "today uses current local date", args: []string{"today", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-29"},
{name: "today accepts date", args: []string{"today", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantDay: "2026-05-30"},
{name: "tomorrow rejects date", args: []string{"tomorrow", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
{name: "hourly rejects date", args: []string{"hourly", "--date", "2026-05-30", "--profile", "one", "--profile", "two", "--config", configPath}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
req, _, err := runner.resolveComparisonAction(test.args)
if test.wantErr {
if err == nil {
t.Fatal("resolveComparisonAction() error = nil")
}
return
}
if err != nil || req.Date.Format("2006-01-02") != test.wantDay {
t.Fatalf("request/error = %#v/%v", req, err)
}
})
}
}
func TestResolveComparisonActionLeavesConfiguredOutputWithoutOverride(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\noutput:\n directory: configured/../reports\n")
req, _, err := comparisonRunner(t, workingDir).resolveComparisonAction([]string{
"daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--config", configPath,
})
if err != nil || req.OutputDir != "" || req.Config.Output.Directory != "configured/../reports" {
t.Fatalf("request/error = %#v/%v", req, err)
}
}
func TestComparisonInputFailuresDoNotConstructOrExecute(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{name: "missing report", args: nil},
{name: "unknown report", args: []string{"unknown", "--profile", "one", "--profile", "two"}},
{name: "single profile", args: []string{"today", "--profile", "one"}},
{name: "blank profile", args: []string{"today", "--profile", "one", "--profile", " \t"}},
{name: "duplicate profile", args: []string{"today", "--profile", "one", "--profile", "one"}},
{name: "unexpected argument", args: []string{"today", "extra", "--profile", "one", "--profile", "two"}},
{name: "unsupported output flag", args: []string{"today", "--out", "report.md", "--profile", "one", "--profile", "two"}},
{name: "configuration failure", args: []string{"today", "--profile", "one", "--profile", "two", "--config", filepath.Join(t.TempDir(), "missing.yml")}},
} {
t.Run(test.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
},
compareDetailed: func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error) {
applicationCalls++
return nil, nil
},
}
if _, err := runner.executeComparison(context.Background(), test.args); err == nil {
t.Fatal("executeComparison() error = nil")
}
if factoryCalls != 0 || applicationCalls != 0 {
t.Fatalf("factory/application calls = %d/%d", factoryCalls, applicationCalls)
}
})
}
}
func TestExecuteComparisonUsesOneExecutorAndInjectedApplication(t *testing.T) {
workingDir := t.TempDir()
configPath := comparisonConfigPath(t, "weather_api:\n base_url: https://weather.api.example.com/\n")
executor := &factoryExecutor{}
factoryCalls, applicationCalls := 0, 0
var received app.ComparisonRequest
wantResult := &app.ComparisonResult{ComparisonID: "comparison_test"}
runner := Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
factoryCalls++
return executor, nil
},
compareDetailed: func(_ context.Context, req app.ComparisonRequest) (*app.ComparisonResult, error) {
applicationCalls++
received = req
return wantResult, errors.New("comparison completed with 1 failed profiles")
},
}
result, err := runner.executeComparison(context.Background(), []string{
"daily", "--date", "2026-05-29", "--profile", "weather-light", "--profile", "weather-deep", "--out-dir", "comparison", "--replace", "--config", configPath,
})
if result != wantResult || err == nil || factoryCalls != 1 || applicationCalls != 1 || received.Executor != executor || received.OutputDir != filepath.Join(workingDir, "comparison") || !received.Replace {
t.Fatalf("result/error/calls/request = %#v/%v/%d/%d/%#v", result, err, factoryCalls, applicationCalls, received)
}
if !reflect.DeepEqual(received.ProfileIDs, []string{"weather-light", "weather-deep"}) {
t.Fatalf("profile IDs = %#v", received.ProfileIDs)
}
}
func TestCompareRemainsAbsentFromRootDispatchAndHelp(t *testing.T) {
runner := comparisonRunner(t, t.TempDir())
var stdout, stderr bytes.Buffer
if err := runner.Run(context.Background(), []string{"compare", "today"}, &stdout, &stderr); err == nil || err.Error() != `unknown command "compare"` {
t.Fatalf("Run(compare) error = %v", err)
}
if err := runner.Run(context.Background(), []string{"--help"}, &stdout, &stderr); err != nil || strings.Contains(stdout.String(), "compare") {
t.Fatalf("help/error = %q/%v", stdout.String(), err)
}
}
func comparisonRunner(t *testing.T, workingDir string) Runner {
t.Helper()
return Runner{
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 30, 0, 0, time.UTC)}, WorkingDir: workingDir,
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { return &factoryExecutor{}, nil },
}
}
func comparisonConfigPath(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -45,6 +46,7 @@ type Runner struct {
Version string
WorkingDir string
runBatchDetailed func(context.Context, app.BatchRequest) (*app.BatchResult, error)
compareDetailed func(context.Context, app.ComparisonRequest) (*app.ComparisonResult, error)
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
@@ -127,6 +129,24 @@ type generateOptions struct {
Date string
}
type comparisonOptions struct {
commonOptions
Date string
ProfileIDs profileValues
Replace bool
}
type profileValues []string
func (values *profileValues) String() string {
return ""
}
func (values *profileValues) Set(value string) error {
*values = append(*values, value)
return nil
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
req, _, err := r.resolveGenerateAction(args)
return req, err
@@ -212,6 +232,93 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
return req, err
}
func (r Runner) resolveComparison(args []string) (app.ComparisonRequest, error) {
req, _, err := r.resolveComparisonAction(args)
return req, err
}
func (r Runner) executeComparison(ctx context.Context, args []string) (*app.ComparisonResult, error) {
req, _, err := r.resolveComparisonAction(args)
if err != nil {
return nil, err
}
compareDetailed := r.compareDetailed
if compareDetailed == nil {
compareDetailed = app.CompareDetailed
}
return compareDetailed(ctx, req)
}
func (r Runner) resolveComparisonAction(args []string) (app.ComparisonRequest, commonOptions, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 {
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare requires a report name")
}
if _, err := report.IDForCommandName(args[0]); err != nil {
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("unknown compare report %q", args[0])
}
reportKind := app.ReportKind(args[0])
opts, err := parseComparisonFlags(reportKind, args[1:])
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
if err := comparison.ValidateProfileIDs(opts.ProfileIDs); err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
workingDir, err := r.workingDir()
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
outputDir, err := resolveOutputOverride(workingDir, opts.OutputDir)
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
req := app.ComparisonRequest{
Config: cfg, Report: reportKind, ProfileIDs: append([]string(nil), opts.ProfileIDs...),
WorkingDir: workingDir, OutputDir: outputDir, Replace: opts.Replace, LLMDebugDir: opts.LLMDebugDir, Clock: r.Clock,
}
switch reportKind {
case app.ReportDaily:
if opts.Date == "" {
return app.ComparisonRequest{}, commonOptions{}, fmt.Errorf("compare daily requires --date YYYY-MM-DD")
}
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
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.ComparisonRequest{}, commonOptions{}, err
}
promptkitConfig := cfg.Promptkit
promptkitConfig.Profile = ""
executor, err := r.promptExecutor(promptkitConfig)
if err != nil {
return app.ComparisonRequest{}, commonOptions{}, err
}
req.Executor = executor
return req, opts.commonOptions, nil
}
func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
@@ -288,6 +395,27 @@ func parseRunFlags(args []string) (commonOptions, error) {
return opts, nil
}
func parseComparisonFlags(report app.ReportKind, args []string) (comparisonOptions, error) {
fs := flag.NewFlagSet("compare "+string(report), flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := comparisonOptions{}
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.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")
}
if err := fs.Parse(args); err != nil {
return comparisonOptions{}, err
}
if fs.NArg() > 0 {
return comparisonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return opts, nil
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")