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, ¬ificationArtifact); 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, ¬ificationArtifact); 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, ¬ificationErr) { 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, ¬ification); 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, ¬ificationArtifact); 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, ¬ification); 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 TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) cfg.Notify.Distributor.ReportPathTemplates = []string{ "archive/{valid_start_date}/{artifact_group}/{run_id}.md", "latest/{batch_output_name}", } cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch}.{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-29T18:00:00-05:00") planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31", "2026-06-01") runID := batchRunID(startedAt, BatchEvening) req, err := buildBatchNotificationRequest(cfg, BatchEvening, runID, startedAt, reports, planned) if err != nil { t.Fatalf("buildBatchNotificationRequest() error = %v", err) } if req.Batch != BatchEvening || req.RunID != runID { t.Fatalf("batch identity = %s/%s, want %s/%s", req.Batch, req.RunID, BatchEvening, runID) } if req.PipelineID != "weatherreporter.evening.2026-05-29" { t.Fatalf("PipelineID = %q, want rendered batch pipeline", req.PipelineID) } if req.BundleID != "weatherreporter.home.evening" { t.Fatalf("BundleID = %q, want rendered batch bundle id", req.BundleID) } wantKey := "weatherreporter.home.evening." + runID if req.IdempotencyKey != wantKey { t.Fatalf("IdempotencyKey = %q, want %q", req.IdempotencyKey, wantKey) } if !req.CreatedAt.Equal(startedAt) { t.Fatalf("CreatedAt = %s, want %s", req.CreatedAt, startedAt) } if len(req.IncludedReports) != 3 { t.Fatalf("IncludedReports = %d, want 3", len(req.IncludedReports)) } if len(req.Files) != 6 { t.Fatalf("Files = %d, want two mappings per report", len(req.Files)) } wantBundlePaths := map[string]struct{}{} for _, plannedReport := range planned { resolved := plannedReport.Resolved runID := resolved.Metadata().RunID outputName := plannedReport.OutputCopyName if outputName == "" { outputName = resolved.Definition.BatchOutputName } wantBundlePaths[fmt.Sprintf("archive/%s/%s/%s.md", resolved.ValidPeriod.Start.In(mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)).Format(timeutil.DateLayout), resolved.Definition.ArtifactGroup, runID)] = struct{}{} wantBundlePaths["latest/"+outputName] = struct{}{} } gotBundlePaths := map[string]struct{}{} gotSourcePaths := map[string]struct{}{} for _, file := range req.Files { gotBundlePaths[file.BundlePath] = struct{}{} gotSourcePaths[file.SourcePath] = struct{}{} } for want := range wantBundlePaths { if _, ok := gotBundlePaths[want]; !ok { t.Fatalf("bundle paths = %#v, missing %q", gotBundlePaths, want) } } for _, item := range reports { if _, ok := gotSourcePaths[item.ReportPath]; !ok { t.Fatalf("source paths = %#v, missing managed report path %q", gotSourcePaths, item.ReportPath) } } uploadReq := batchDistributorUploadRequest(req) if uploadReq.PipelineID != req.PipelineID || uploadReq.BundleID != req.BundleID || uploadReq.IdempotencyKey != req.IdempotencyKey || !uploadReq.CreatedAt.Equal(startedAt) { t.Fatalf("upload request = %#v, want batch notification identity", uploadReq) } if len(uploadReq.Files) != len(req.Files) { t.Fatalf("upload files = %d, want %d", len(uploadReq.Files), len(req.Files)) } } func TestBuildBatchNotificationRequestRejectsDuplicateBundlePaths(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) cfg.Notify.Distributor.ReportPathTemplates = []string{"index.md"} startedAt := mustParse("2026-05-29T18:00:00-05:00") planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") _, err := buildBatchNotificationRequest(cfg, BatchEvening, batchRunID(startedAt, BatchEvening), startedAt, reports, planned) if err == nil { t.Fatal("buildBatchNotificationRequest() error = nil, want duplicate path error") } for _, want := range []string{"duplicate bundle path", "index.md", "report", "run", "source path"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q, want %q", err.Error(), want) } } } func TestBuildBatchNotificationRequestRejectsMissingReportPath(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) startedAt := mustParse("2026-05-29T18:00:00-05:00") planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") reports[0].ReportPath = "" _, err := buildBatchNotificationRequest(cfg, BatchEvening, batchRunID(startedAt, BatchEvening), startedAt, reports, planned) if err == nil { t.Fatal("buildBatchNotificationRequest() error = nil, want missing path error") } for _, want := range []string{"missing managed report path", string(reports[0].ReportID), reports[0].RunID} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q, want %q", err.Error(), want) } } } func TestRunBatchContinuesAfterReportFailure(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{} 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, 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 renderer.runCalls != 0 || renderer.structuredRunCalls != 2 { t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls) } if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { t.Fatalf("notification requests report=%d batch=%d, want none after report failure", len(notifier.requests), len(notifier.batchRequests)) } if result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "one or more reports failed" { t.Fatalf("batch notification = %#v, want skipped after report failure", result.Notification) } 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 TestRunBatchMorningSendsOneBatchNotification(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{batchResult: successfulBatchNotificationResult()} 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 != 3 || result.Failed != 0 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/3/0", result.Total, result.Succeeded, result.Failed) } if len(notifier.requests) != 0 { t.Fatalf("per-report notification requests = %#v, want none", notifier.requests) } if len(notifier.batchRequests) != 1 { t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) } req := notifier.batchRequests[0] if req.Batch != BatchMorning || req.RunID != "20260529T100000.000000000Z_morning" { t.Fatalf("batch request identity = %s/%s, want morning run id", req.Batch, req.RunID) } if len(req.IncludedReports) != 3 || len(req.Files) != 3 { t.Fatalf("batch request reports/files = %d/%d, want 3/3", len(req.IncludedReports), len(req.Files)) } for _, file := range req.Files { if file.SourcePath == "" || file.BundlePath == "" { t.Fatalf("batch file = %#v, want source and bundle path", file) } if !strings.Contains(file.BundlePath, file.RunID) { t.Fatalf("bundle path %q does not include report run id %q", file.BundlePath, file.RunID) } } if result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.RunID != "batch-distributor-run" || result.Notification.Path == "" { t.Fatalf("batch notification = %#v, want succeeded result with artifact path", result.Notification) } if len(result.Notification.IncludedReports) != 3 { t.Fatalf("batch notification included reports = %d, want 3", len(result.Notification.IncludedReports)) } artifact := readBatchNotificationForTest(t, result.Notification.Path) if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-distributor-run" || artifact.RunStatus == nil { t.Fatalf("batch notification artifact = %#v, want succeeded upload and run status", artifact) } if len(artifact.Reports) != 3 { t.Fatalf("artifact included reports = %d, want 3", len(artifact.Reports)) } } func TestRunBatchSuppressesPerReportNotification(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") cfg.WeatherAPI.BaseURL = "" collector := &recordingCollector{result: &collection} store := recordingFilesystemStore(t, cfg) notifier := &recordingNotifier{err: errors.New("distributor unavailable")} result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), Collector: collector, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Store: store, Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 { t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/2/0", 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) != 0 { t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests) } if len(notifier.batchRequests) != 1 { t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) } for _, item := range result.Reports { if item.Status != "succeeded" { t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) } if item.NotificationStatus != "" || item.NotificationRunID != "" || item.NotificationPipelineID != "" || item.NotificationError != "" || item.NotificationPath != "" { t.Fatalf("report %s notification fields = %#v, want empty per-report notification fields", item.ReportID, item) } metadata := readMetadataForTest(t, item.MetadataPath) if metadata.NotificationPath != "" { t.Fatalf("report %s metadata NotificationPath = %q, want empty", item.ReportID, metadata.NotificationPath) } } for _, item := range result.Reports { reportNotificationDir := filepath.Join(cfg.Workspace.Root, cfg.Workspace.NotificationsDir, string(item.ReportID)) if _, err := os.Stat(reportNotificationDir); err == nil { t.Fatalf("per-report notification directory %q exists, want none", reportNotificationDir) } else if !os.IsNotExist(err) { t.Fatalf("stat per-report notification directory %q: %v", reportNotificationDir, err) } } } func TestRunBatchNotificationFailureKeepsReportItemsSucceeded(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") cfg.WeatherAPI.BaseURL = "" notifier := &recordingNotifier{batchErr: errors.New("batch upload rejected")} result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), Collector: &recordingCollector{result: &collection}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Failed != 1 || result.Succeeded != 2 { t.Fatalf("summary succeeded/failed = %d/%d, want report successes plus notification failure", result.Succeeded, result.Failed) } for _, item := range result.Reports { if item.Status != "succeeded" { t.Fatalf("report %s status = %s, want succeeded despite batch notification failure", item.ReportID, item.Status) } } if result.Notification == nil || result.Notification.Status != "failed" || !strings.Contains(result.Notification.Error, "batch upload rejected") || result.Notification.Path == "" { t.Fatalf("batch notification = %#v, want failed upload result", result.Notification) } artifact := readBatchNotificationForTest(t, result.Notification.Path) if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") { t.Fatalf("batch notification artifact = %#v, want failed upload error", artifact) } err = RunBatch(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), Collector: &recordingCollector{result: &collection}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Notifier: &recordingNotifier{batchErr: errors.New("batch upload rejected")}, }) var batchErr BatchError if !errors.As(err, &batchErr) { t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err) } if batchErr.Result == nil || !batchNotificationFailed(batchErr.Result) || batchReportFailures(batchErr.Result) != 0 { t.Fatalf("RunBatch() result = %#v, want notification-only batch failure", batchErr.Result) } } func TestRunBatchNotificationStatusErrorPersistsStatusReport(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") cfg.WeatherAPI.BaseURL = "" notifier := &recordingNotifier{ batchResult: &NotificationResult{ RunID: "batch-distributor-run", Status: "accepted", UploadStatus: "accepted", StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`), }, } result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchMorning, Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" { t.Fatalf("result = %#v, want status-error notification artifact without batch failure", result) } artifact := readBatchNotificationForTest(t, result.Notification.Path) if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !strings.Contains(string(artifact.RunStatus.Report), "replace_older") { t.Fatalf("batch notification artifact = %#v, want status error and raw status report", artifact) } } func TestRunBatchDisabledDistributorSkipsBatchNotification(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") cfg.WeatherAPI.BaseURL = "" notifier := &recordingNotifier{} result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), Collector: &recordingCollector{result: &collection}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Notification != nil || result.Failed != 0 { t.Fatalf("result notification/failed = %#v/%d, want disabled notification omitted", result.Notification, result.Failed) } if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { t.Fatalf("notification requests report=%d batch=%d, want none when distributor disabled", len(notifier.requests), len(notifier.batchRequests)) } } func TestRunBatchDisabledBatchNotificationSkipsNotifier(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) cfg.Notify.Distributor.Batch.Enabled = false collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") cfg.WeatherAPI.BaseURL = "" notifier := &recordingNotifier{} result, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), Collector: &recordingCollector{result: &collection}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } if result.Notification != nil || result.Failed != 0 { t.Fatalf("result notification/failed = %#v/%d, want disabled batch notification omitted", result.Notification, result.Failed) } if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { t.Fatalf("notification requests report=%d batch=%d, want none when batch notification disabled", len(notifier.requests), len(notifier.batchRequests)) } } 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) != 0 { t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests) } if len(notifier.batchRequests) != 1 { t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) } 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) } } if len(notifier.batchRequests[0].IncludedReports) != len(result.Reports) { t.Fatalf("batch included reports = %d, want %d", len(notifier.batchRequests[0].IncludedReports), len(result.Reports)) } for _, included := range notifier.batchRequests[0].IncludedReports { if strings.Contains(included.SourcePath, outputDir) { t.Fatalf("batch notification source path = %q, want managed report path outside output dir", included.SourcePath) } if _, ok := reportPaths[included.SourcePath]; !ok { t.Fatalf("batch notification source path = %q, want one of %#v", included.SourcePath, reportPaths) } } } 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 plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch BatchKind, now time.Time, futureDailyDates ...string) ([]plannedBatchReport, []BatchReportResult) { t.Helper() collection := collectionWithFutureDailyForTest(t, cfg, futureDailyDates...) planned, err := planBatchRun(BatchRequest{Config: cfg, Batch: batch}, now, collection) if err != nil { t.Fatalf("planBatchRun() error = %v", err) } reportDir := filepath.Join(t.TempDir(), "managed-reports") results := make([]BatchReportResult, 0, len(planned)) for _, item := range planned { metadata := item.Resolved.Metadata() results = append(results, BatchReportResult{ ReportID: item.Resolved.Definition.ID, RunID: metadata.RunID, Status: "succeeded", ReportPath: filepath.Join(reportDir, string(item.Resolved.Definition.ID), metadata.RunID+".md"), }) } return planned, results } 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 successfulBatchNotificationResult() *NotificationResult { return &NotificationResult{ RunID: "batch-distributor-run", Status: "succeeded", UploadStatus: "accepted", 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 readBatchNotificationForTest(t *testing.T, path string) state.BatchDistributorNotificationArtifact { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read batch notification %q: %v", path, err) } var artifact state.BatchDistributorNotificationArtifact if err := json.Unmarshal(data, &artifact); err != nil { t.Fatalf("decode batch notification %q: %v", path, err) } return artifact } 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 batchRequests []batchNotificationRequest result *NotificationResult batchResult *NotificationResult err error batchErr 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 (n *recordingNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) { n.batchRequests = append(n.batchRequests, req) if n.batchErr != nil { return nil, n.batchErr } if n.batchResult != nil { result := *n.batchResult 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, RunID: "batch-distributor-run", 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 "" } }