package briefing import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" ) func TestStormBriefingWithActiveAlert(t *testing.T) { location := mustLocation(t) resolved, err := report.Resolve(report.Storm, report.ResolveRequest{ Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location, StormStart: mustParse("2026-05-29T06:00:00-05:00"), StormEnd: mustParse("2026-05-29T12:00:00-05:00"), }) if err != nil { t.Fatalf("resolve storm: %v", err) } precip := 80.0 gust := 42.0 bundle := &forecast.Bundle{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Severe thunderstorms and gusty wind", ProbabilityOfPrecipitationPercent: &precip, WindGustMph: &gust, }}}, Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ StartTime: mustParse("2026-05-29T06:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"), TextDescription: "Storms likely.", }}}, Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{ StartTime: mustParse("2026-05-29T06:00:00-05:00"), EndTime: mustParse("2026-05-29T18:00:00-05:00"), TextDescription: "Damaging wind possible in stronger storms.", }}}, Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`), }}, Discussion: &forecast.Discussion{ Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}, ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."}, LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."}, }, WeatherStory: &forecast.WeatherStory{ OfficeID: "LSX", StartTime: mustParse("2026-05-29T06:00:00Z"), EndTime: mustParse("2026-05-29T18:00:00Z"), Title: "Storm Risk", Description: "Strong storms are possible.", AltText: "Weather story graphic showing storm risk.", Order: 1, }, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) if err != nil { t.Fatalf("BuildStorm() error = %v", err) } if pkg.Metadata.ReportID != report.Storm { t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID) } if pkg.Storm == nil { t.Fatal("Storm = nil") } if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 { t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines) } if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) { t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod) } if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") { t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards) } if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 { t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods)) } if pkg.Storm.WeatherStory == nil { t.Fatal("WeatherStory = nil, want available story context") } if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." { t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory) } if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." { t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm) } if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." { t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm) } if len(pkg.Storm.WhatToWatchNext) == 0 { t.Fatal("WhatToWatchNext length = 0, want watch inputs") } } func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) { location := mustLocation(t) resolved, err := report.Resolve(report.Storm, report.ResolveRequest{ Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location, StormStart: mustParse("2026-05-29T06:00:00-05:00"), StormEnd: mustParse("2026-05-29T12:00:00-05:00"), }) if err != nil { t.Fatalf("resolve storm: %v", err) } bundle := &forecast.Bundle{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}}, Alerts: &forecast.AlertRun{}, Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}}, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) if err != nil { t.Fatalf("BuildStorm() error = %v", err) } if len(pkg.Storm.RelevantAlerts) != 0 { t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts)) } if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") { t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines) } if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") { t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs) } if len(pkg.Storm.MostLikelyScenario) == 0 { t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs") } } func TestStormBriefingQuietWindow(t *testing.T) { location := mustLocation(t) resolved, err := report.Resolve(report.Storm, report.ResolveRequest{ Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location, StormStart: mustParse("2026-05-29T06:00:00-05:00"), StormEnd: mustParse("2026-05-29T12:00:00-05:00"), }) if err != nil { t.Fatalf("resolve storm: %v", err) } bundle := &forecast.Bundle{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}}, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, } pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}) if err != nil { t.Fatalf("BuildStorm() error = %v", err) } if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") { t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards) } if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") { t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext) } }