Files
weatherreporter/internal/app/app_test.go

3382 lines
134 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestFetchAndSaveBundle(t *testing.T) {
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":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
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":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
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)
}
}))
defer server.Close()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
path := filepath.Join(t.TempDir(), "bundle.json")
bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path})
if err != nil {
t.Fatalf("FetchAndSaveBundle() error = %v", err)
}
if bundle.Hourly == nil {
t.Fatal("Hourly = nil, want fetched bundle")
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"product": "hourly"`) {
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
}
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
}
}
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
if err == nil {
t.Fatal("FetchAndSaveBundle() error = nil, want output path error")
}
if !strings.Contains(err.Error(), "output path") {
t.Fatalf("error = %q, want output path context", err.Error())
}
}
func TestGenerateUsesProvidedCollector(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
collector := &recordingCollector{err: errors.New("provided collector failed")}
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
if err == nil {
t.Fatal("Generate() error = nil, want collector error")
}
if !strings.Contains(err.Error(), "provided collector failed") {
t.Fatalf("Generate() error = %q, want provided collector error", err.Error())
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want 1", len(collector.requests))
}
if collector.requests[0].Config.WeatherAPI.BaseURL != "" {
t.Fatalf("collector base URL = %q, want request config", collector.requests[0].Config.WeatherAPI.BaseURL)
}
}
func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionForTest(t, cfg)
collector := &recordingCollector{result: &collection}
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nprintf render failed >&2\nexit 1\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
if err == nil {
t.Fatal("Generate() error = nil, want render error")
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want 1", len(collector.requests))
}
if _, statErr := os.Stat(markerPath); statErr != nil {
t.Fatalf("scriptorium marker stat error = %v, want report execution reached renderer", statErr)
}
}
func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nexit 0\n", markerPath)
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
t.Fatalf("write scriptorium marker script: %v", err)
}
cfg.Scriptorium.Binary = binaryPath
collector := &recordingCollector{err: errors.New("collection unavailable")}
err := Generate(context.Background(), GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
})
if err == nil {
t.Fatal("Generate() error = nil, want collector error")
}
if _, statErr := os.Stat(markerPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("scriptorium marker stat error = %v, want marker absent after collection failure", statErr)
}
}
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stdout: `{"prepared":true}`,
ExitCode: 0,
},
runResult: &scriptorium.RunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote report",
ExitCode: 0,
OutputPath: "",
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
runBody: "# Daily Report\n\nRain this morning.\n",
}
outputPath := filepath.Join(t.TempDir(), "daily.md")
store := recordingFilesystemStore(t, cfg)
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.renderRequest.PromptID != "weather.daily_generated_text" {
t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID)
}
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
}
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want raw generated text path %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
}
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath)
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil {
t.Fatalf("read module snapshot: %v", err)
}
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
}
for _, want := range []string{`"condition_text_lower"`, `"hour_label"`, `"text_description_lower"`, `"mention_precipitation"`} {
if !strings.Contains(string(snapshotData), want) {
t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData))
}
}
for _, want := range []string{`"temperature_phrase_f"`, `"dominant_condition_lower"`, `"dominant_condition_display"`, `"max_pop_time_label"`} {
if !strings.Contains(string(snapshotData), want) {
t.Fatalf("module snapshot missing rich daypart helper field %q:\n%s", want, string(snapshotData))
}
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
}
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") ||
!strings.Contains(string(data), "recent_changes:") ||
!strings.Contains(string(data), "applicable_risk_products:") ||
!strings.Contains(string(data), "derived_summaries:") ||
!strings.Contains(string(data), "narrative_products:") ||
!strings.Contains(string(data), "raw_data:") ||
!strings.Contains(string(data), "current_conditions:") ||
!strings.Contains(string(data), "narrative_forecast:") ||
!strings.Contains(string(data), "hourly_forecast:") ||
!strings.Contains(string(data), "area_forecast_discussion:") ||
!strings.Contains(string(data), "spc_convective_outlooks:") {
t.Fatalf("data package missing expected content:\n%s", string(data))
}
if strings.Contains(string(data), "spc_convective_discussion:") {
t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data))
}
if strings.Contains(string(data), "source_warnings:") {
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
}
riskIndex := strings.Index(string(data), " applicable_risk_products:")
derivedIndex := strings.Index(string(data), " derived_summaries:")
narrativeIndex := strings.Index(string(data), " narrative_products:")
rawIndex := strings.Index(string(data), " raw_data:")
alertIndex := strings.Index(string(data), " alert_digest:")
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
storyIndex := strings.Index(string(data), " weather_story:")
currentIndex := strings.Index(string(data), " current_conditions:")
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
outlookIndex := strings.Index(string(data), " spc_convective_outlooks:")
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
}
savedDataPackage, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("decode data package: %v", err)
}
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
}
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
t.Fatal("data package metadata stanza missing")
}
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
}
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
if !ok || current["condition_text"] != "Clear" {
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
}
for _, omitted := range []string{"condition_text_lower", "wind_direction_text"} {
if _, ok := current[omitted]; ok {
t.Fatalf("data package current conditions contains helper field %q: %#v", omitted, current)
}
}
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
}
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
}
periods, ok := hourly["periods"].([]any)
if !ok || len(periods) == 0 {
t.Fatalf("data package hourly periods = %#v, want prompt period rows", hourly["periods"])
}
firstPeriod, ok := periods[0].(map[string]any)
if !ok {
t.Fatalf("data package hourly first period = %#v, want mapping", periods[0])
}
for _, omitted := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if _, ok := firstPeriod[omitted]; ok {
t.Fatalf("data package hourly period contains helper field %q: %#v", omitted, firstPeriod)
}
}
dayparts, ok := savedDataPackage.Briefing.Values["derived_daypart_summaries"].(map[string]any)
if !ok {
t.Fatalf("data package daypart summaries = %#v, want daypart map", savedDataPackage.Briefing.Values["derived_daypart_summaries"])
}
morning, ok := dayparts["morning"].(map[string]any)
if !ok {
t.Fatalf("data package morning daypart = %#v, want daypart map", dayparts["morning"])
}
if morning["max_pop_time"] != "6:00 AM" {
t.Fatalf("data package morning max_pop_time = %#v, want friendly label", morning["max_pop_time"])
}
for _, omitted := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
if _, ok := morning[omitted]; ok {
t.Fatalf("data package morning daypart contains helper field %q: %#v", omitted, morning)
}
}
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
}
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
}
preflight, err := os.ReadFile(result.PreflightPath)
if err != nil {
t.Fatalf("read preflight: %v", err)
}
if !strings.Contains(string(preflight), `prepared`) {
t.Fatalf("preflight output missing render stdout:\n%s", string(preflight))
}
if result.Metadata.RunID != resolved.Metadata().RunID {
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
}
if result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath {
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
}
if result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges)
}
report, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read report output: %v", err)
}
if !strings.Contains(string(report), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
}
func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testing.T) {
server := dailyBundleServer(t)
tests := []struct {
name string
kind ReportKind
date time.Time
now time.Time
prompt string
}{
{
name: "today",
kind: ReportToday,
date: mustParse("2026-05-29T12:00:00-05:00"),
now: mustParse("2026-05-29T05:00:00-05:00"),
prompt: "weather.today_generated_text",
},
{
name: "tomorrow",
kind: ReportTomorrow,
now: mustParse("2026-05-29T18:00:00-05:00"),
prompt: "weather.tomorrow_generated_text",
},
{
name: "daily",
kind: ReportDaily,
date: mustParse("2026-05-29T12:00:00-05:00"),
now: mustParse("2026-05-29T05:00:00-05:00"),
prompt: "weather.daily_generated_text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: tt.kind,
Date: tt.date,
}, tt.now.Format(time.RFC3339))
renderer := successfulGeneratedTextRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("renderer calls render=%d structured=%d run=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
}
if renderer.renderRequest.PromptID != tt.prompt || renderer.structuredRunRequest.PromptID != tt.prompt {
t.Fatalf("prompt IDs render=%q structured=%q, want %q", renderer.renderRequest.PromptID, renderer.structuredRunRequest.PromptID, tt.prompt)
}
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
}
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
assertRichPromptHelperArtifacts(t, result)
assertCuratedPromptDataPackage(t, result)
})
}
}
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok {
t.Fatal("module snapshot missing spc_convective_discussion stanza")
}
data := readDataPackageForTest(t, result)
text := string(data)
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
for _, want := range []string{
" spc_convective_outlooks:",
" spc_convective_discussion:",
" included_because: categorical severity_rank >= 3",
" label_text: Slight Risk",
" period_begins:",
" period_ends:",
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
} {
if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text)
}
}
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:")
alertIndex := strings.Index(text, " alert_digest:")
outlookIndex := strings.Index(text, " spc_convective_outlooks:")
derivedIndex := strings.Index(text, " derived_summaries:")
narrativeIndex := strings.Index(text, " narrative_products:")
forecastIndex := strings.Index(text, " narrative_forecast:")
afdIndex := strings.Index(text, " area_forecast_discussion:")
discussionIndex := strings.Index(text, " spc_convective_discussion:")
storyIndex := strings.Index(text, " weather_story:")
rawIndex := strings.Index(text, " raw_data:")
if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 ||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) ||
!(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) {
t.Fatalf("data package category order is wrong:\n%s", text)
}
loaded, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("LoadYAML() error = %v", err)
}
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza")
}
if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok {
t.Fatal("loaded package missing spc_convective_discussion stanza")
}
}
func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) {
server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse)
cfg := dailyTestConfig(t, server)
result := generateDailyReportForTest(t, cfg)
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
}
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok {
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
}
text := string(readDataPackageForTest(t, result))
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") {
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
}
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
}
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
}
}
func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyTestConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00")
if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode)
}
store := recordingFilesystemStore(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stdout: `{"prepared":true}`,
ExitCode: 0,
},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run"},
Stderr: "wrote generated text",
ExitCode: 0,
},
structuredRunBody: `{
"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"
}`,
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderCalls != 1 {
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
t.Fatalf("structured run DataPackagePath = %q, want %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
}
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; got != want {
t.Fatalf("store calls = %v, want %s", store.calls, want)
}
assertPathsExist(t,
result.ModuleSnapshotPath,
result.DataPackagePath,
result.PreflightPath,
result.GeneratedTextRawPath,
result.GeneratedTextResultPath,
result.GeneratedTextPath,
result.RenderContextPath,
result.ReportPath,
result.MetadataPath,
)
raw, err := os.ReadFile(result.GeneratedTextRawPath)
if err != nil {
t.Fatalf("read raw generated text: %v", err)
}
if !strings.Contains(string(raw), `"summary": " Storm chances increase through late morning. "`) {
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
}
normalized, err := os.ReadFile(result.GeneratedTextPath)
if err != nil {
t.Fatalf("read validated generated text: %v", err)
}
if string(normalized) != `{"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"}` {
t.Fatalf("validated generated text = %s, want normalized JSON", normalized)
}
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
for _, want := range []string{
`"Report": {`,
`"Title": "Hourly Report"`,
`"LocationName": "Brentwood, MO"`,
`"GeneratedText": {`,
`"Modules": {`,
`"CurrentConditions": {`,
`"HourlyForecast": {`,
`"Collected": {`,
`"Derived": {`,
} {
if !strings.Contains(string(renderContext), want) {
t.Fatalf("render context missing %q:\n%s", want, string(renderContext))
}
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
reportText := string(reportData)
for _, want := range []string{
"# Hourly Report",
"Storm chances increase through late morning.",
"## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.",
"## Precipitation Timing",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
if !strings.Contains(reportText, want) {
t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText)
}
}
if result.OutputPath != result.ReportPath {
t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath)
}
if result.StructuredRunResult == nil || result.StructuredRunResult.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("StructuredRunResult = %#v, want captured structured run result", result.StructuredRunResult)
}
if result.RunResult != nil {
t.Fatalf("RunResult = %#v, want nil for generated-text template workflow", result.RunResult)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges)
}
if result.Metadata.GeneratedTextSchemaID != "hourly" ||
result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath ||
result.Metadata.GeneratedTextResultPath != result.GeneratedTextResultPath ||
result.Metadata.GeneratedTextPath != result.GeneratedTextPath ||
result.Metadata.RenderContextPath != result.RenderContextPath ||
result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata generated-text links = %#v, want saved artifact links", result.Metadata)
}
metadataData, err := os.ReadFile(result.MetadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if !strings.Contains(string(metadataData), `"generatedTextSchemaId": "hourly"`) ||
!strings.Contains(string(metadataData), result.GeneratedTextRawPath) ||
!strings.Contains(string(metadataData), result.RenderContextPath) {
t.Fatalf("metadata JSON missing generated-text links:\n%s", string(metadataData))
}
}
func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.result = successfulNotificationResult()
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("output copy differs from managed report")
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportPath != result.ReportPath {
t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
wantBundlePaths := []string{"2026-05-29/hourly/hourly.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.PipelineID != "weatherreporter.hourly" {
t.Fatalf("notification PipelineID = %q, want weatherreporter.hourly", req.PipelineID)
}
if req.BundleID != "weatherreporter.home.hourly" {
t.Fatalf("notification BundleID = %q, want weatherreporter.home.hourly", req.BundleID)
}
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
t.Fatalf("notification IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
}
if result.Notification == nil || result.Notification.RunID != "distributor-run" {
t.Fatalf("Notification = %#v, want distributor result", result.Notification)
}
if result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("metadata NotificationPath = %q, want %q", result.Metadata.NotificationPath, result.NotificationPath)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notificationArtifact.SourcePath != result.ReportPath || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.RunStatus == nil {
t.Fatalf("notification artifact = %#v, want managed source and run status", notificationArtifact)
}
}
func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
outputPath := filepath.Join(t.TempDir(), "today-copy.md")
notifier := &recordingNotifier{
result: successfulNotificationResult(),
}
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.OutputPath != outputPath {
t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath)
}
assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath)
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("output copy differs from managed report")
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportID != report.Today {
t.Fatalf("notification ReportID = %q, want today", req.ReportID)
}
if req.ReportPath != result.ReportPath {
t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
if req.PipelineID != "weatherreporter.today.today" {
t.Fatalf("PipelineID = %q, want Today report and artifact values", req.PipelineID)
}
if req.BundleID != "today.today.md.today" {
t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID)
}
wantBundlePaths := []string{"2026-05-29/today/today.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notificationArtifact.ReportID != report.Today ||
notificationArtifact.PipelineID != "weatherreporter.today.today" ||
notificationArtifact.BundleID != "today.today.md.today" ||
notificationArtifact.SourcePath != result.ReportPath ||
strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") ||
notificationArtifact.RunStatus == nil ||
!strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
t.Fatalf("notification artifact = %#v, want Today managed-source notification", notificationArtifact)
}
}
func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.err = errors.New("upload rejected")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
}
var notificationErr *NotificationError
if !errors.As(err, &notificationErr) {
t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err)
}
if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want hourly notification context", err.Error())
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.RenderedReport, outputPath, paths.Metadata, paths.Notification)
if notifier.requests[0].ReportPath != paths.RenderedReport {
t.Fatalf("notification ReportPath = %q, want managed report path %q", notifier.requests[0].ReportPath, paths.RenderedReport)
}
notificationData, readErr := os.ReadFile(paths.Notification)
if readErr != nil {
t.Fatalf("read notification artifact after failure: %v", readErr)
}
var notification state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notification); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") || notification.SourcePath != paths.RenderedReport {
t.Fatalf("notification failure artifact = %+v, want failed managed-source context", notification)
}
}
func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *testing.T) {
t.Run("Markdown", func(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportThreeDay,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# 3-Day Outlook\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "" || saved.GeneratedTextPath != "" || saved.RenderContextPath != "" {
t.Fatalf("saved markdown metadata has generated-text fields: %#v", saved)
}
})
t.Run("GeneratedTextTemplate", func(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Notify.Distributor.Enabled = false
resolved, store, _, _ := resolveHourlyGeneratedTextFixture(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
}
})
}
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, "2026-05-29T18:00:00-05:00")
notifier := &recordingNotifier{}
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportID != report.Tomorrow {
t.Fatalf("notification ReportID = %q, want tomorrow", req.ReportID)
}
if req.PipelineID != "weatherreporter.tomorrow.tomorrow" {
t.Fatalf("PipelineID = %q, want report/artifact group values", req.PipelineID)
}
if req.BundleID != "tomorrow.tomorrow.md.tomorrow" {
t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID)
}
wantBundlePaths := []string{"2026-05-30/tomorrow/tomorrow.md"}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.ReportPath != result.ReportPath {
t.Fatalf("ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
}
}
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stderr: "render failed",
ExitCode: 1,
},
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "render preflight")
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
t.Fatalf("post-preflight calls structured=%d run=%d, want none", renderer.structuredRunCalls, renderer.runCalls)
}
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata)
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
preflight, readErr := os.ReadFile(paths.Preflight)
if readErr != nil {
t.Fatalf("read failed preflight: %v", readErr)
}
if !strings.Contains(string(preflight), `"exitCode": 1`) || !strings.Contains(string(preflight), "render failed") {
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
}
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.Preflight) || !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
t.Fatalf("metadata missing failed-run artifact links:\n%s", string(metadataData))
}
}
func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{
Command: []string{"scriptorium", "run", "--json"},
Stderr: "generation failed",
ExitCode: 2,
},
structuredRunErr: errors.New("scriptorium structured run exited with code 2: generation failed"),
structuredRunBody: validHourlyGeneratedTextJSON(),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "structured generated text")
if renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls structured=%d run=%d, want one structured run and no markdown run", renderer.structuredRunCalls, renderer.runCalls)
}
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) || !strings.Contains(string(metadataData), paths.GeneratedTextResult) {
t.Fatalf("metadata missing structured failure links:\n%s", string(metadataData))
}
}
func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := successfulGeneratedTextRenderer(`{
"summary": "Storm chances increase through late morning.",
"forecast_discussion": "A front will keep the region unsettled.",
"details": "not allowed"
}`)
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "validate generated text")
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
raw, readErr := os.ReadFile(paths.GeneratedTextRaw)
if readErr != nil {
t.Fatalf("read raw generated text: %v", readErr)
}
if !strings.Contains(string(raw), `"details": "not allowed"`) {
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
}
}
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
resolved.Definition.TemplateID = "missing-template"
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
Store: store,
Notifier: notifier,
})
assertGeneratedReportError(t, err, resolved, "lookup generated text catalog")
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
t.Fatalf("calls structured=%d run=%d, want no generated text run", renderer.structuredRunCalls, renderer.runCalls)
}
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
paths := hourlyArtifactPaths(t, store, resolved)
assertPathsExist(t, paths.Preflight, paths.Metadata)
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
metadataData, readErr := os.ReadFile(paths.Metadata)
if readErr != nil {
t.Fatalf("read metadata: %v", readErr)
}
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
t.Fatalf("metadata missing generated text artifact path:\n%s", string(metadataData))
}
}
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests)
}
}
func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{
result: successfulNotificationResult(),
}
outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: successfulRenderer("# Daily Report\n"),
Notifier: notifier,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.Notification == nil {
t.Fatal("Notification = nil, want notification result")
}
if result.Notification.RunID != "distributor-run" || result.Notification.Status != "succeeded" {
t.Fatalf("Notification = %#v, want succeeded distributor run", result.Notification)
}
if result.NotificationPath == "" || result.Metadata.NotificationPath != result.NotificationPath {
t.Fatalf("NotificationPath result=%q metadata=%q, want linked artifact", result.NotificationPath, result.Metadata.NotificationPath)
}
notificationData, err := os.ReadFile(result.NotificationPath)
if err != nil {
t.Fatalf("read notification artifact: %v", err)
}
var notificationArtifact state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notificationArtifact); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
wantBundlePaths := []string{
"2026-05-29/daily/2026-05-29-daily-" + result.Metadata.RunID + ".md",
}
if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact)
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
}
req := notifier.requests[0]
if req.ReportPath != result.ReportPath {
t.Fatalf("notification ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
}
if req.ReportPath == outputPath {
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
}
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
}
if req.PipelineID != "weatherreporter.daily" {
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
}
if req.BundleID != "weatherreporter.home.daily" {
t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
}
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
t.Fatalf("IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
}
if req.RunID != result.Metadata.RunID {
t.Fatalf("notification RunID = %q, want report run id %q", req.RunID, result.Metadata.RunID)
}
if !req.CreatedAt.Equal(result.Metadata.GeneratedAt) {
t.Fatalf("notification CreatedAt = %s, want generated at %s", req.CreatedAt, result.Metadata.GeneratedAt)
}
}
func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{err: errors.New("upload rejected")}
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error")
}
if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") {
t.Fatalf("error = %q, want notification context", err.Error())
}
if len(notifier.requests) != 1 {
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
}
paths, pathErr := store.Paths(resolved)
if pathErr != nil {
t.Fatalf("Paths() error = %v", pathErr)
}
notificationData, readErr := os.ReadFile(paths.Notification)
if readErr != nil {
t.Fatalf("read notification artifact after failure: %v", readErr)
}
var notification state.DistributorNotificationArtifact
if err := json.Unmarshal(notificationData, &notification); err != nil {
t.Fatalf("decode notification artifact: %v", err)
}
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") {
t.Fatalf("notification failure artifact = %+v, want failed upload context", notification)
}
}
func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
server := dailyBundleServer(t)
tests := []struct {
name string
renderer Renderer
}{
{
name: "Render",
renderer: &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"},
err: errors.New("render failed"),
},
},
{
name: "Run",
renderer: &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"},
structuredRunErr: errors.New("run failed"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: tt.renderer,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want generation error")
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests)
}
})
}
}
func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
Notifier: notifier,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want collected bundle error")
}
if !strings.Contains(err.Error(), "collected weather bundle is required") {
t.Fatalf("GenerateReport() error = %q, want collected bundle context", err.Error())
}
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests)
}
if len(store.calls) != 0 {
t.Fatalf("state calls = %#v, want no state writes without collected data", store.calls)
}
}
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
Stderr: "render failed",
ExitCode: 1,
},
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want render error")
}
store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
preflightPath := paths.Preflight
preflight, readErr := os.ReadFile(preflightPath)
if readErr != nil {
t.Fatalf("read failed preflight: %v", readErr)
}
if !strings.Contains(string(preflight), `"exitCode": 1`) {
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
}
if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
}
if renderer.runCalls != 0 {
t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls)
}
}
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{
Stderr: "validation failed",
ExitCode: 2,
},
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
}
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err == nil {
t.Fatal("GenerateReport() error = nil, want run error")
}
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
}
store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
if _, err := os.Stat(paths.Metadata); err != nil {
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
}
if _, err := os.Stat(paths.GeneratedTextRaw); err != nil {
t.Fatalf("expected raw generated text from failed run %q: %v", paths.GeneratedTextRaw, err)
}
if _, err := os.Stat(paths.RenderedReport); !os.IsNotExist(err) {
t.Fatalf("rendered report exists after failed generated-text run: %v", err)
}
}
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") {
t.Fatalf("data package missing recent changes:\n%s", string(data))
}
}
func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 {
t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls)
}
if result.Metadata.ReportID != report.Today || result.Metadata.Variant != "today" || result.Metadata.GeneratedTextSchemaID != "today" {
t.Fatalf("metadata = %#v, want Today generated-text metadata", result.Metadata)
}
if !strings.Contains(result.Metadata.RunID, "_today") {
t.Fatalf("RunID = %q, want Today report ID suffix", result.Metadata.RunID)
}
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "today", "2026-05-29")) {
t.Fatalf("DataPackagePath = %q, want Today artifact group", result.DataPackagePath)
}
if !strings.Contains(result.ReportPath, filepath.Join("reports", "today")) {
t.Fatalf("ReportPath = %q, want Today report group", result.ReportPath)
}
if _, ok := result.ModuleSnapshot.LookupStanza("today_planning"); !ok {
t.Fatal("today_planning stanza missing")
}
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); ok {
t.Fatal("tomorrow_planning stanza present, want Today-specific planning")
}
if result.PriorSnapshot == nil || len(result.RecentChanges) == 0 {
t.Fatalf("prior=%#v recentChanges=%#v, want Today daily comparison changes", result.PriorSnapshot, result.RecentChanges)
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
for _, want := range []string{"id: today", "prompt_id: weather.today_generated_text", "today_planning:", "recent_changes:"} {
if !strings.Contains(string(data), want) {
t.Fatalf("data package missing %q:\n%s", want, string(data))
}
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{"# Today's Weather", "Today starts with showers before improving."} {
if !strings.Contains(string(reportData), want) {
t.Fatalf("today report missing %q:\n%s", want, string(reportData))
}
}
}
func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.runCalls != 0 {
t.Fatalf("markdown run calls = %d, want none", renderer.runCalls)
}
if renderer.structuredRunCalls != 1 {
t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls)
}
if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath {
t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath)
}
if result.Metadata.ReportID != report.Tomorrow || result.Metadata.Variant != "tomorrow" {
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
}
if result.Metadata.GeneratedTextSchemaID != "tomorrow" || result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderedReportPath != result.ReportPath {
t.Fatalf("metadata generated-text links = %#v, want tomorrow generated-text artifacts", result.Metadata)
}
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
if err != nil {
t.Fatalf("decode daily summary: %v", err)
}
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
}
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
t.Fatal("tomorrow_planning stanza missing")
}
if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") {
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
}
assertPathsExist(t, result.GeneratedTextRawPath, result.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath)
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
for _, want := range []string{`"Title": "Saturday's Weather"`, `"GeneratedText": {`, `"forecast_discussion": [`, `"Dayparts": [`} {
if !strings.Contains(string(renderContext), want) {
t.Fatalf("render context missing %q:\n%s", want, string(renderContext))
}
}
reportData, err := os.ReadFile(result.ReportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
for _, want := range []string{"# Saturday's Weather", "## Daypart Forecast", "## Forecast Discussion", "Tomorrow starts with showers before improving."} {
if !strings.Contains(string(reportData), want) {
t.Fatalf("tomorrow report missing %q:\n%s", want, string(reportData))
}
}
}
func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, "2026-05-29T17:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot")
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from compatible prior tomorrow snapshot")
}
}
func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, "2026-05-28T18:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.PriorSnapshot != nil {
t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot)
}
if len(result.RecentChanges) != 0 {
t.Fatalf("RecentChanges = %#v, want none from incompatible prior tomorrow snapshot", result.RecentChanges)
}
}
func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# 3-Day Outlook\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
}
if !ok || len(dayparts) == 0 {
t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
}
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot")
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot")
}
}
func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Weekend Outlook\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: currentResolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
if err != nil {
t.Fatalf("decode daypart summaries: %v", err)
}
if !ok || len(dayparts) == 0 {
t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
}
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
}
if result.PriorSnapshot == nil {
t.Fatal("PriorSnapshot = nil, want prior weekend snapshot")
}
if len(result.RecentChanges) == 0 {
t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot")
}
}
func TestGenerateStormReportWritesReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportStorm,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T10:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Storm Report\n",
}
outputPath := filepath.Join(t.TempDir(), "storm.md")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if renderer.renderRequest.PromptID != "weather.storm_report" {
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
}
if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
t.Fatal("precip_timing stanza missing")
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
if !strings.Contains(string(data), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
t.Fatalf("data package missing storm content:\n%s", string(data))
}
}
func TestInspectGeneratedReportArtifacts(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1})
if err != nil {
t.Fatalf("InspectReports() error = %v", err)
}
if len(records) != 1 || records[0].RunID != result.Metadata.RunID {
t.Fatalf("records = %#v, want generated run", records)
}
metadata, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil {
t.Fatalf("InspectMetadata() error = %v", err)
}
if metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
}
moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil {
t.Fatalf("InspectModules() error = %v", err)
}
if moduleSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(moduleSnapshot.Outputs) == 0 {
t.Fatalf("module snapshot = %#v, want persisted outputs", moduleSnapshot)
}
dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil {
t.Fatalf("InspectDataPackage() error = %v", err)
}
if dataPackage.RunID != result.Metadata.RunID {
t.Fatalf("data package RunID = %q, want %q", dataPackage.RunID, result.Metadata.RunID)
}
sources, err := InspectSources(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
if err != nil {
t.Fatalf("InspectSources() error = %v", err)
}
if len(sources.Sources) == 0 {
t.Fatalf("sources = %#v, want provenance", sources)
}
if len(sources.Warnings) != 0 {
t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings)
}
}
func TestInspectPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T04:00:00-05:00")
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n",
}
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
t.Fatalf("GenerateReport(prior) error = %v", err)
}
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store})
if err != nil {
t.Fatalf("GenerateReport(current) error = %v", err)
}
prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID})
if err != nil {
t.Fatalf("InspectPriorSnapshot() error = %v", err)
}
if prior == nil || prior.Metadata.RunID != priorResolved.Metadata().RunID {
t.Fatalf("prior = %#v, want previous generated run", prior)
}
}
func TestInspectMissingMetadata(t *testing.T) {
cfg := config.Defaults()
cfg.Workspace.Root = t.TempDir()
_, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: "missing"})
if err == nil {
t.Fatal("InspectMetadata() 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 TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T08:00:00-05:00")
tests := []struct {
name string
kind ReportKind
wantID report.ID
wantPrompt string
wantStart string
wantEnd string
requestDate time.Time
}{
{
name: "tomorrow",
kind: ReportTomorrow,
wantID: report.Tomorrow,
wantPrompt: "weather.tomorrow_generated_text",
wantStart: "2026-05-30T00:00:00-05:00",
wantEnd: "2026-05-31T00:00:00-05:00",
requestDate: time.Time{},
},
{
name: "hourly",
kind: ReportHourly,
wantID: report.Hourly,
wantPrompt: "weather.hourly_generated_text",
wantStart: "2026-05-29T08:00:00-05:00",
wantEnd: "2026-05-29T14:00:00-05:00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: tt.kind,
Date: tt.requestDate,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != tt.wantID {
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
}
if resolved.Definition.PromptID != tt.wantPrompt {
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
}
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
}
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
}
})
}
}
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
_, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
}, mustParse("2026-05-29T08:00:00-05:00"))
if err == nil {
t.Fatal("ResolveGenerate() error = nil, want required date error")
}
if !strings.Contains(err.Error(), "requires an explicit date") {
t.Fatalf("ResolveGenerate() error = %q, want required date context", err.Error())
}
}
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(`
reports:
tomorrow:
deterministic_modules:
- metadata
- alert_digest
- tomorrow_planning
`), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
now := mustParse("2026-05-29T18:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportTomorrow,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning}
if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") {
t.Fatalf("ModuleIDs() = %#v, want %#v", got, want)
}
}
func TestResolveGenerateRejectsInvalidProgrammaticReportOverrides(t *testing.T) {
cfg := config.Defaults()
cfg.Reports = map[string]config.ReportConfig{
"moon": {},
}
_, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err == nil {
t.Fatal("ResolveGenerate() error = nil, want report override error")
}
if !strings.Contains(err.Error(), "reports.moon") {
t.Fatalf("ResolveGenerate() error = %q, want report override context", err.Error())
}
}
func moduleIDsForTest(ids []module.ID) []string {
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, string(id))
}
return out
}
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
ids := make([]module.ID, 0, len(snapshot.Outputs))
for _, output := range snapshot.Outputs {
ids = append(ids, output.ID)
}
return ids
}
func mustMarshalString(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal value: %v", err)
}
return string(data)
}
func dailyBundleServer(t *testing.T) *httptest.Server {
t.Helper()
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
}
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *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","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
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":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
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(convectiveResponse))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
func hourlyBundleServer(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-29T13:20:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
case "/forecast/hourly":
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","instruction":"Avoid low-water crossings.","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`))
case "/weatherstories/latest":
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Hourly Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/hourly"}}`))
case "/outlooks/convective":
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-hourly","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"hourly severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the hourly window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
func TestResolveGenerateStorm(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
now := mustParse("2026-05-29T12:00:00-05:00")
start := mustParse("2026-05-29T18:00:00-05:00")
end := mustParse("2026-05-30T06:00:00-05:00")
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: start,
StormEnd: end,
}, now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
if resolved.Definition.ID != report.Storm {
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
}
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
}
}
func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) {
tests := []struct {
name string
startedAt time.Time
batch BatchKind
want string
}{
{
name: "morning",
startedAt: mustParse("2026-05-29T05:00:00-05:00"),
batch: BatchMorning,
want: "20260529T100000.000000000Z_morning",
},
{
name: "evening",
startedAt: mustParse("2026-05-29T18:30:45-05:00"),
batch: BatchEvening,
want: "20260529T233045.000000000Z_evening",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := batchRunID(tt.startedAt, tt.batch); got != tt.want {
t.Fatalf("batchRunID() = %q, want %q", got, tt.want)
}
})
}
}
func TestRenderBatchNotificationIdentity(t *testing.T) {
cfg := config.Defaults()
cfg.Location.ID = "home"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch_started_date}"
cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}"
cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}"
startedAt := mustParse("2026-05-30T03:30:00Z")
runID := batchRunID(startedAt, BatchEvening)
identity, err := renderBatchNotificationIdentity(cfg, BatchEvening, runID, startedAt)
if err != nil {
t.Fatalf("renderBatchNotificationIdentity() error = %v", err)
}
if identity.PipelineID != "weatherreporter.2026-05-29" {
t.Fatalf("PipelineID = %q, want local batch date", identity.PipelineID)
}
if identity.BundleID != "weatherreporter.home.evening" {
t.Fatalf("BundleID = %q, want rendered bundle id", identity.BundleID)
}
wantKey := "weatherreporter.home.evening.20260530T033000.000000000Z_evening"
if identity.IdempotencyKey != wantKey {
t.Fatalf("IdempotencyKey = %q, want %q", identity.IdempotencyKey, wantKey)
}
}
func TestBatchResultJSONOmitsNilNotification(t *testing.T) {
data, err := json.Marshal(BatchResult{
Batch: BatchMorning,
Reports: []BatchReportResult{},
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Contains(string(data), "notification") {
t.Fatalf("BatchResult JSON = %s, want no notification field", data)
}
}
func TestBatchResultJSONIncludesNotification(t *testing.T) {
result := BatchResult{
Batch: BatchEvening,
Notification: &BatchNotificationResult{
Status: "accepted",
RunID: "distributor-run",
PipelineID: "weatherreporter",
BundleID: "weatherreporter.home.evening",
IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening",
Path: "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json",
IncludedReports: []BatchNotificationReport{
{
ReportID: report.Tomorrow,
RunID: "20260529T233000.000000000Z_tomorrow",
SourcePath: "reports/tomorrow.md",
BundlePaths: []string{"tomorrow/index.md"},
},
},
},
Reports: []BatchReportResult{},
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, want := range []string{
`"notification":{`,
`"status":"accepted"`,
`"runId":"distributor-run"`,
`"pipelineId":"weatherreporter"`,
`"bundleId":"weatherreporter.home.evening"`,
`"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`,
`"path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json"`,
`"includedReports":[`,
`"reportId":"tomorrow"`,
`"sourcePath":"reports/tomorrow.md"`,
`"bundlePaths":["tomorrow/index.md"]`,
} {
if !strings.Contains(string(data), want) {
t.Fatalf("BatchResult JSON = %s, want %s", data, want)
}
}
}
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
renderer := &selectiveRenderer{
failRenderPrompt: "weather.tomorrow_generated_text",
runBody: "# Batch Report\n",
}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: renderer,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests))
}
if renderer.runCalls != 0 || renderer.structuredRunCalls != 2 {
t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
}
var failedTomorrow bool
var succeededDaily bool
for _, item := range result.Reports {
if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedTomorrow = true
}
if item.ReportID == report.Daily && item.Status == "succeeded" {
succeededDaily = true
}
if item.ReportID != report.Tomorrow && item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
}
}
if !failedTomorrow {
t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports)
}
if !succeededDaily {
t.Fatalf("reports = %#v, want Daily report to continue after Tomorrow failure", result.Reports)
}
}
func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
notifier := &recordingNotifier{
errByReport: map[report.ID]error{
report.Tomorrow: errors.New("distributor unavailable"),
},
}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
Notifier: notifier,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests))
}
if len(notifier.requests) != 3 {
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
}
var failedTomorrow bool
for _, item := range result.Reports {
if item.ReportID == report.Tomorrow {
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
failedTomorrow = true
}
if item.NotificationStatus != "failed" {
t.Fatalf("Tomorrow notification status = %q, want failed", item.NotificationStatus)
}
if item.NotificationPipelineID != "weatherreporter.tomorrow" {
t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID)
}
if !strings.Contains(item.NotificationError, "distributor unavailable") {
t.Fatalf("Tomorrow notification error = %q, want distributor unavailable", item.NotificationError)
}
continue
}
if item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
}
if item.NotificationStatus != "accepted" {
t.Fatalf("report %s notification status = %q, want accepted", item.ReportID, item.NotificationStatus)
}
if item.NotificationPipelineID == "" {
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
}
}
if !failedTomorrow {
t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports)
}
cfg.Workspace.Root = t.TempDir()
collector = &recordingCollector{result: &collection}
err = RunBatch(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: &selectiveRenderer{
runBody: "# Batch Report\n",
},
Notifier: &recordingNotifier{
errByReport: map[report.ID]error{
report.Tomorrow: errors.New("distributor unavailable"),
},
},
})
var batchErr BatchError
if !errors.As(err, &batchErr) {
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err)
}
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 {
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
}
}
func TestRunBatchUsesOutputDirectory(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchEvening,
Now: mustParse("2026-05-29T18:00:00-05:00"),
OutputDir: outputDir,
Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 2 {
t.Fatalf("summary = %#v, want two successful reports", result)
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests))
}
wantByReport := map[report.ID]string{
report.Tomorrow: filepath.Join(outputDir, "tomorrow.md"),
report.Daily: filepath.Join(outputDir, "daily-2026-05-31.md"),
}
for _, item := range result.Reports {
want := wantByReport[item.ReportID]
if want == "" {
t.Fatalf("unexpected report item = %#v", item)
}
if item.OutputPath != want {
t.Fatalf("%s OutputPath = %q, want %q", item.ReportID, item.OutputPath, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected output copy %q: %v", want, err)
}
reportData, err := os.ReadFile(item.ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(want)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("batch output copy differs from managed report for %s", item.ReportID)
}
}
}
func TestRunBatchDynamicDailyReportsHaveDistinctIdentity(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31", "2026-06-01")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
notifier := &recordingNotifier{}
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchEvening,
Now: mustParse("2026-05-29T18:00:00-05:00"),
OutputDir: outputDir,
Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
Notifier: notifier,
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 3 {
t.Fatalf("summary = %#v, want three successful reports", result)
}
if len(notifier.requests) != 3 {
t.Fatalf("notification requests = %d, want one per report", len(notifier.requests))
}
dailyByDate := map[string]BatchReportResult{}
runIDs := map[string]struct{}{}
reportPaths := map[string]struct{}{}
metadataPaths := map[string]struct{}{}
dataPackagePaths := map[string]struct{}{}
for _, item := range result.Reports {
if _, ok := runIDs[item.RunID]; ok {
t.Fatalf("duplicate RunID in batch result: %q", item.RunID)
}
runIDs[item.RunID] = struct{}{}
if _, ok := reportPaths[item.ReportPath]; ok {
t.Fatalf("duplicate ReportPath in batch result: %q", item.ReportPath)
}
reportPaths[item.ReportPath] = struct{}{}
if _, ok := metadataPaths[item.MetadataPath]; ok {
t.Fatalf("duplicate MetadataPath in batch result: %q", item.MetadataPath)
}
metadataPaths[item.MetadataPath] = struct{}{}
if _, ok := dataPackagePaths[item.DataPackagePath]; ok {
t.Fatalf("duplicate DataPackagePath in batch result: %q", item.DataPackagePath)
}
dataPackagePaths[item.DataPackagePath] = struct{}{}
if item.ReportID == report.Daily {
if !strings.HasPrefix(item.RunID, "20260529T230000.000000000Z_daily_") {
t.Fatalf("Daily RunID = %q, want dated daily run id", item.RunID)
}
date := strings.TrimPrefix(filepath.Base(item.OutputPath), "daily-")
date = strings.TrimSuffix(date, ".md")
dailyByDate[date] = item
}
}
for _, date := range []string{"2026-05-31", "2026-06-01"} {
item, ok := dailyByDate[date]
if !ok {
t.Fatalf("daily outputs = %#v, want Daily output for %s", dailyByDate, date)
}
if item.RunID != "20260529T230000.000000000Z_daily_"+date {
t.Fatalf("Daily %s RunID = %q, want date disambiguator", date, item.RunID)
}
if item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") {
t.Fatalf("Daily %s OutputPath = %q, want date-qualified copy", date, item.OutputPath)
}
}
dailyKeys := map[string]struct{}{}
for _, req := range notifier.requests {
if req.ReportID != report.Daily {
continue
}
if req.IdempotencyKey != req.BundleID+"."+req.RunID {
t.Fatalf("Daily idempotency key = %q, want bundle id plus run id", req.IdempotencyKey)
}
if _, ok := dailyKeys[req.IdempotencyKey]; ok {
t.Fatalf("duplicate Daily idempotency key: %q", req.IdempotencyKey)
}
dailyKeys[req.IdempotencyKey] = struct{}{}
}
if len(dailyKeys) != 2 {
t.Fatalf("Daily notification keys = %#v, want two distinct keys", dailyKeys)
}
}
func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server)
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
OutputDir: outputDir,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 2 {
t.Fatalf("summary = %#v, want successful morning batch", result)
}
var todayItem *BatchReportResult
for i := range result.Reports {
if result.Reports[i].ReportID == report.Today {
todayItem = &result.Reports[i]
break
}
}
if todayItem == nil {
t.Fatalf("reports = %#v, want Today item", result.Reports)
}
want := filepath.Join(outputDir, "today.md")
if todayItem.OutputPath != want {
t.Fatalf("Today OutputPath = %q, want %q", todayItem.OutputPath, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected Today output copy %q: %v", want, err)
}
if strings.Contains(todayItem.ReportPath, outputDir) {
t.Fatalf("Today ReportPath = %q, want managed report path separate from output copy", todayItem.ReportPath)
}
}
func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = ""
cfg.Workspace.Root = t.TempDir()
collector := &recordingCollector{err: errors.New("batch collector failed")}
renderer := &recordingRenderer{}
_, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: renderer,
})
if err == nil || !strings.Contains(err.Error(), "batch collector failed") {
t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err)
}
if len(collector.requests) != 1 {
t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests))
}
if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 {
t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls)
}
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func dailyWorkspaceConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
return cfg
}
func dailyNotificationConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
return cfg
}
func collectionForTest(t *testing.T, cfg config.Config) collect.Result {
t.Helper()
bundle, err := FetchBundle(context.Background(), FetchBundleRequest{Config: cfg})
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
return collect.Result{Bundle: bundle}
}
func collectionWithFutureDailyForTest(t *testing.T, cfg config.Config, dates ...string) collect.Result {
t.Helper()
collection := collectionForTest(t, cfg)
location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)
for _, date := range dates {
collection.Bundle.Hourly.Periods = append(collection.Bundle.Hourly.Periods, fullDayPeriods(t, date, location)...)
}
return collection
}
func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved {
t.Helper()
req.Config = cfg
resolved, err := ResolveGenerate(req, mustParse(now))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
return resolved
}
func recordingFilesystemStore(t *testing.T, cfg config.Config) *recordingStore {
t.Helper()
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return &recordingStore{Store: filesystemStore}
}
func successfulNotificationResult() *NotificationResult {
return &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}
}
func successfulGeneratedTextRenderer(body string) *recordingRenderer {
return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: body,
}
}
func hourlyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
return cfg
}
func hourlyGeneratedTextFixture(t *testing.T) (config.Config, report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
return cfg, resolved, store, notifier, outputPath
}
func hourlyGeneratedTextConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
applyHourlyGeneratedTextSettings(&cfg, t, server)
return cfg
}
func hourlyGeneratedTextConfigWithModules(t *testing.T, server *httptest.Server, modules []string) config.Config {
t.Helper()
var data strings.Builder
data.WriteString("reports:\n hourly:\n deterministic_modules:\n")
for _, id := range modules {
_, _ = fmt.Fprintf(&data, " - %s\n", id)
}
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(data.String()), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
cfg, err := config.LoadFile(path)
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
applyHourlyGeneratedTextSettings(&cfg, t, server)
return cfg
}
func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *httptest.Server) {
t.Helper()
base := hourlyTestConfig(t, server)
cfg.WeatherAPI = base.WeatherAPI
cfg.Workspace = base.Workspace
cfg.Location = base.Location
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
}
func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00")
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
}
func validHourlyGeneratedTextJSON() string {
return `{"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"}`
}
func validTomorrowGeneratedTextJSON() string {
return `{"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."}`
}
func validTodayGeneratedTextJSON() string {
return `{"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."}`
}
func validDailyGeneratedTextJSON() string {
return `{"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"}`
}
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Helper()
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
return result
}
func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
t.Helper()
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
return data
}
func assertRichPromptHelperArtifacts(t *testing.T, result *ReportResult) {
t.Helper()
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
if err != nil {
t.Fatalf("read module snapshot: %v", err)
}
for _, want := range []string{
`"condition_text_lower"`,
`"hour_label"`,
`"text_description_lower"`,
`"mention_precipitation"`,
`"temperature_phrase_f"`,
`"dominant_condition_lower"`,
`"dominant_condition_display"`,
`"max_pop_time_label"`,
} {
if !strings.Contains(string(snapshotData), want) {
t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData))
}
}
renderContext, err := os.ReadFile(result.RenderContextPath)
if err != nil {
t.Fatalf("read render context: %v", err)
}
for _, want := range []string{
`"condition_text_lower"`,
`"hour_label"`,
`"text_description_lower"`,
`"mention_precipitation"`,
`"temperature_phrase_f"`,
`"dominant_condition_lower"`,
`"dominant_condition_display"`,
`"max_pop_time_label"`,
} {
if !strings.Contains(string(renderContext), want) {
t.Fatalf("render context missing rich helper field %q:\n%s", want, string(renderContext))
}
}
}
func assertCuratedPromptDataPackage(t *testing.T, result *ReportResult) {
t.Helper()
data := readDataPackageForTest(t, result)
pkg, err := promptinput.LoadYAML(data)
if err != nil {
t.Fatalf("decode data package: %v", err)
}
current, ok := pkg.Briefing.Values["current_conditions"].(map[string]any)
if !ok {
t.Fatalf("current_conditions = %#v, want prompt map", pkg.Briefing.Values["current_conditions"])
}
assertMapOmitsKeys(t, "current_conditions", current, "condition_text_lower", "wind_direction_text")
hourly, ok := pkg.Briefing.Values["hourly_forecast"].(map[string]any)
if !ok {
t.Fatalf("hourly_forecast = %#v, want prompt map", pkg.Briefing.Values["hourly_forecast"])
}
periods, ok := hourly["periods"].([]any)
if !ok || len(periods) == 0 {
t.Fatalf("hourly_forecast.periods = %#v, want prompt periods", hourly["periods"])
}
firstPeriod, ok := periods[0].(map[string]any)
if !ok {
t.Fatalf("hourly first period = %#v, want prompt map", periods[0])
}
assertMapOmitsKeys(t, "hourly_forecast.periods[0]", firstPeriod, "hour_label", "text_description_lower", "mention_precipitation")
dayparts, ok := pkg.Briefing.Values["derived_daypart_summaries"].(map[string]any)
if !ok {
t.Fatalf("derived_daypart_summaries = %#v, want prompt map", pkg.Briefing.Values["derived_daypart_summaries"])
}
morning, ok := dayparts["morning"].(map[string]any)
if !ok {
t.Fatalf("derived_daypart_summaries.morning = %#v, want prompt map", dayparts["morning"])
}
if morning["max_pop_time"] != "6:00 AM" {
t.Fatalf("derived_daypart_summaries.morning.max_pop_time = %#v, want friendly label", morning["max_pop_time"])
}
assertMapOmitsKeys(t, "derived_daypart_summaries.morning", morning, "temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label")
}
func assertMapOmitsKeys(t *testing.T, name string, value map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := value[key]; ok {
t.Fatalf("%s contains helper field %q: %#v", name, key, value)
}
}
}
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
t.Helper()
for name, value := range values {
if name == "metadata" {
continue
}
assertNoStaleIntervalKeys(t, "briefing."+name, value)
}
}
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
t.Helper()
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
switch key {
case "start_time", "end_time", "period", "start", "end":
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
}
assertNoStaleIntervalKeys(t, path+"."+key, child)
}
case []any:
for i, child := range typed {
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
}
}
}
func assertPathsExist(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
}
func assertPathsMissing(t *testing.T, paths ...string) {
t.Helper()
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
t.Fatalf("artifact %q exists, want missing", path)
} else if !os.IsNotExist(err) {
t.Fatalf("stat artifact %q: %v", path, err)
}
}
}
func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolved) state.ArtifactPaths {
t.Helper()
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
return paths
}
func readMetadataForTest(t *testing.T, path string) state.Metadata {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read metadata %q: %v", path, err)
}
var metadata state.Metadata
if err := json.Unmarshal(data, &metadata); err != nil {
t.Fatalf("decode metadata %q: %v", path, err)
}
return metadata
}
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper()
if err == nil {
t.Fatal("GenerateReport() error = nil, want generated-text report error")
}
text := err.Error()
for _, want := range []string{
fmt.Sprintf("generate report %q", resolved.Definition.ID),
fmt.Sprintf("run %q", resolved.Metadata().RunID),
operation,
} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q, want %q", text, want)
}
}
}
func assertNoGeneratedFailureSideEffects(t *testing.T, notifier *recordingNotifier, outputPath string) {
t.Helper()
if len(notifier.requests) != 0 {
t.Fatalf("notification requests = %#v, want none after generated-text failure", notifier.requests)
}
assertPathsMissing(t, outputPath)
}
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
t.Helper()
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
if err != nil {
t.Fatalf("SaveModuleSnapshot() error = %v", err)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{
ModuleSnapshot: moduleSnapshotPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
}
func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot {
t.Helper()
low := 50
high := 58
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"low_temp_f": low,
"high_temp_f": high,
"daily_precipitation_probability": precip,
}},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58",
},
}},
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
"max_pop_percent": precip,
"max_pop_time": "6 AM",
}},
{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
t.Helper()
precip := 10
snapshot, err := module.NewSnapshot([]module.Output{
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
date + "_morning": map[string]any{
"date": date,
"period_begins": date + " at 6:00 AM",
"period_ends": date + " at 10:00 AM",
"temp_range_f": "50-58",
"max_pop_percent": precip,
"max_pop_time": "6 AM",
},
}},
})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
return snapshot
}
func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
return briefing.Metadata{
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
Variant: "today",
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
}
}
type recordingRenderer struct {
renderCalls int
runCalls int
structuredRunCalls int
renderRequest scriptorium.RenderRequest
runRequest scriptorium.RunRequest
structuredRunRequest scriptorium.StructuredRunRequest
renderResult *scriptorium.RenderResult
runResult *scriptorium.RunResult
structuredRunResult *scriptorium.StructuredRunResult
err error
runErr error
structuredRunErr error
runBody string
structuredRunBody string
}
type recordingCollector struct {
result *collect.Result
err error
requests []collect.Request
}
func (c *recordingCollector) Run(_ context.Context, req collect.Request) (*collect.Result, error) {
c.requests = append(c.requests, req)
if c.err != nil {
return nil, c.err
}
return c.result, nil
}
type recordingStore struct {
state.Store
calls []string
}
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
s.calls = append(s.calls, "module_snapshot")
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
}
func (s *recordingStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
s.calls = append(s.calls, "data_package")
return s.Store.SaveDataPackage(ctx, resolved, pkg)
}
func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Resolved, artifact state.PreflightArtifact) (string, error) {
s.calls = append(s.calls, "preflight")
return s.Store.SavePreflight(ctx, resolved, artifact)
}
func (s *recordingStore) SaveGeneratedTextRaw(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text_raw")
return s.Store.SaveGeneratedTextRaw(ctx, resolved, data)
}
func (s *recordingStore) SaveGeneratedTextResult(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "generated_text_result")
return s.Store.SaveGeneratedTextResult(ctx, resolved, value)
}
func (s *recordingStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) {
s.calls = append(s.calls, "generated_text")
return s.Store.SaveGeneratedText(ctx, resolved, data)
}
func (s *recordingStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) {
s.calls = append(s.calls, "render_context")
return s.Store.SaveRenderContext(ctx, resolved, value)
}
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
s.calls = append(s.calls, "prepare_report")
return s.Store.PrepareRenderedReport(ctx, resolved)
}
func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
s.calls = append(s.calls, "metadata")
return s.Store.SaveMetadata(ctx, metadata)
}
func successfulRenderer(body string) *recordingRenderer {
return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
runBody: body,
}
}
type selectiveRenderer struct {
renderCalls int
runCalls int
structuredRunCalls int
failRenderPrompt string
runBody string
}
type recordingNotifier struct {
requests []NotificationRequest
result *NotificationResult
err error
errByReport map[report.ID]error
}
func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
n.requests = append(n.requests, req)
if err := n.errByReport[req.ReportID]; err != nil {
return nil, err
}
if n.err != nil {
return nil, n.err
}
if n.result != nil {
result := *n.result
if result.BundleID == "" {
result.BundleID = req.BundleID
}
if result.IdempotencyKey == "" {
result.IdempotencyKey = req.IdempotencyKey
}
if result.PipelineID == "" {
result.PipelineID = req.PipelineID
}
return &result, nil
}
return &NotificationResult{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Status: "accepted",
UploadStatus: "accepted",
}, nil
}
func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++
if req.PromptID == r.failRenderPrompt {
return &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, errors.New("render failed")
}
return &scriptorium.RenderResult{ExitCode: 0}, nil
}
func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
r.runCalls++
if r.runBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
return nil, err
}
}
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
body := validHourlyGeneratedTextJSON()
if req.PromptID == "weather.today_generated_text" {
body = validTodayGeneratedTextJSON()
}
if req.PromptID == "weather.tomorrow_generated_text" {
body = validTomorrowGeneratedTextJSON()
}
if req.PromptID == "weather.daily_generated_text" {
body = validDailyGeneratedTextJSON()
}
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err
}
return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++
r.renderRequest = req
return r.renderResult, r.err
}
func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
r.runCalls++
r.runRequest = req
if r.runBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
return nil, err
}
}
if r.runResult != nil {
r.runResult.OutputPath = req.OutputPath
}
return r.runResult, r.runErr
}
func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) {
r.structuredRunCalls++
r.structuredRunRequest = req
body := r.structuredRunBody
if body == "" {
body = validGeneratedTextJSONForPrompt(req.PromptID)
}
if body != "" {
if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil {
return nil, err
}
}
if r.structuredRunResult != nil {
r.structuredRunResult.OutputPath = req.OutputPath
}
return r.structuredRunResult, r.structuredRunErr
}
func validGeneratedTextJSONForPrompt(promptID string) string {
switch promptID {
case "weather.daily_generated_text":
return validDailyGeneratedTextJSON()
case "weather.today_generated_text":
return validTodayGeneratedTextJSON()
case "weather.tomorrow_generated_text":
return validTomorrowGeneratedTextJSON()
case "weather.hourly_generated_text":
return validHourlyGeneratedTextJSON()
default:
return ""
}
}