diff --git a/internal/providers/nws/forecast_discussion.go b/internal/providers/nws/forecast_discussion.go index c36aa2a..e6901d8 100644 --- a/internal/providers/nws/forecast_discussion.go +++ b/internal/providers/nws/forecast_discussion.go @@ -121,22 +121,30 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) { IssuedAt: issuedAt.UTC(), } - if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleKeyMessages)); ok { - out.KeyMessages = parseForecastDiscussionKeyMessages(block.body) - } - if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm)); ok { - section, err := parseForecastDiscussionTextSection(block) - if err != nil { - return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) + seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles)) + for _, block := range parseForecastDiscussionSectionBlocks(lines) { + role, ok := forecastDiscussionSectionRoles[block.heading.section] + if !ok || seenRoles[role] { + continue } - out.ShortTerm = §ion - } - if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm)); ok { - section, err := parseForecastDiscussionTextSection(block) - if err != nil { - return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) + seenRoles[role] = true + + switch role { + case forecastDiscussionSectionRoleKeyMessages: + out.KeyMessages = parseForecastDiscussionKeyMessages(block.body) + case forecastDiscussionSectionRoleShortTerm: + section, err := parseForecastDiscussionTextSection(block) + if err != nil { + return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) + } + out.ShortTerm = §ion + case forecastDiscussionSectionRoleLongTerm: + section, err := parseForecastDiscussionTextSection(block) + if err != nil { + return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) + } + out.LongTerm = §ion } - out.LongTerm = §ion } return out, nil @@ -500,36 +508,42 @@ func isForecastDiscussionHorizontalWhitespace(b byte) bool { return b == ' ' || b == '\t' } -func forecastDiscussionSectionForRole(role forecastDiscussionSectionRole) string { - for section, registeredRole := range forecastDiscussionSectionRoles { - if registeredRole == role { - return section - } - } - return "" -} +func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock { + var blocks []forecastDiscussionSectionBlock + var active *forecastDiscussionSectionBlock -func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) { - for i, raw := range lines { - heading, ok := parseForecastDiscussionSectionHeading(raw) - if !ok || heading.section != section { + finish := func() { + if active == nil { + return + } + blocks = append(blocks, *active) + active = nil + } + + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "$$" { + finish() + break + } + if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") { + finish() continue } - block := forecastDiscussionSectionBlock{heading: heading} - for j := i + 1; j < len(lines); j++ { - next := strings.TrimSpace(lines[j]) - if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") { - break - } - if _, ok := parseForecastDiscussionSectionHeading(next); ok { - break - } - block.body = append(block.body, lines[j]) + heading, ok := parseForecastDiscussionSectionHeading(raw) + if ok { + finish() + active = &forecastDiscussionSectionBlock{heading: heading} + continue + } + if active != nil { + active.body = append(active.body, raw) } - return block, true } - return forecastDiscussionSectionBlock{}, false + finish() + + return blocks } func parseForecastDiscussionKeyMessages(body []string) []string { diff --git a/internal/providers/nws/forecast_discussion_test.go b/internal/providers/nws/forecast_discussion_test.go index 8c1f292..7d42e28 100644 --- a/internal/providers/nws/forecast_discussion_test.go +++ b/internal/providers/nws/forecast_discussion_test.go @@ -236,41 +236,82 @@ func TestForecastDiscussionSectionRoleHeadingsParse(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.", - }, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm)) - 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{ +func TestParseForecastDiscussionSectionBlocksRetainsOrderAndEmptyBodies(t *testing.T) { + got := parseForecastDiscussionSectionBlocks([]string{ ".SHORT TERM... (Tonight)", ".LONG TERM... (Tomorrow)", "Long-term prose.", - }, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm)) - if !ok { - t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block") + }) + want := []forecastDiscussionSectionBlock{ + {heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"}}, + { + heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"}, + body: []string{"Long-term prose."}, + }, } - if len(block.body) != 0 { - t.Fatalf("body = %#v, want empty body", block.body) + if !reflect.DeepEqual(got, want) { + t.Fatalf("blocks = %#v, want %#v", got, want) } } -func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testing.T) { - block, ok := extractForecastDiscussionSection([]string{ +func TestParseForecastDiscussionSectionBlocksStopsAtTerminators(t *testing.T) { + got := parseForecastDiscussionSectionBlocks([]string{ + "Preamble is ignored.", + ".KEY MESSAGES...", + "- First message.", + "&&", + ".SHORT TERM...", + "Short-term prose.", + ".LSX WATCHES/WARNINGS/ADVISORIES...", + "Watch text is ignored.", + "$$", + "WFO LSX", + }) + want := []forecastDiscussionSectionBlock{ + { + heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"}, + body: []string{"- First message."}, + }, + { + heading: forecastDiscussionSectionHeading{section: "SHORT TERM"}, + body: []string{"Short-term prose."}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("blocks = %#v, want %#v", got, want) + } +} + +func TestParseForecastDiscussionSectionBlocksUsesGenericHeadingsAsBoundaries(t *testing.T) { + got := parseForecastDiscussionSectionBlocks([]string{ + ".SHORT TERM...", + "Short-term prose.", + ".SYNOPSIS...", + "Synopsis prose.", + ".LONG TERM /Tomorrow/...", + "Long-term prose.", + }) + want := []forecastDiscussionSectionBlock{ + { + heading: forecastDiscussionSectionHeading{section: "SHORT TERM"}, + body: []string{"Short-term prose."}, + }, + { + heading: forecastDiscussionSectionHeading{section: "SYNOPSIS"}, + body: []string{"Synopsis prose."}, + }, + { + heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "Tomorrow"}, + body: []string{"Long-term prose."}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("blocks = %#v, want %#v", got, want) + } +} + +func TestParseForecastDiscussionSectionBlocksKeepsMalformedHeadingLikeLines(t *testing.T) { + got := parseForecastDiscussionSectionBlocks([]string{ ".SHORT TERM... (Tonight)", ".short term... Lowercase prose stays in the body.", ".LONG TERM /Tomorrow/..", @@ -278,18 +319,63 @@ func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testi "Expected short-term prose.", ".LONG TERM... (Tomorrow)", "Long-term prose.", - }, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm)) - if !ok { - t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block") + }) + want := []forecastDiscussionSectionBlock{ + { + heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"}, + body: []string{ + ".short term... Lowercase prose stays in the body.", + ".LONG TERM /Tomorrow/..", + ".LONG TERM /Tomorrow/... more", + "Expected short-term prose.", + }, + }, + { + heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"}, + body: []string{"Long-term prose."}, + }, } - wantBody := []string{ - ".short term... Lowercase prose stays in the body.", - ".LONG TERM /Tomorrow/..", - ".LONG TERM /Tomorrow/... more", - "Expected short-term prose.", + if !reflect.DeepEqual(got, want) { + t.Fatalf("blocks = %#v, want %#v", got, want) } - if !reflect.DeepEqual(block.body, wantBody) { - t.Fatalf("body = %#v, want %#v", block.body, wantBody) +} + +func TestParseForecastDiscussionTextKeepsFirstMappedSections(t *testing.T) { + text := strings.Join([]string{ + "AFDLSX", + "National Weather Service Saint Louis MO", + "224 PM CDT Sat Mar 28 2026", + "", + ".KEY MESSAGES...", + "&&", + ".KEY MESSAGES...", + "- Second key message.", + "&&", + ".SHORT TERM... (First short term)", + "First short-term prose.", + "&&", + ".SHORT TERM... (Second short term)", + "Second short-term prose.", + "&&", + ".LONG TERM... (First long term)", + "First long-term prose.", + "&&", + ".LONG TERM... (Second long term)", + "Second long-term prose.", + }, "\n") + + got, err := ParseForecastDiscussionText(text) + if err != nil { + t.Fatalf("ParseForecastDiscussionText() error = %v", err) + } + if len(got.KeyMessages) != 0 { + t.Fatalf("KeyMessages = %#v, want empty first block", got.KeyMessages) + } + if got.ShortTerm == nil || got.ShortTerm.Qualifier != "(First short term)" || got.ShortTerm.Text != "First short-term prose." { + t.Fatalf("ShortTerm = %#v, want first section", got.ShortTerm) + } + if got.LongTerm == nil || got.LongTerm.Qualifier != "(First long term)" || got.LongTerm.Text != "First long-term prose." { + t.Fatalf("LongTerm = %#v, want first section", got.LongTerm) } }