Files
weatherreporter/internal/cli/root_test.go

1698 lines
62 KiB
Go

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/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestRunHelpLongFlag(t *testing.T) {
output, err := runRootCommand(t, "--help")
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") {
t.Fatalf("help output missing generate command:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "generate today") {
t.Fatalf("help output missing today generate command:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "generate today") || !strings.Contains(output.stdout, "[--quiet]") {
t.Fatalf("help output missing quiet generate usage:\n%s", output.stdout)
}
if !strings.Contains(output.stdout, "run morning") || !strings.Contains(output.stdout, "--quiet Suppress successful generate and run output.") {
t.Fatalf("help output missing quiet run option:\n%s", output.stdout)
}
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
if strings.Contains(output.stdout, removedGenerateCommand) {
t.Fatalf("help output includes retired generate command:\n%s", output.stdout)
}
removedInspectCommand := "inspect " + "briefing"
if !strings.Contains(output.stdout, "inspect modules") || strings.Contains(output.stdout, removedInspectCommand) {
t.Fatalf("help output has wrong inspect commands:\n%s", output.stdout)
}
}
func TestRunHelpShortFlag(t *testing.T) {
output, err := runRootCommand(t, "-h")
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(output.stdout, "weatherreporter run evening") {
t.Fatalf("help output missing run command:\n%s", output.stdout)
}
}
func TestRunUnknownCommand(t *testing.T) {
_, err := runRootCommand(t, "unknown")
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) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("storm.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "storm",
"--config", fixture.configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: storm")
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
}
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("tomorrow.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "tomorrow",
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Saturday's Weather")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: tomorrow")
assertFileContains(t, dataPackagePath, "tomorrow_planning:")
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
}
}
func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"run", "evening",
"--config", fixture.configPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
}
}
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("three-day.md")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: three_day")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
summary := decodeGenerateSummary(t, output.stdout)
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.ThreeDay {
t.Fatalf("generate summary = %#v, want successful 3-day summary", summary)
}
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
t.Fatalf("summary paths = %#v, want managed artifact paths", summary)
}
if summary.OutputPath != outPath {
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
}
if summary.GeneratedTextRawPath != "" || summary.GeneratedTextResultPath != "" || summary.GeneratedTextPath != "" || summary.RenderContextPath != "" {
t.Fatalf("generated-text paths = %#v, want omitted for markdown report", summary)
}
}
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("weekend.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "weekend",
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
assertFileContains(t, dataPackagePath, "id: weekend")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
}
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"run", "morning",
"--config", fixture.configPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
}
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
fixture := newCLIFixture(t, writeFailingScriptorium)
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "morning",
"--config", fixture.configPath,
)
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "1 of 2 reports failed") {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
}
summary := decodeBatchSummary(t, output.stdout)
if summary.Command != "run" || summary.Status != "failed" {
t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status)
}
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml")
}
func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 2,
Failed: 0,
Notification: &app.BatchNotificationResult{
Status: "succeeded",
RunID: "batch-distributor-run",
PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.morning",
IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning",
Path: "/tmp/distributor.batch.json",
IncludedReports: []app.BatchNotificationReport{
{ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}},
},
},
Reports: []app.BatchReportResult{
{
ReportID: "daily",
Status: "succeeded",
OutputPath: "/tmp/daily.md",
},
{
ReportID: "tomorrow",
Status: "succeeded",
OutputPath: "/tmp/tomorrow.md",
},
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeBatchStatus(&stderr, result)
var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
}
if decoded.Notification == nil || decoded.Notification.Status != "succeeded" || decoded.Notification.RunID != "batch-distributor-run" || decoded.Notification.PipelineID != "weatherreporter" || len(decoded.Notification.IncludedReports) != 1 {
t.Fatalf("top-level notification = %#v, want succeeded batch notification", decoded.Notification)
}
for _, report := range decoded.Reports {
if report.NotificationStatus != "" || report.NotificationRunID != "" || report.NotificationError != "" {
t.Fatalf("report notification fields = %#v, want empty", report)
}
}
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) || !strings.Contains(stderr.String(), `pipelineId="weatherreporter"`) {
t.Fatalf("stderr missing batch notification details:\n%s", stderr.String())
}
if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") {
t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String())
}
}
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 1,
Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch morning: upload failed: [redacted]",
},
Reports: []app.BatchReportResult{
{
ReportID: "daily",
Status: "succeeded",
},
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeBatchStatus(&stderr, result)
for _, output := range []string{stdout.String(), stderr.String()} {
if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") {
t.Fatalf("output contains token value:\n%s", output)
}
if !strings.Contains(output, "[redacted]") {
t.Fatalf("output missing redacted marker:\n%s", output)
}
}
}
func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchMorning,
Total: 2,
Succeeded: 1,
Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
},
Reports: []app.BatchReportResult{
{ReportID: "today", Status: "succeeded", OutputPath: "/tmp/today.md"},
{ReportID: "tomorrow", Status: "failed", Error: "render failed"},
},
}
var stderr bytes.Buffer
var stdout bytes.Buffer
if err := writeJSON(&stdout, result); err != nil {
t.Fatalf("writeJSON() error = %v", err)
}
writeBatchStatus(&stderr, result)
var decoded app.BatchResult
if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil {
t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String())
}
if decoded.Notification == nil || decoded.Notification.Status != "skipped" || decoded.Notification.Reason != "one or more reports failed" {
t.Fatalf("top-level notification = %#v, want skipped notification", decoded.Notification)
}
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if !strings.Contains(stderr.String(), `batchNotification status="skipped" reason="one or more reports failed"`) {
t.Fatalf("stderr missing skipped batch notification:\n%s", stderr.String())
}
}
func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) {
result := &app.BatchResult{
Batch: app.BatchEvening,
Total: 1,
Succeeded: 1,
Failed: 1,
Notification: &app.BatchNotificationResult{
Status: "failed",
Error: "notify batch evening: upload failed",
},
Reports: []app.BatchReportResult{
{ReportID: "tomorrow", Status: "succeeded", OutputPath: "/tmp/tomorrow.md"},
},
}
var stderr bytes.Buffer
writeBatchStatus(&stderr, result)
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if count := strings.Count(stderr.String(), "notify batch evening: upload failed"); count != 1 {
t.Fatalf("stderr batch notification error occurrences = %d, want one:\n%s", count, stderr.String())
}
reportLine := firstLineWithPrefix(stderr.String(), "report=tomorrow ")
if strings.Contains(reportLine, "notify batch evening") || strings.Contains(reportLine, "notificationError") {
t.Fatalf("report line repeats batch notification error:\n%s", reportLine)
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outputDir := fixture.path("copies")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "evening",
"--config", fixture.configPath,
"--out-dir", outputDir,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
summary := decodeBatchSummary(t, output.stdout)
if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
}
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 TestRunQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "evening",
"--config", fixture.configPath,
"--quiet",
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.stdout != "" || output.stderr != "" {
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
}
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md")
}
func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) {
server := dailyServer(t)
var uploadCount int
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/runs/batch-distributor-run" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","pipeline_id":"weatherreporter","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
return
}
if r.URL.Path != "/v1/pipelines/weatherreporter/upload" {
http.NotFound(w, r)
return
}
uploadCount++
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","status":"accepted"}`))
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
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)
}
summary := decodeBatchSummary(t, stdout.String())
if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
}
if len(summary.Reports) != 1 {
t.Fatalf("reports = %#v, want one report", summary.Reports)
}
if uploadCount != 1 {
t.Fatalf("batch upload count = %d, want 1", uploadCount)
}
if summary.Notification == nil || summary.Notification.Status != "succeeded" || summary.Notification.RunID != "batch-distributor-run" {
t.Fatalf("batch notification = %#v, want succeeded batch notification", summary.Notification)
}
if count := strings.Count(stderr.String(), "batchNotification "); count != 1 {
t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String())
}
if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) {
t.Fatalf("stderr missing batch notification success:\n%s", stderr.String())
}
if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationRunID != "" || summary.Reports[0].NotificationPipelineID != "" || summary.Reports[0].NotificationError != "" || summary.Reports[0].NotificationPath != "" {
t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0])
}
if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") {
t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String())
}
if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") {
t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String())
}
}
func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T) {
server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected distributor request %s", r.URL.Path)
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
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)
}
summary := decodeBatchSummary(t, stdout.String())
if summary.Command != "run" || summary.Status != "succeeded" {
t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status)
}
if len(summary.Reports) != 1 {
t.Fatalf("summary reports = %#v, want one report", summary.Reports)
}
if summary.Notification != nil {
t.Fatalf("batch notification = %#v, want omitted when disabled", summary.Notification)
}
if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationError != "" {
t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0])
}
}
func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
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", fixture.configPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "data_package.*.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml")
}
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("daily.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "daily",
"--config", fixture.configPath,
"--date", "2026-05-29",
"--tz", "UTC",
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
reportData, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(reportData), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(reportData))
}
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
data, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || !strings.Contains(string(data), "id: daily") {
t.Fatalf("data package output missing expected content:\n%s", string(data))
}
if !strings.Contains(string(data), "location:") ||
!strings.Contains(string(data), "id: home") ||
!strings.Contains(string(data), "name: Brentwood") ||
!strings.Contains(string(data), "region: St. Louis Metro") ||
!strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
}
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "render.*.json")
preflight, err := os.ReadFile(preflightPath)
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))
}
_ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "render_context.*.json")
metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "metadata.*.json")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
assertFileContains(t, metadataPath, `"generatedTextSchemaId": "daily"`)
}
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("today.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "today",
"--config", fixture.configPath,
"--date", "2026-05-29",
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
reportData, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{
"# Today's Weather",
"Today starts with showers before improving.",
"Morning showers should taper as drier air arrives.",
} {
if !strings.Contains(string(reportData), want) {
t.Fatalf("today report output missing %q:\n%s", want, string(reportData))
}
}
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(dataPackage), "id: today") ||
!strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") ||
!strings.Contains(string(dataPackage), "today_planning:") {
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
}
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
assertFileContains(t, managedReportPath, "# Today's Weather")
summary := decodeGenerateSummary(t, stdout.String())
if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today {
t.Fatalf("generate summary = %#v, want successful Today summary", summary)
}
if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" {
t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary)
}
if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" {
t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary)
}
if summary.OutputPath != outPath {
t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath)
}
}
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("hourly.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{
"generate", "hourly",
"--config", fixture.configPath,
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
reportData, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
if !strings.Contains(string(reportData), want) {
t.Fatalf("report output missing %q:\n%s", want, string(reportData))
}
}
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml")
dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(dataPackage), "id: hourly") ||
!strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") ||
!strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
}
rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text_raw.*.json")
validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json")
renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json")
managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"Report": {`)
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
assertFileContains(t, renderContextPath, `"Modules": {`)
assertFileContains(t, renderContextPath, `"Collected": {`)
assertFileContains(t, renderContextPath, `"Derived": {`)
assertFileContains(t, managedReportPath, "# Hourly Report")
}
func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) {
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
outPath := fixture.path("today.md")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "today",
"--config", fixture.configPath,
"--date", "2026-05-29",
"--out", outPath,
"--quiet",
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if output.stdout != "" || output.stderr != "" {
t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr)
}
assertFileContains(t, outPath, "# Today's Weather")
}
func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want required date error")
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want no partial JSON", stdout.String())
}
}
func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) {
server := dailyServer(t)
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path)
}))
t.Cleanup(distributorServer.Close)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", configPath,
)
if err == nil {
t.Fatal("Run() error = nil, want notification failure")
}
summary := decodeGenerateSummary(t, output.stdout)
if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" {
t.Fatalf("summary = %#v, want failed generate summary", summary)
}
if !strings.Contains(summary.Error, "token environment variable") {
t.Fatalf("summary error = %q, want token environment context", summary.Error)
}
if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" {
t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary)
}
if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") {
t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr)
}
}
func TestRunInspectTodayArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{
"generate", "today",
"--config", fixture.configPath,
"--date", "2026-05-29",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(generate) error = %v", err)
}
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml")
runID := runIDFromDataPackagePath(t, dataPackagePath)
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.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(), `"reportId": "today"`) {
t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String())
}
var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(inspect %s) error = %v", command, err)
}
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") {
t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String())
}
if command == "sources" {
sourcesOutput = stdout.String()
}
}
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
}
}
func TestRunInspectGeneratedArtifacts(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{
"generate", "daily",
"--config", fixture.configPath,
"--date", "2026-05-29",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(generate) error = %v", err)
}
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml")
runID := runIDFromDataPackagePath(t, dataPackagePath)
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.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())
}
var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.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 command == "sources" {
sourcesOutput = stdout.String()
}
}
if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) {
t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput)
}
}
func TestRunInspectMissingMetadata(t *testing.T) {
tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
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 TestRunInspectRejectsQuiet(t *testing.T) {
tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error")
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("error = %q, want unexpected quiet flag", err.Error())
}
}
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "modules", "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: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
{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 TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, name := range report.CommandNames() {
t.Run(name, func(t *testing.T) {
args := []string{name}
if name == report.CommandNameDaily || name == report.CommandNameToday {
args = append(args, "--date", "2026-05-29")
}
if name == report.CommandNameStorm {
args = append(args, "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00")
}
req, err := runner.resolveGenerate(args)
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
want, err := report.IDForCommandName(name)
if err != nil {
t.Fatalf("IDForCommandName() error = %v", err)
}
resolved, err := app.ResolveGenerate(req, req.Now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != want {
t.Fatalf("resolved ID = %q, want %q", resolved.Definition.ID, want)
}
})
}
}
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n")
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != app.ReportHourly {
t.Fatalf("Report = %q, want hourly", req.Report)
}
if req.Config.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
}
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
}
if req.OutputPath != "./hourly.md" {
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
}
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
}
}
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
runner := Runner{Clock: fixedClock()}
for _, args := range [][]string{
{"hourly", "--date", "2026-05-29"},
{"hourly", "--start", "2026-05-29T18:00"},
{"hourly", "--end", "2026-05-29T20:00"},
{"hourly", "--hours", "6"},
{"hourly", "--duration", "6h"},
} {
_, err := runner.resolveGenerate(args)
if err == nil {
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
}
}
}
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want required date error")
}
if !strings.Contains(err.Error(), "generate daily requires --date YYYY-MM-DD") {
t.Fatalf("resolveGenerate() error = %q, want required date context", err.Error())
}
if !req.Date.IsZero() {
t.Fatalf("Date = %s, want unset on error", req.Date)
}
}
func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want date parse error")
}
if !strings.Contains(err.Error(), `parse date "bad-date" as YYYY-MM-DD`) {
t.Fatalf("resolveGenerate() error = %q, want date parse context", err.Error())
}
}
func TestResolveGenerateDailyParsesDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != app.ReportDaily {
t.Fatalf("Report = %q, want daily", req.Report)
}
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
t.Fatalf("Date = %s, want 2026-05-29", got)
}
}
func TestResolveGenerateTodayDate(t *testing.T) {
runner := Runner{Clock: fixedClock()}
defaultReq, err := runner.resolveGenerate([]string{"today"})
if err != nil {
t.Fatalf("resolveGenerate(default) error = %v", err)
}
if defaultReq.Report != app.ReportToday {
t.Fatalf("Report = %q, want today", defaultReq.Report)
}
if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
t.Fatalf("default Date = %s, want 2026-05-29", got)
}
explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"})
if err != nil {
t.Fatalf("resolveGenerate(explicit) error = %v", err)
}
if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" {
t.Fatalf("explicit Date = %s, want 2026-05-30", got)
}
resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.Today {
t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID)
}
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" {
t.Fatalf("valid period start = %s, want explicit UTC date", got)
}
}
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--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 TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) {
runner := Runner{Clock: fixedClock()}
retired := strings.Join([]string{"near", "term"}, "-")
_, err := runner.resolveGenerate([]string{retired})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want unknown report")
}
if !strings.Contains(err.Error(), "unknown generate report") {
t.Fatalf("error = %q, want unknown generate report", err.Error())
}
}
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)}
}
type commandOutput struct {
stdout string
stderr string
}
func runRootCommand(t *testing.T, args ...string) (commandOutput, error) {
t.Helper()
return runTestCommand(t, Runner{}, args...)
}
func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput, error) {
t.Helper()
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), args, &stdout, &stderr)
return commandOutput{
stdout: stdout.String(),
stderr: stderr.String(),
}, err
}
func decodeGenerateSummary(t *testing.T, text string) generateSummary {
t.Helper()
var summary generateSummary
if err := json.Unmarshal([]byte(text), &summary); err != nil {
t.Fatalf("decode generate summary: %v\n%s", err, text)
}
return summary
}
func decodeBatchSummary(t *testing.T, text string) batchSummary {
t.Helper()
var summary batchSummary
if err := json.Unmarshal([]byte(text), &summary); err != nil {
t.Fatalf("decode batch summary: %v\n%s", err, text)
}
return summary
}
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."]}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
type cliFixture struct {
tempDir string
workspaceRoot string
configPath string
}
func newCLIFixture(t *testing.T, writeScriptorium func(*testing.T, string) string) cliFixture {
t.Helper()
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
return cliFixture{
tempDir: tempDir,
workspaceRoot: workspaceRoot,
configPath: writeTestConfig(t, server, scriptoriumPath, workspaceRoot),
}
}
func (f cliFixture) path(name string) string {
return filepath.Join(f.tempDir, name)
}
func writeConfigFile(t *testing.T, body string) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return configPath
}
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
t.Helper()
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
return writeConfigFile(t, configBody)
}
func writeTestConfigWithDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string {
t.Helper()
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
return writeConfigFile(t, configBody)
}
func writeTestConfigWithDisabledBatchDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string {
t.Helper()
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n batch:\n enabled: false\n"
return writeConfigFile(t, configBody)
}
func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string {
t.Helper()
return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n")
}
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 noArtifacts(t *testing.T, root string, parts ...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) != 0 {
t.Fatalf("artifact matches = %#v, want none", matches)
}
}
func runIDFromDataPackagePath(t *testing.T, path string) string {
t.Helper()
base := filepath.Base(path)
runID := strings.TrimSuffix(strings.TrimPrefix(base, "data_package."), ".yaml")
if runID == base || runID == "" {
t.Fatalf("data package path = %q, want data_package.<run_id>.yaml", path)
}
return runID
}
func firstLineWithPrefix(text string, prefix string) string {
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, prefix) {
return line
}
}
return ""
}
func assertFileContains(t *testing.T, path string, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if !strings.Contains(string(data), want) {
t.Fatalf("%s missing %q:\n%s", path, want, string(data))
}
}
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=""
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.today_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Today starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Tomorrow starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.daily_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": [
"A front will keep rain chances in the forecast.",
"Temperatures stay seasonable by afternoon."
],
"precipitation_timing": "Rain is most likely during the afternoon.",
"confidence": "Medium"
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
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 writeStructuredOutputScriptorium(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=""
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.hourly_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": " Storm chances increase through late morning. ",
"forecast_discussion": "A front will keep the region unsettled.",
"precipitation_timing": "A cold front is moving into the region.",
"confidence": "Medium"
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.daily_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Showers are possible during the selected day.",
"forecast_discussion": [
"A front will keep rain chances in the forecast.",
"Temperatures stay seasonable by afternoon."
],
"precipitation_timing": "Rain is most likely during the afternoon.",
"confidence": "Medium"
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.today_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Today starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Tomorrow starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
printf 'wrote generated text\n' >&2
exit 0
fi
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.tomorrow_generated_text" ]; then
printf 'render failed\n' >&2
exit 1
fi
printf '{"ok":true,"prompt":"%s"}' "$prompt"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
elif [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.today_generated_text" ]; then
cat > "$out" <<'JSON'
{
"summary": "Today starts with showers before improving.",
"forecast_discussion": [
"Morning showers should taper as drier air arrives.",
"Afternoon conditions trend quieter."
],
"precipitation_timing": "The best rain chance is during the morning."
}
JSON
exit 0
fi
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
}