375 lines
15 KiB
Go
375 lines
15 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)
|
|
}
|
|
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,
|
|
}
|
|
}
|