package cli import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestRunHelpLongFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"--help"}, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(stdout.String(), "generate daily") { t.Fatalf("help output missing generate command:\n%s", stdout.String()) } } func TestRunHelpShortFlag(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"-h"}, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } if !strings.Contains(stdout.String(), "weatherreporter run evening") { t.Fatalf("help output missing run command:\n%s", stdout.String()) } } func TestRunUnknownCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := Run(context.Background(), []string{"unknown"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want unknown command error") } if !strings.Contains(err.Error(), `unknown command "unknown"`) { t.Fatalf("Run() error = %q, want unknown command message", err.Error()) } } func TestRunGenerateStormWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) outPath := filepath.Join(tempDir, "storm.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "storm", "--config", configPath, "--start", "2026-05-29T06:00", "--end", "2026-05-29T10:00", "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } report, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json") data, err := os.ReadFile(dataPackagePath) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) { t.Fatalf("data package output missing storm content:\n%s", string(data)) } } func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) workspaceRoot := filepath.Join(tempDir, "workspace") configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) outPath := filepath.Join(tempDir, "tomorrow.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "tomorrow", "--config", configPath, "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } report, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json") data, err := os.ReadFile(dataPackagePath) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) { t.Fatalf("data package output missing tomorrow content:\n%s", string(data)) } reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) if err != nil { t.Fatalf("glob managed report: %v", err) } if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches) } } func TestRunEveningGeneratesTomorrowReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "run", "evening", "--config", configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")) if err != nil { t.Fatalf("glob data package: %v", err) } if len(dataPackageMatches) != 1 { t.Fatalf("data package files = %#v, want one", dataPackageMatches) } reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) if err != nil { t.Fatalf("glob managed report: %v", err) } if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") { t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches) } } func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } outPath := filepath.Join(tempDir, "three-day.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "three-day", "--config", configPath, "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } report, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob data package: %v", err) } if len(dataPackageMatches) != 1 { t.Fatalf("data package files = %#v, want one", dataPackageMatches) } data, err := os.ReadFile(dataPackageMatches[0]) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) { t.Fatalf("data package output missing 3-day content:\n%s", string(data)) } } func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } outPath := filepath.Join(tempDir, "weekend.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "weekend", "--config", configPath, "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } report, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob data package: %v", err) } if len(dataPackageMatches) != 1 { t.Fatalf("data package files = %#v, want one", dataPackageMatches) } data, err := os.ReadFile(dataPackageMatches[0]) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) { t.Fatalf("data package output missing weekend content:\n%s", string(data)) } } func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "run", "morning", "--config", configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob weekend packages: %v", err) } if len(weekendPackages) != 1 { t.Fatalf("weekend packages = %#v, want one", weekendPackages) } } func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFailingScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "run", "morning", "--config", configPath, }, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want aggregate failure") } if !strings.Contains(err.Error(), "1 of 3 reports failed") { t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) } var summary app.BatchResult if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) } if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed) } if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") { t.Fatalf("stderr missing structured report logs:\n%s", stderr.String()) } dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob daily packages: %v", err) } weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob weekend packages: %v", err) } if len(dailyPackages) != 1 || len(weekendPackages) != 1 { t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages) } } func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") outputDir := filepath.Join(tempDir, "copies") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "run", "evening", "--config", configPath, "--out-dir", outputDir, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } var summary app.BatchResult if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil { t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String()) } if summary.Total != 1 || summary.Failed != 0 { t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed) } if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil { t.Fatalf("expected copied report: %v", err) } if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") { t.Fatalf("summary reports = %#v, want output path", summary.Reports) } } func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}} err := runner.Run(context.Background(), []string{ "run", "morning", "--config", configPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json")) if err != nil { t.Fatalf("glob daily packages: %v", err) } threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json")) if err != nil { t.Fatalf("glob 3-day packages: %v", err) } if len(dailyPackages) != 1 || len(threeDayPackages) != 1 { t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages) } } func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } outPath := filepath.Join(tempDir, "daily.md") var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{ "generate", "daily", "--config", configPath, "--date", "2026-05-29", "--tz", "UTC", "--out", outPath, }, &stdout, &stderr) if err != nil { t.Fatalf("Run() error = %v", err) } report, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read report: %v", err) } if !strings.Contains(string(report), "# Daily Report") { t.Fatalf("report output missing markdown:\n%s", string(report)) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob data package: %v", err) } if len(dataPackageMatches) != 1 { t.Fatalf("data package files = %#v, want one", dataPackageMatches) } data, err := os.ReadFile(dataPackageMatches[0]) if err != nil { t.Fatalf("read managed data package: %v", err) } if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) { t.Fatalf("data package output missing expected content:\n%s", string(data)) } var decoded struct { Briefing struct { Metadata struct { Location struct { ID string `json:"id"` Name string `json:"name"` Region string `json:"region"` Timezone string `json:"timezone"` } `json:"location"` } `json:"metadata"` } `json:"briefing"` } if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("decode data package: %v", err) } if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" { t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location) } preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")) if err != nil { t.Fatalf("glob preflight: %v", err) } if len(preflightMatches) != 1 { t.Fatalf("preflight files = %#v, want one render output", preflightMatches) } preflight, err := os.ReadFile(preflightMatches[0]) if err != nil { t.Fatalf("read preflight: %v", err) } if !strings.Contains(string(preflight), `ok`) { t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) } reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) if err != nil { t.Fatalf("glob managed report: %v", err) } if len(reportMatches) != 1 { t.Fatalf("managed reports = %#v, want one", reportMatches) } } func TestRunInspectGeneratedArtifacts(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() scriptoriumPath := writeFakeScriptorium(t, tempDir) configPath := filepath.Join(tempDir, "config.yml") workspaceRoot := filepath.Join(tempDir, "workspace") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } runner := Runner{Clock: fixedClock()} var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{ "generate", "daily", "--config", configPath, "--date", "2026-05-29", }, &stdout, &stderr) if err != nil { t.Fatalf("Run(generate) error = %v", err) } dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json")) if err != nil { t.Fatalf("glob data package: %v", err) } if len(dataPackageMatches) != 1 { t.Fatalf("data package files = %#v, want one", dataPackageMatches) } runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json") stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect reports) error = %v", err) } if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) { t.Fatalf("inspect reports output missing run:\n%s", stdout.String()) } for _, command := range []string{"metadata", "briefing", "data-package", "sources"} { stdout.Reset() err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr) if err != nil { t.Fatalf("Run(inspect %s) error = %v", command, err) } if !strings.Contains(stdout.String(), runID) { t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String()) } } if !strings.Contains(stdout.String(), `"warnings"`) { t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String()) } } func TestRunInspectMissingMetadata(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.yml") configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr) if err == nil { t.Fatal("Run(inspect metadata) error = nil, want missing metadata error") } if !strings.Contains(err.Error(), "metadata for run id") { t.Fatalf("error = %q, want missing run id context", err.Error()) } } func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.yml") configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } runner := Runner{Clock: fixedClock()} commands := []string{"metadata", "briefing", "data-package", "prior", "sources"} for _, command := range commands { t.Run(command+" requires run id", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want missing run id error") } if !strings.Contains(err.Error(), "requires a run id") { t.Fatalf("error = %q, want missing run id context", err.Error()) } }) t.Run(command+" accepts config", func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want missing metadata error") } if !strings.Contains(err.Error(), "metadata for run id") { t.Fatalf("error = %q, want missing metadata context", err.Error()) } }) } } func TestResolveGenerateCommands(t *testing.T) { runner := Runner{Clock: fixedClock()} tests := []struct { name string args []string want app.ReportKind }{ {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, {name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay}, {name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend}, {name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := runner.resolveGenerate(tt.args) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Report != tt.want { t.Fatalf("Report = %q, want %q", req.Report, tt.want) } }) } } func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{"daily"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { t.Fatalf("Date = %s, want 2026-05-29", got) } } func TestResolveGenerateAppliesSharedFlags(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if req.Config.WeatherAPI.Units != "metric" { t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units) } if req.Config.WeatherAPI.Timezone != "UTC" { t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone) } if req.OutputPath != "./daily.md" { t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath) } } func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) { runner := Runner{Clock: fixedClock()} _, err := runner.resolveGenerate([]string{"storm", "--end", "2026-05-29T18:00"}) if err == nil { t.Fatal("resolveGenerate() error = nil, want missing start error") } if !strings.Contains(err.Error(), "requires --start") { t.Fatalf("error = %q, want missing start", err.Error()) } _, err = runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"}) if err == nil { t.Fatal("resolveGenerate() error = nil, want missing end error") } if !strings.Contains(err.Error(), "requires --end") { t.Fatalf("error = %q, want missing end", err.Error()) } } func TestResolveGenerateStormParsesLocalTimestamps(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{ "storm", "--tz", "America/Chicago", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00", }) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if got := req.StormStart.Format(time.RFC3339); got != "2026-05-29T18:00:00-05:00" { t.Fatalf("StormStart = %q, want local Chicago time", got) } if got := req.StormEnd.Format(time.RFC3339); got != "2026-05-30T06:00:00-05:00" { t.Fatalf("StormEnd = %q, want local Chicago time", got) } } func TestResolveGenerateStormParsesRFC3339(t *testing.T) { runner := Runner{Clock: fixedClock()} req, err := runner.resolveGenerate([]string{ "storm", "--start", "2026-05-29T18:00:00-05:00", "--end", "2026-05-30T06:00:00-05:00", }) if err != nil { t.Fatalf("resolveGenerate() error = %v", err) } if !req.StormEnd.After(req.StormStart) { t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart) } } func TestResolveGenerateStormRejectsInvalidBounds(t *testing.T) { runner := Runner{Clock: fixedClock()} _, err := runner.resolveGenerate([]string{ "storm", "--start", "2026-05-30T06:00", "--end", "2026-05-29T18:00", }) if err == nil { t.Fatal("resolveGenerate() error = nil, want invalid bounds error") } if !strings.Contains(err.Error(), "end time after start time") { t.Fatalf("error = %q, want invalid bounds context", err.Error()) } } func TestResolveRunCommands(t *testing.T) { tests := []struct { name string args []string want app.BatchKind }{ {name: "morning", args: []string{"morning"}, want: app.BatchMorning}, {name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, err := resolveRun(tt.args) if err != nil { t.Fatalf("resolveRun() error = %v", err) } if req.Batch != tt.want { t.Fatalf("Batch = %q, want %q", req.Batch, tt.want) } }) } } func TestResolveRunRejectsOutputFlag(t *testing.T) { _, err := resolveRun([]string{"morning", "--out", "./report.md"}) if err == nil { t.Fatal("resolveRun() error = nil, want flag error") } if !strings.Contains(err.Error(), "flag provided but not defined") { t.Fatalf("error = %q, want undefined flag error", err.Error()) } } func TestResolveRunAppliesOutputDirectory(t *testing.T) { req, err := resolveRun([]string{"evening", "--out-dir", "./reports"}) if err != nil { t.Fatalf("resolveRun() error = %v", err) } if req.OutputDir != "./reports" { t.Fatalf("OutputDir = %q, want ./reports", req.OutputDir) } } func fixedClock() timeutil.Clock { return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} } func dailyServer(t *testing.T) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/observations": _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) case "/conditions/current": _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) case "/forecast/hourly": _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) case "/alerts/active": _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) default: http.NotFound(w, r) } })) t.Cleanup(server.Close) return server } func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string { t.Helper() configPath := filepath.Join(t.TempDir(), "config.yml") configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { t.Fatalf("write config: %v", err) } return configPath } func oneArtifact(t *testing.T, root string, parts ...string) string { t.Helper() matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) if err != nil { t.Fatalf("glob artifact: %v", err) } if len(matches) != 1 { t.Fatalf("artifact matches = %#v, want one", matches) } return matches[0] } func writeFakeScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") body := `#!/bin/sh if [ "$1" = "render" ]; then printf '{"ok":true,"argv":"%s"}' "$*" exit 0 fi if [ "$1" = "run" ]; then out="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" fi shift done printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" printf 'wrote report\n' >&2 exit 0 fi printf 'unexpected command\n' >&2 exit 1 ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatalf("write fake scriptorium: %v", err) } return path } func writeFailingScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") body := `#!/bin/sh if [ "$1" = "render" ]; then prompt="" while [ "$#" -gt 0 ]; do if [ "$1" = "--prompt" ]; then shift prompt="$1" fi shift done if [ "$prompt" = "weather.three_day_outlook" ]; then printf 'render failed\n' >&2 exit 1 fi printf '{"ok":true,"prompt":"%s"}' "$prompt" exit 0 fi if [ "$1" = "run" ]; then out="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift out="$1" fi shift done printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out" exit 0 fi printf 'unexpected command\n' >&2 exit 1 ` if err := os.WriteFile(path, []byte(body), 0o700); err != nil { t.Fatalf("write fake scriptorium: %v", err) } return path }