package briefing import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"), }) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule(discussion) error = %v", err) } if output != nil { t.Fatalf("discussion output = %#v, want omitted below threshold", output) } outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule(outlooks) error = %v", err) } outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput) if !outlookValue.Checked || outlookValue.OutlookCount != 1 { t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue) } } func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"), }) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" { t.Fatalf("output = %#v, want spc convective discussion stanza", output) } value := moduleValue[SPCConvectiveDiscussionModule](t, output) if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 { t.Fatalf("value = %#v, want threshold reason and one discussion", value) } discussion := value.Discussions[0] if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" { t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion) } if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" { t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds) } if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" { t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } } if strings.Contains(text, `"period":`) { t.Fatalf("json = %s, want period_begins/period_ends instead of period", text) } } func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"), }) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveDiscussionModule](t, output) if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" { t.Fatalf("value = %#v, want above-threshold discussion", value) } } func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) { registry := MustDefaultModuleRegistry() rank := 30 ctx := testModuleContext() outlook := weatherdata.ConvectiveOutlook{ ID: "day1-wind-30", Day: 1, OutlookType: "wind", Label: "30%", LabelText: "30% Wind Risk", SeverityRank: &rank, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"), } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output != nil { t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output) } } func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) { registry := MustDefaultModuleRegistry() rank2 := 2 rank30 := 30 ctx := testModuleContext() categorical := weatherdata.ConvectiveOutlook{ ID: "day1-marginal", Day: 1, OutlookType: "categorical", Label: "MRGL", LabelText: "Marginal Risk", SeverityRank: &rank2, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), } wind := weatherdata.ConvectiveOutlook{ ID: "day1-wind-30", Day: 1, OutlookType: "wind", Label: "30%", LabelText: "30% Wind Risk", SeverityRank: &rank30, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind} ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"), } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output != nil { t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output) } } func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) { registry := MustDefaultModuleRegistry() rank2 := 2 rank4 := 4 ctx := testModuleContext() ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00") ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00") day1Outlook := weatherdata.ConvectiveOutlook{ ID: "day1-marginal", Day: 1, OutlookType: "categorical", Label: "MRGL", LabelText: "Marginal Risk", SeverityRank: &rank2, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), } day2Outlook := weatherdata.ConvectiveOutlook{ ID: "day2-enhanced", Day: 2, OutlookType: "categorical", Label: "ENH", LabelText: "Enhanced Risk", SeverityRank: &rank4, ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook} ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"), spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"), } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveDiscussionModule](t, output) if len(value.Discussions) != 1 { t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions) } if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" { t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0]) } if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" { t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds) } } func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) { registry := MustDefaultModuleRegistry() rank := 5 ctx := testModuleContext() outlook := weatherdata.ConvectiveOutlook{ ID: "day2-enhanced", Day: 2, OutlookType: "categorical", Label: "ENH", LabelText: "Enhanced Risk", SeverityRank: &rank, ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{ spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"), } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output != nil { t.Fatalf("output = %#v, want non-overlapping discussion omitted", output) } } func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ {Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")}, }) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output != nil { t.Fatalf("output = %#v, want omitted when discussion text is missing", output) } } func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.SPCConvectiveOutlooks = nil ctx.Derived.SPCConvectiveOutlooks = nil ctx.Derived.SPCConvectiveDiscussions = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output != nil { t.Fatalf("output = %#v, want omitted for missing optional source", output) } } func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext { ctx := testModuleContext() outlook := weatherdata.ConvectiveOutlook{ ID: "day1-categorical", Day: 1, OutlookType: "categorical", Label: "SLGT", LabelText: "Slight Risk", SeverityRank: &rank, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"), IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"), Outlooks: []weatherdata.ConvectiveOutlook{outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} ctx.Derived.SPCConvectiveDiscussions = discussions return ctx } func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion { return weatherdata.ConvectiveOutlookDiscussion{ Day: day, Headline: headline, Summary: summary, Discussion: discussion, UpdatedAt: ptrModuleTime(updatedAt), } } func ptrModuleTime(value string) *time.Time { parsed := mustParseModuleTime(value) return &parsed }