diff --git a/examples/config.yml b/examples/config.yml index 1357d0c..226a7c9 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -89,3 +89,18 @@ reports: - weather_story - outdoor_windows - hourly_forecast + hourly: + deterministic_modules: + - metadata + - current_conditions + - hourly_forecast + - precip_timing + - alert_digest + - spc_convective_outlooks + - id: area_forecast_discussion + options: + sections: + - key_messages + - short_term + - spc_convective_discussion + - weather_story diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 96f520f..ee3c366 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -685,6 +685,59 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { } } +func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir) + workspaceRoot := filepath.Join(tempDir, "workspace") + configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot) + outPath := filepath.Join(tempDir, "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", 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) + } + for _, want := range []string{ + "# Hourly Report", + "Storm chances increase through late morning.", + "The main window is 10 AM to noon.", + "Brief downpours may slow travel.", + } { + if !strings.Contains(string(report), want) { + t.Fatalf("report output missing %q:\n%s", want, string(report)) + } + } + dataPackagePath := oneArtifact(t, 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, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json") + validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json") + renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json") + managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md") + assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`) + assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) + assertFileContains(t, renderContextPath, `"ReportTitle": "Hourly Report"`) + assertFileContains(t, managedReportPath, "# Hourly Report") +} + func TestRunInspectGeneratedArtifacts(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() @@ -867,6 +920,8 @@ func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) { {"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 { @@ -1092,6 +1147,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string { return matches[0] } +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") @@ -1122,6 +1188,52 @@ exit 1 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. ", + "timing": "The main window is 10 AM to noon.", + "impacts": "Brief downpours may slow travel.", + "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 writeFailingScriptorium(t *testing.T, dir string) string { t.Helper() path := filepath.Join(dir, "scriptorium") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 794f1c2..9028618 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -91,6 +91,22 @@ func TestLoadExampleConfig(t *testing.T) { if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 { t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates) } + overrides := cfg.ReportModuleOverrides() + hourly := overrides[report.Hourly] + if len(hourly) != 9 { + t.Fatalf("hourly example override length = %d, want 9", len(hourly)) + } + if hourly[0].ID != module.Metadata || + hourly[1].ID != module.CurrentConditions || + hourly[2].ID != module.HourlyForecast || + hourly[3].ID != module.PrecipTiming || + hourly[4].ID != module.AlertDigest || + hourly[5].ID != module.SPCConvectiveOutlooks || + hourly[6].ID != module.AreaForecastDiscussion || + hourly[7].ID != module.SPCConvectiveDiscussion || + hourly[8].ID != module.WeatherStory { + t.Fatalf("hourly example override = %#v, want configured module order", hourly) + } } func TestLoadMinimalExampleConfig(t *testing.T) {