Add SPC convective workflow coverage

This commit is contained in:
2026-06-12 15:14:09 +00:00
parent c09b7410ce
commit d7a72f8581

View File

@@ -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 {