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) } wantBackground := spcOutlookBackgroundDefinition("categorical", "SLGT") if got.BackgroundDefinition == nil || wantBackground == nil || *got.BackgroundDefinition != *wantBackground { t.Fatalf("background definition = %#v, want matching Slight Risk helper %#v", got.BackgroundDefinition, wantBackground) } 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", "background_definition", "plain_language", "official_description", "relative_level", "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 TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) { tests := []struct { name string outlookType string label string want bool }{ {name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true}, {name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true}, {name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true}, {name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true}, {name: "unknown risk", outlookType: "categorical", label: "FOO"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label) if tt.want && definition == nil { t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label) } if !tt.want && definition != nil { t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition) } }) } } func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true, "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00") outlook.Label = "FOO" ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ 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 len(value.Outlooks) != 1 { t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks)) } if value.Outlooks[0].BackgroundDefinition != nil { t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition) } } func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) { wantKeys := []string{ "categorical:TSTM", "categorical:MRGL", "categorical:SLGT", "categorical:ENH", "categorical:MDT", "categorical:HIGH", "tornado:CIG1", "tornado:CIG2", "tornado:CIG3", "wind:CIG1", "wind:CIG2", "wind:CIG3", "hail:CIG1", "hail:CIG2", } if len(spcOutlookBackgroundDefinitions) != len(wantKeys) { t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys)) } for _, key := range wantKeys { if _, ok := spcOutlookBackgroundDefinitions[key]; !ok { t.Fatalf("embedded SPC outlook background definitions missing %q", key) } } for key, definition := range spcOutlookBackgroundDefinitions { if strings.TrimSpace(key) == "" { t.Fatal("embedded SPC outlook background definitions contain empty key") } if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" { t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition) } if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") { t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription) } if strings.Contains(definition.OfficialDescription, "by themselves") { t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription) } } } func TestSPCOutlookBackgroundDefinitionAssetRecordsProvenance(t *testing.T) { data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json") if err != nil { t.Fatalf("ReadFile() error = %v", err) } var asset spcOutlookBackgroundDefinitionAsset if err := json.Unmarshal(data, &asset); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if asset.Provenance.ReviewedOn == "" || asset.Provenance.ReviewOwner == "" || asset.Provenance.ReviewSchedule == "" { t.Fatalf("asset provenance = %#v, want review metadata", asset.Provenance) } wantSources := map[string]string{ "https://www.spc.noaa.gov/about/outlooks/": "categorical outlook descriptions", "https://www.spc.noaa.gov/exper/conditional-intensity-information": "conditional intensity group descriptions", } if len(asset.Provenance.Sources) != len(wantSources) { t.Fatalf("asset provenance sources = %#v, want %d authoritative sources", asset.Provenance.Sources, len(wantSources)) } for _, source := range asset.Provenance.Sources { if source.UpdatedOn == "" || wantSources[source.URL] != source.AppliesTo { t.Fatalf("asset provenance source = %#v, want authoritative source metadata", source) } delete(wantSources, source.URL) } if len(wantSources) != 0 { t.Fatalf("asset provenance missing sources = %#v", wantSources) } } 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, } }