diff --git a/internal/normalizers/nws/forecast_discussion_test.go b/internal/normalizers/nws/forecast_discussion_test.go index bbe864f..b812040 100644 --- a/internal/normalizers/nws/forecast_discussion_test.go +++ b/internal/normalizers/nws/forecast_discussion_test.go @@ -71,6 +71,70 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) { } } +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 TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) { _, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{ ID: "evt-discussion-bad", @@ -128,3 +192,34 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string { } 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 +} diff --git a/internal/providers/nws/forecast_discussion_test.go b/internal/providers/nws/forecast_discussion_test.go index 5faf742..50f2f16 100644 --- a/internal/providers/nws/forecast_discussion_test.go +++ b/internal/providers/nws/forecast_discussion_test.go @@ -3,6 +3,7 @@ package nws import ( "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -137,6 +138,63 @@ func TestParseForecastDiscussionSectionHeading(t *testing.T) { } } +func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) { + block, ok := extractForecastDiscussionSection([]string{ + ".LONG TERM /Monday through Next Saturday/...", + "Long-term prose.", + ".AVIATION /18Z TAFS/...", + "Aviation prose.", + }, forecastDiscussionSectionLongTerm) + if !ok { + t.Fatalf("extractForecastDiscussionSection() found no LONG TERM block") + } + if block.heading.qualifier != "Monday through Next Saturday" { + t.Fatalf("qualifier = %q, want normalized slash qualifier", block.heading.qualifier) + } + wantBody := []string{"Long-term prose."} + if !reflect.DeepEqual(block.body, wantBody) { + t.Fatalf("body = %#v, want %#v", block.body, wantBody) + } +} + +func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing.T) { + block, ok := extractForecastDiscussionSection([]string{ + ".SHORT TERM... (Tonight)", + ".LONG TERM... (Tomorrow)", + "Long-term prose.", + }, forecastDiscussionSectionShortTerm) + if !ok { + t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block") + } + if len(block.body) != 0 { + t.Fatalf("body = %#v, want empty body", block.body) + } +} + +func TestExtractForecastDiscussionSectionKeepsUnrecognizedHeadingLikeLines(t *testing.T) { + block, ok := extractForecastDiscussionSection([]string{ + ".SHORT TERM... (Tonight)", + ".SYNOPSIS... This unknown section stays in the body.", + ".LONG TERM /Tomorrow/..", + ".LONG TERM /Tomorrow/Next Week/...", + "Expected short-term prose.", + ".LONG TERM... (Tomorrow)", + "Long-term prose.", + }, forecastDiscussionSectionShortTerm) + if !ok { + t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block") + } + wantBody := []string{ + ".SYNOPSIS... This unknown section stays in the body.", + ".LONG TERM /Tomorrow/..", + ".LONG TERM /Tomorrow/Next Week/...", + "Expected short-term prose.", + } + if !reflect.DeepEqual(block.body, wantBody) { + t.Fatalf("body = %#v, want %#v", block.body, wantBody) + } +} + func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) { raw := loadForecastDiscussionSampleHTML(t) @@ -215,6 +273,51 @@ func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) { } } +func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) { + legacy, err := ParseForecastDiscussionHTML(loadForecastDiscussionSampleHTML(t)) + if err != nil { + t.Fatalf("ParseForecastDiscussionHTML() legacy error = %v", err) + } + + got, err := ParseForecastDiscussionHTML(loadMixedFormatForecastDiscussionSampleHTML(t)) + if err != nil { + t.Fatalf("ParseForecastDiscussionHTML() mixed-format error = %v", err) + } + if !reflect.DeepEqual(got.KeyMessages, legacy.KeyMessages) { + t.Fatalf("KeyMessages = %#v, want unchanged %#v", got.KeyMessages, legacy.KeyMessages) + } + + if got.ShortTerm == nil { + t.Fatalf("ShortTerm is nil") + } + if got.ShortTerm.Qualifier != "Through Late Sunday Night" { + t.Fatalf("ShortTerm.Qualifier = %q", got.ShortTerm.Qualifier) + } + wantIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC) + if got.ShortTerm.IssuedAt == nil || !got.ShortTerm.IssuedAt.Equal(wantIssuedAt) { + t.Fatalf("ShortTerm.IssuedAt = %v, want %s", got.ShortTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339)) + } + if !strings.Contains(got.ShortTerm.Text, "After a chilly morning") { + t.Fatalf("ShortTerm.Text missing expected prose: %q", got.ShortTerm.Text) + } + + if got.LongTerm == nil { + t.Fatalf("LongTerm is nil") + } + if got.LongTerm.Qualifier != "Monday through Next Saturday" { + t.Fatalf("LongTerm.Qualifier = %q", got.LongTerm.Qualifier) + } + if got.LongTerm.IssuedAt == nil || !got.LongTerm.IssuedAt.Equal(wantIssuedAt) { + t.Fatalf("LongTerm.IssuedAt = %v, want %s", got.LongTerm.IssuedAt, wantIssuedAt.Format(time.RFC3339)) + } + if !strings.Contains(got.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") { + t.Fatalf("LongTerm.Text missing expected prose: %q", got.LongTerm.Text) + } + if strings.Contains(got.LongTerm.Text, "AVIATION") || strings.Contains(got.LongTerm.Text, "VFR conditions are expected") { + t.Fatalf("LongTerm.Text includes aviation content: %q", got.LongTerm.Text) + } +} + func TestParseForecastDiscussionHTMLMissingPreBlock(t *testing.T) { _, err := ParseForecastDiscussionHTML("