258 lines
10 KiB
Go
258 lines
10 KiB
Go
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,
|
|
}
|
|
}
|