package briefing import ( "encoding/json" "strings" "testing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() rank := 3 asOf := mustParseModuleTime("2026-05-29T14:00:00Z") issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z") expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00") outlook := weatherdata.ConvectiveOutlook{ ID: "day1-categorical-slight", Provider: "spc", Product: "convective_outlook", Day: 1, OutlookType: "categorical", Label: "SLGT", LabelText: "Slight Risk", Forecaster: "Smith", SeverityRank: &rank, ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"), ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"), IssuedAt: &issuedAt, ExpiresAt: &expiresAt, SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif", ContainsLocation: true, Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`), } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ LocationID: "nws-lsx-grid-90-74", LocationName: "St. Louis, MO", AsOf: &asOf, IssuedAt: &issuedAt, Outlooks: []weatherdata.ConvectiveOutlook{outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" { t.Fatalf("output = %#v, want spc convective outlook output", output) } value := moduleValue[SPCConvectiveOutlooksModule](t, output) if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" { t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value) } if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" { t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName) } if len(value.Outlooks) != 1 { t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks)) } got := value.Outlooks[0] if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" { t.Fatalf("outlook = %#v, want categorical slight risk fields", got) } if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" { t.Fatalf("outlook times = %#v, want friendly local labels", got) } if !got.ContainsLocation || got.ImageURL == "" { t.Fatalf("outlook = %#v, want location flag and image URL", got) } if len(value.RiskDigest) != 1 { t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest)) } digest := value.RiskDigest[0] if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" { t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } } for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} { if strings.Contains(text, omitted) { t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted) } } } func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) { if defaultSPCRiskDigestOutlookType != "categorical" { t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType) } if defaultSPCRiskDigestMinimumSeverityRank != 3 { t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank) } } func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) { tests := []struct { name string outlook weatherdata.ConvectiveOutlook wantRisk bool }{ { name: "categorical slight risk included", outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true, "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), wantRisk: true, }, { name: "marginal risk excluded", outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true, "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), }, { name: "non categorical high rank excluded", outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true, "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), }, { name: "non overlapping excluded", outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true, "2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"), }, { name: "location miss excluded", outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false, "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook} output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveOutlooksModule](t, output) gotRisk := len(value.RiskDigest) > 0 if gotRisk != tt.wantRisk { t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk) } }) } } func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() rank := 5 outlook := weatherdata.ConvectiveOutlook{ ID: "tomorrow-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"), ContainsLocation: true, } ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ AsOf: ptrModuleTime("2026-05-29T14:00:00Z"), Outlooks: []weatherdata.ConvectiveOutlook{outlook}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook} output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveOutlooksModule](t, output) if value.OutlookCount != 0 || len(value.Outlooks) != 0 { t.Fatalf("value = %#v, want non-overlapping outlook omitted", value) } if len(value.RiskDigest) != 0 { t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest) } } func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() asOf := mustParseModuleTime("2026-05-29T14:00:00Z") ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ LocationID: "nws-lsx-grid-90-74", LocationName: "St. Louis, MO", AsOf: &asOf, Outlooks: []weatherdata.ConvectiveOutlook{}, } ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{} output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveOutlooksModule](t, output) if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 { t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal() error = %v", err) } if strings.Contains(string(data), "outlooks") { t.Fatalf("json = %s, want empty outlook list omitted", string(data)) } } func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() ctx.Collected.SPCConvectiveOutlooks = nil ctx.Collected.SourceProvenance = []weatherdata.Source{{ Name: string(module.SPCConvectiveOutlooks), Missing: true, }} ctx.Derived.SPCConvectiveOutlooks = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[SPCConvectiveOutlooksModule](t, output) if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" { t.Fatalf("missing source value = %#v, want unchecked empty stanza", value) } } func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook { return weatherdata.ConvectiveOutlook{ ID: labelText, Day: 1, OutlookType: outlookType, LabelText: labelText, SeverityRank: &rank, ValidFrom: mustParseModuleTime(validFrom), ValidTo: mustParseModuleTime(validTo), ContainsLocation: containsLocation, } }