From d7a72f858140f7d119d63378e1a9ec6edc9c817a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 15:14:09 +0000 Subject: [PATCH] Add SPC convective workflow coverage --- internal/app/app_test.go | 139 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 4 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index c636ebc..f7af2b4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -169,9 +169,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { !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), "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)) } @@ -184,9 +188,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { storyIndex := strings.Index(string(data), " weather_story:") currentIndex := strings.Index(string(data), " current_conditions:") hourlyIndex := strings.Index(string(data), " hourly_forecast:") - if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 || + 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 && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) { + !(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) @@ -199,6 +204,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok { t.Fatal("data package metadata stanza missing") } + 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"]) @@ -246,6 +255,86 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) { } } +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: severity_rank >= 3", + " label_text: Slight Risk", + " severity_rank: 3", + " 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) + } + } + + 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) + } + 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") || !strings.Contains(text, " severity_rank: 2") { + t.Fatalf("data package missing lower-risk SPC outlook:\n%s", 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 TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { server := dailyBundleServer(t) cfg := dailyTestConfig(t, server) @@ -1119,6 +1208,11 @@ func moduleIDsForTest(ids []module.ID) []string { } 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 { @@ -1137,7 +1231,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server { 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":[]}}`)) + _, _ = w.Write([]byte(convectiveResponse)) default: http.NotFound(w, r) } @@ -1146,6 +1240,12 @@ func dailyBundleServer(t *testing.T) *httptest.Server { 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" @@ -1339,6 +1439,37 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config { return cfg } +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, + 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 assertPathsExist(t *testing.T, paths ...string) { t.Helper() for _, path := range paths {