From b38230bc35a41bc4331ef5d4e85a1a486d525070 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 17:40:54 +0000 Subject: [PATCH] Add near-term generation artifact coverage --- internal/app/app_test.go | 226 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6aa1e28..5e2bf6b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -349,6 +349,187 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) } } +func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) { + server := nearTermBundleServer(t) + cfg := dailyTestConfig(t, server) + cfg.Workspace.Root = t.TempDir() + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportNearTerm, + }, mustParse("2026-05-29T08:30:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + outputPath := filepath.Join(t.TempDir(), "near-term.md") + renderer := successfulRenderer("# Near-Term Report\n") + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: renderer, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + + if result.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" { + t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID) + } + if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" { + t.Fatalf("valid period start = %s, want rolling window start", got) + } + if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" { + t.Fatalf("valid period end = %s, want six-hour rolling window end", got) + } + if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" { + t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID) + } + if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) { + t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath) + } + if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) { + t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath) + } + assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath) + if result.OutputPath != outputPath { + t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath) + } + copiedReport, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output copy: %v", err) + } + if !strings.Contains(string(copiedReport), "# Near-Term Report") { + t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport)) + } + + wantModules := []module.ID{ + module.Metadata, + module.CurrentConditions, + module.HourlyForecast, + module.PrecipTiming, + module.AlertDigest, + module.SPCConvectiveOutlooks, + module.AreaForecastDiscussion, + module.SPCConvectiveDiscussion, + module.WeatherStory, + } + if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") { + t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules) + } + + hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast") + if err != nil { + t.Fatalf("decode hourly forecast: %v", err) + } + if !ok || len(hourly.Periods) != 5 { + t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly) + } + hourlyJSON := mustMarshalString(t, hourly) + if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") { + t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON) + } + if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") { + t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON) + } + + precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing") + if err != nil { + t.Fatalf("decode precip timing: %v", err) + } + if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 { + t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip) + } + if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" { + t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0]) + } + if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" { + t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1]) + } + + alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest") + if err != nil { + t.Fatalf("decode alert digest: %v", err) + } + if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" { + t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts) + } + + outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks") + if err != nil { + t.Fatalf("decode SPC outlooks: %v", err) + } + if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" { + t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks) + } + discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion") + if err != nil { + t.Fatalf("decode SPC discussion: %v", err) + } + if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" { + t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion) + } + + afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion") + if err != nil { + t.Fatalf("decode AFD: %v", err) + } + if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." { + t.Fatalf("AFD = %#v, want key messages and short term", afd) + } + if afd.Product != "" || afd.LongTerm != "" { + t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd) + } + + if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 { + t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items) + } + data := readDataPackageForTest(t, result) + text := string(data) + for _, want := range []string{ + "id: near_term", + "prompt_id: weather.near_term_report", + "valid_period:", + "recent_changes:", + " items: []", + " alert_digest:", + " spc_convective_outlooks:", + " precip_timing:", + " area_forecast_discussion:", + " spc_convective_discussion:", + " weather_story:", + " current_conditions:", + " hourly_forecast:", + } { + if !strings.Contains(text, want) { + t.Fatalf("data package missing %q:\n%s", want, text) + } + } + for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} { + if strings.Contains(text, omitted) { + t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text) + } + } + riskIndex := strings.Index(text, " applicable_risk_products:") + derivedIndex := strings.Index(text, " derived_summaries:") + narrativeIndex := strings.Index(text, " narrative_products:") + rawIndex := strings.Index(text, " raw_data:") + alertIndex := strings.Index(text, " alert_digest:") + precipIndex := strings.Index(text, " precip_timing:") + afdIndex := strings.Index(text, " area_forecast_discussion:") + currentIndex := strings.Index(text, " current_conditions:") + hourlyIndex := strings.Index(text, " hourly_forecast:") + if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 || + !(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) { + 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) +} + func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { server := dailyBundleServer(t) cfg := dailyTestConfig(t, server) @@ -1257,6 +1438,23 @@ func moduleIDsForTest(ids []module.ID) []string { 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) @@ -1290,6 +1488,34 @@ func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse st return server } +func nearTermBundleServer(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","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 near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term 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":"Near-Term 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/near-term"}}`)) + 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-near-term","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":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term 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"}]}}`