package nws import ( "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" "time" "gitea.maximumdirect.net/ejr/feedkit/event" "gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) { rawHTML := loadForecastDiscussionSampleHTML(t) out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-1", Kind: event.Kind(standards.KindForecastDiscussion), Source: "nws-discussion-test", EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC), Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: rawHTML, }) if err != nil { t.Fatalf("Normalize() error = %v", err) } if out == nil { t.Fatalf("Normalize() returned nil output") } if out.Schema != standards.SchemaWeatherForecastDiscussionV1 { t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1) } if out.Kind != event.Kind(standards.KindForecastDiscussion) { t.Fatalf("Kind = %q, want forecast_discussion", out.Kind) } payload, ok := out.Payload.(model.WeatherForecastDiscussion) if !ok { t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) } if payload.OfficeID != "LSX" { t.Fatalf("OfficeID = %q, want LSX", payload.OfficeID) } if payload.Product != model.ForecastDiscussionProductAFD { t.Fatalf("Product = %q, want %q", payload.Product, model.ForecastDiscussionProductAFD) } if len(payload.KeyMessages) != 3 { t.Fatalf("KeyMessages len = %d, want 3", len(payload.KeyMessages)) } if payload.ShortTerm == nil || payload.LongTerm == nil { t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm) } if payload.ShortTerm.Qualifier != "(Through Late Sunday Night)" { t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier) } if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") { t.Fatalf("ShortTerm.Text = %q, want normalized prose", payload.ShortTerm.Text) } if strings.Contains(payload.ShortTerm.Text, "BRC") { t.Fatalf("ShortTerm.Text contains signature: %q", payload.ShortTerm.Text) } if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "WATCHES/WARNINGS/ADVISORIES") { t.Fatalf("LongTerm.Text includes downstream sections: %q", payload.LongTerm.Text) } wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC) if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) { t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339)) } } func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) { out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-mixed-format", Kind: event.Kind(standards.KindForecastDiscussion), Source: "nws-discussion-test", EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC), Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: loadMixedFormatForecastDiscussionSampleHTML(t), }) if err != nil { t.Fatalf("Normalize() error = %v", err) } if out == nil { t.Fatalf("Normalize() returned nil output") } if out.Kind != event.Kind(standards.KindForecastDiscussion) { t.Fatalf("Kind = %q, want forecast_discussion", out.Kind) } if out.Schema != standards.SchemaWeatherForecastDiscussionV1 { t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1) } wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC) if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) { t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339)) } payload, ok := out.Payload.(model.WeatherForecastDiscussion) if !ok { t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) } if payload.ShortTerm == nil || payload.LongTerm == nil { t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm) } if payload.ShortTerm.Qualifier != "Through Late Sunday Night" { t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier) } if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") { t.Fatalf("ShortTerm.Text missing expected prose: %q", payload.ShortTerm.Text) } if payload.LongTerm.Qualifier != "Monday through Next Saturday" { t.Fatalf("LongTerm.Qualifier = %q", payload.LongTerm.Qualifier) } if !strings.Contains(payload.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") { t.Fatalf("LongTerm.Text missing expected prose: %q", payload.LongTerm.Text) } if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "VFR conditions are expected") { t.Fatalf("LongTerm.Text includes aviation content: %q", payload.LongTerm.Text) } b, err := json.Marshal(out.Payload) if err != nil { t.Fatalf("json.Marshal(payload) error = %v", err) } var fields map[string]any if err := json.Unmarshal(b, &fields); err != nil { t.Fatalf("json.Unmarshal(payload) error = %v", err) } for _, key := range []string{"aviation", "sections"} { if _, ok := fields[key]; ok { t.Fatalf("unexpected key %q in canonical payload", key) } } } func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) { in := event.Event{ ID: "evt-discussion-bou", Kind: event.Kind(standards.KindForecastDiscussion), Source: "nws-discussion-bou-test", EmittedAt: time.Date(2026, 4, 7, 19, 1, 0, 0, time.UTC), Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: loadForecastDiscussionBOUSampleHTML(t), } out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in) if err != nil { t.Fatalf("Normalize() error = %v", err) } if out == nil { t.Fatalf("Normalize() returned nil output") } if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) { t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out) } if out.Kind != event.Kind(standards.KindForecastDiscussion) { t.Fatalf("Kind = %q, want forecast_discussion", out.Kind) } if out.Schema != standards.SchemaWeatherForecastDiscussionV1 { t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1) } wantEffectiveAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC) if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) { t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339)) } payload, ok := out.Payload.(model.WeatherForecastDiscussion) if !ok { t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) } if payload.OfficeID != "BOU" || payload.OfficeName != "National Weather Service Denver CO" { t.Fatalf("OfficeID=%q OfficeName=%q", payload.OfficeID, payload.OfficeName) } wantMessages := []string{ "Strong winds are expected along the Front Range this evening.", "Cooler temperatures arrive on Wednesday.", } if len(payload.KeyMessages) != len(wantMessages) { t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, wantMessages) } for i := range wantMessages { if payload.KeyMessages[i] != wantMessages[i] { t.Fatalf("KeyMessages[%d] = %q, want %q", i, payload.KeyMessages[i], wantMessages[i]) } } if payload.ShortTerm == nil || payload.LongTerm == nil { t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm) } if payload.ShortTerm.Qualifier != "(Tonight through Wednesday)" || payload.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." { t.Fatalf("ShortTerm = %#v", payload.ShortTerm) } if payload.LongTerm.Qualifier != "(Thursday through Saturday)" || payload.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." { t.Fatalf("LongTerm = %#v", payload.LongTerm) } if payload.ShortTerm.IssuedAt == nil || payload.LongTerm.IssuedAt == nil || !payload.ShortTerm.IssuedAt.Equal(wantEffectiveAt) || !payload.LongTerm.IssuedAt.Equal(wantEffectiveAt) { t.Fatalf("section issue times = short %v long %v, want %s", payload.ShortTerm.IssuedAt, payload.LongTerm.IssuedAt, wantEffectiveAt.Format(time.RFC3339)) } b, err := json.Marshal(out.Payload) if err != nil { t.Fatalf("json.Marshal(payload) error = %v", err) } var fields map[string]any if err := json.Unmarshal(b, &fields); err != nil { t.Fatalf("json.Unmarshal(payload) error = %v", err) } for _, key := range []string{"aviation", "discussion", "sections"} { if _, ok := fields[key]; ok { t.Fatalf("unexpected key %q in canonical payload", key) } } } func TestForecastDiscussionNormalizerSupportsCrossOfficeKeyMessageFixtures(t *testing.T) { tests := []struct { name string filename string id string source string emittedAt time.Time effectiveAt time.Time messages []string }{ { name: "numbered key messages", filename: "forecast_discussion_bgm_numbered_sample.html", id: "evt-discussion-bgm", source: "nws-discussion-bgm-test", emittedAt: time.Date(2026, 4, 10, 17, 31, 0, 0, time.UTC), effectiveAt: time.Date(2026, 4, 10, 17, 30, 0, 0, time.UTC), messages: []string{ "Periods of rain are expected through Saturday, with locally heavier amounts possible.", "Cooler temperatures return late this weekend.", }, }, { name: "key points alias", filename: "forecast_discussion_mfr_key_points_sample.html", id: "evt-discussion-mfr", source: "nws-discussion-mfr-test", emittedAt: time.Date(2026, 4, 10, 19, 46, 0, 0, time.UTC), effectiveAt: time.Date(2026, 4, 10, 19, 45, 0, 0, time.UTC), messages: []string{ "Gusty winds will develop over exposed ridges, especially during the afternoon.", "Inland valleys remain dry through Saturday.", }, }, { name: "as of preamble", filename: "forecast_discussion_rah_as_of_sample.html", id: "evt-discussion-rah", source: "nws-discussion-rah-test", emittedAt: time.Date(2026, 8, 2, 16, 36, 0, 0, time.UTC), effectiveAt: time.Date(2026, 8, 2, 16, 35, 0, 0, time.UTC), messages: []string{ "Scattered storms may produce locally heavy rain this afternoon.", "Drier weather arrives Monday.", }, }, { name: "parenthesized numeric markers", filename: "forecast_discussion_lwx_parenthesized_number_sample.html", id: "evt-discussion-lwx", source: "nws-discussion-lwx-test", emittedAt: time.Date(2026, 8, 2, 18, 1, 0, 0, time.UTC), effectiveAt: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC), messages: []string{ "Thunderstorms remain possible near the Blue Ridge this evening.", "Seasonably warm conditions continue Monday.", }, }, { name: "embedded key messages in previous discussion", filename: "forecast_discussion_mfr_prev_discussion_sample.html", id: "evt-discussion-mfr-previous", source: "nws-discussion-mfr-previous-test", emittedAt: time.Date(2026, 8, 2, 22, 20, 0, 0, time.UTC), effectiveAt: time.Date(2026, 8, 2, 22, 19, 0, 0, time.UTC), messages: []string{ "Heat returns to inland valleys Monday.", "Gusty afternoon winds develop east of the Cascades.", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { in := event.Event{ ID: tt.id, Kind: event.Kind(standards.KindForecastDiscussion), Source: tt.source, EmittedAt: tt.emittedAt, Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: loadForecastDiscussionFixtureHTML(t, tt.filename), } out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in) if err != nil { t.Fatalf("Normalize() error = %v", err) } if out == nil { t.Fatalf("Normalize() returned nil output") } if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) { t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out) } if out.Kind != event.Kind(standards.KindForecastDiscussion) { t.Fatalf("Kind = %q, want forecast_discussion", out.Kind) } if out.Schema != standards.SchemaWeatherForecastDiscussionV1 { t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1) } if out.EffectiveAt == nil || !out.EffectiveAt.Equal(tt.effectiveAt) { t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, tt.effectiveAt.Format(time.RFC3339)) } payload, ok := out.Payload.(model.WeatherForecastDiscussion) if !ok { t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload) } if !reflect.DeepEqual(payload.KeyMessages, tt.messages) { t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, tt.messages) } b, err := json.Marshal(payload) if err != nil { t.Fatalf("json.Marshal(payload) error = %v", err) } var fields map[string]any if err := json.Unmarshal(b, &fields); err != nil { t.Fatalf("json.Unmarshal(payload) error = %v", err) } for _, key := range []string{"nearTerm", "discussion", "aviation", "sections"} { if _, ok := fields[key]; ok { t.Fatalf("unexpected key %q in canonical payload", key) } } }) } } func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) { _, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-bad", Kind: event.Kind(standards.KindForecastDiscussion), Source: "nws-discussion-test", EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC), Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: "
National Weather Service Saint Louis MO", }) if err == nil { t.Fatalf("Normalize() error = nil, want error") } if !strings.Contains(err.Error(), "issue time") { t.Fatalf("error = %q, want issue time context", err) } } func TestForecastDiscussionNormalizerWireShapeHasNoUnexpectedKeys(t *testing.T) { rawHTML := loadForecastDiscussionSampleHTML(t) out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-2", Kind: event.Kind(standards.KindForecastDiscussion), Source: "nws-discussion-test", EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC), Schema: standards.SchemaRawNWSForecastDiscussionV1, Payload: rawHTML, }) if err != nil { t.Fatalf("Normalize() error = %v", err) } b, err := json.Marshal(out.Payload) if err != nil { t.Fatalf("json.Marshal(payload) error = %v", err) } var got map[string]any if err := json.Unmarshal(b, &got); err != nil { t.Fatalf("json.Unmarshal(payload) error = %v", err) } for _, key := range []string{"sections", "aviation"} { if _, ok := got[key]; ok { t.Fatalf("unexpected key %q in canonical payload", key) } } } func loadForecastDiscussionSampleHTML(t *testing.T) string { t.Helper() path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_sample.html") b, err := os.ReadFile(path) if err != nil { t.Fatalf("os.ReadFile(%q) error = %v", path, err) } return string(b) } func loadForecastDiscussionBOUSampleHTML(t *testing.T) string { t.Helper() path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_bou_sample.html") b, err := os.ReadFile(path) if err != nil { t.Fatalf("os.ReadFile(%q) error = %v", path, err) } return string(b) } func loadForecastDiscussionFixtureHTML(t *testing.T, filename string) string { t.Helper() path := filepath.Join("..", "..", "providers", "nws", "testdata", filename) b, err := os.ReadFile(path) if err != nil { t.Fatalf("os.ReadFile(%q) error = %v", path, err) } return string(b) } func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string { t.Helper() raw := loadForecastDiscussionSampleHTML(t) replacements := []struct { original string replacement string }{ { original: ".SHORT TERM... (Through Late Sunday Night)", replacement: ".SHORT TERM /Through Late Sunday Night/...", }, { original: ".LONG TERM... (Monday through Next Saturday)", replacement: ".LONG TERM /Monday through Next Saturday/...", }, { original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)", replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...", }, } for _, replacement := range replacements { if !strings.Contains(raw, replacement.original) { t.Fatalf("fixture missing heading %q", replacement.original) } raw = strings.Replace(raw, replacement.original, replacement.replacement, 1) } return raw }