diff --git a/internal/providers/nws/forecast_discussion.go b/internal/providers/nws/forecast_discussion.go index 164852e..60b8cd8 100644 --- a/internal/providers/nws/forecast_discussion.go +++ b/internal/providers/nws/forecast_discussion.go @@ -27,11 +27,31 @@ type ForecastDiscussionSection struct { Text string } +const ( + forecastDiscussionSectionKeyMessages = "KEY MESSAGES" + forecastDiscussionSectionShortTerm = "SHORT TERM" + forecastDiscussionSectionLongTerm = "LONG TERM" + forecastDiscussionSectionAviation = "AVIATION" + + forecastDiscussionSectionIdentityPattern = "KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION" +) + +type forecastDiscussionSectionHeading struct { + section string + qualifier string +} + +type forecastDiscussionSectionBlock struct { + heading forecastDiscussionSectionHeading + body []string +} + var ( - forecastDiscussionHeaderRE = regexp.MustCompile(`^\.(KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION)\.\.\.(.*)$`) - forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`) - forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`) - forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`) + forecastDiscussionEllipsisHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)\.\.\.(.*)$`) + forecastDiscussionSlashHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)[ \t]+/([^/]+)/\.\.\.$`) + forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`) + forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`) + forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`) ) func ParseForecastDiscussionHTML(raw string) (ForecastDiscussion, error) { @@ -99,20 +119,20 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) { IssuedAt: issuedAt.UTC(), } - if block, ok := extractForecastDiscussionSection(lines, "KEY MESSAGES"); ok { - out.KeyMessages = parseForecastDiscussionKeyMessages(block) + if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionKeyMessages); ok { + out.KeyMessages = parseForecastDiscussionKeyMessages(block.body) } - if block, ok := extractForecastDiscussionSection(lines, "SHORT TERM"); ok { + if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionShortTerm); ok { section, err := parseForecastDiscussionTextSection(block) if err != nil { - return ForecastDiscussion{}, fmt.Errorf("parse SHORT TERM: %w", err) + return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionShortTerm, err) } out.ShortTerm = §ion } - if block, ok := extractForecastDiscussionSection(lines, "LONG TERM"); ok { + if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionLongTerm); ok { section, err := parseForecastDiscussionTextSection(block) if err != nil { - return ForecastDiscussion{}, fmt.Errorf("parse LONG TERM: %w", err) + return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionLongTerm, err) } out.LongTerm = §ion } @@ -386,40 +406,56 @@ func forecastDiscussionLocation(abbrev string) (*time.Location, error) { return time.FixedZone(abbr, offset), nil } -func extractForecastDiscussionSection(lines []string, section string) ([]string, bool) { - target := "." + section + "..." +func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) { + line = strings.TrimSpace(line) + if m := forecastDiscussionEllipsisHeadingRE.FindStringSubmatch(line); len(m) == 3 { + return forecastDiscussionSectionHeading{ + section: m[1], + qualifier: strings.TrimSpace(m[2]), + }, true + } + if m := forecastDiscussionSlashHeadingRE.FindStringSubmatch(line); len(m) == 3 { + qualifier := strings.TrimSpace(m[2]) + if qualifier == "" { + return forecastDiscussionSectionHeading{}, false + } + return forecastDiscussionSectionHeading{ + section: m[1], + qualifier: qualifier, + }, true + } + return forecastDiscussionSectionHeading{}, false +} + +func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) { for i, raw := range lines { - line := strings.TrimSpace(raw) - if !strings.HasPrefix(line, target) { + heading, ok := parseForecastDiscussionSectionHeading(raw) + if !ok || heading.section != section { continue } - out := []string{line} + 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 j > i+1 && isForecastDiscussionSectionHeader(next) { + if _, ok := parseForecastDiscussionSectionHeading(next); ok { break } - out = append(out, lines[j]) + block.body = append(block.body, lines[j]) } - return out, true + return block, true } - return nil, false + return forecastDiscussionSectionBlock{}, false } -func isForecastDiscussionSectionHeader(line string) bool { - return forecastDiscussionHeaderRE.MatchString(strings.TrimSpace(line)) -} - -func parseForecastDiscussionKeyMessages(block []string) []string { - if len(block) <= 1 { +func parseForecastDiscussionKeyMessages(body []string) []string { + if len(body) == 0 { return nil } - body := trimBlankLines(block[1:]) + body = trimBlankLines(body) var messages []string var current strings.Builder @@ -452,16 +488,12 @@ func parseForecastDiscussionKeyMessages(block []string) []string { return messages } -func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSection, error) { - if len(block) == 0 { - return ForecastDiscussionSection{}, fmt.Errorf("empty section") - } - +func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) { section := ForecastDiscussionSection{ - Qualifier: parseForecastDiscussionQualifier(strings.TrimSpace(block[0])), + Qualifier: block.heading.qualifier, } - body := trimBlankLines(block[1:]) + body := trimBlankLines(block.body) if len(body) == 0 { return section, nil } @@ -482,14 +514,6 @@ func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSecti return section, nil } -func parseForecastDiscussionQualifier(header string) string { - m := forecastDiscussionHeaderRE.FindStringSubmatch(header) - if len(m) != 3 { - return "" - } - return strings.TrimSpace(m[2]) -} - func trimBlankLines(lines []string) []string { start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" { diff --git a/internal/providers/nws/forecast_discussion_test.go b/internal/providers/nws/forecast_discussion_test.go index ae3f6af..5faf742 100644 --- a/internal/providers/nws/forecast_discussion_test.go +++ b/internal/providers/nws/forecast_discussion_test.go @@ -8,6 +8,135 @@ import ( "time" ) +func TestParseForecastDiscussionSectionHeading(t *testing.T) { + tests := []struct { + name string + line string + wantSection string + wantQualifier string + wantOK bool + }{ + { + name: "ellipsis-first key messages", + line: ".KEY MESSAGES... (Through Tonight)", + wantSection: forecastDiscussionSectionKeyMessages, + wantQualifier: "(Through Tonight)", + wantOK: true, + }, + { + name: "ellipsis-first short term", + line: ".SHORT TERM... (Sunday)", + wantSection: forecastDiscussionSectionShortTerm, + wantQualifier: "(Sunday)", + wantOK: true, + }, + { + name: "ellipsis-first long term", + line: ".LONG TERM... (Monday Through Friday)", + wantSection: forecastDiscussionSectionLongTerm, + wantQualifier: "(Monday Through Friday)", + wantOK: true, + }, + { + name: "ellipsis-first aviation without qualifier", + line: ".AVIATION...", + wantSection: forecastDiscussionSectionAviation, + wantOK: true, + }, + { + name: "slash-qualified key messages", + line: ".KEY MESSAGES /Tonight/...", + wantSection: forecastDiscussionSectionKeyMessages, + wantQualifier: "Tonight", + wantOK: true, + }, + { + name: "slash-qualified short term with outer whitespace", + line: " .SHORT TERM /Through Late Sunday Night/... ", + wantSection: forecastDiscussionSectionShortTerm, + wantQualifier: "Through Late Sunday Night", + wantOK: true, + }, + { + name: "slash-qualified long term", + line: ".LONG TERM /Monday through Next Saturday/...", + wantSection: forecastDiscussionSectionLongTerm, + wantQualifier: "Monday through Next Saturday", + wantOK: true, + }, + { + name: "slash-qualified aviation", + line: ".AVIATION /18Z TAFS/...", + wantSection: forecastDiscussionSectionAviation, + wantQualifier: "18Z TAFS", + wantOK: true, + }, + { + name: "unknown identity", + line: ".SYNOPSIS... Overview", + wantOK: false, + }, + { + name: "slash qualifier missing opening slash", + line: ".SHORT TERM Through Tonight/...", + wantOK: false, + }, + { + name: "slash qualifier missing closing slash", + line: ".SHORT TERM /Through Tonight...", + wantOK: false, + }, + { + name: "slash qualifier contains embedded slash", + line: ".SHORT TERM /Tonight/Sunday/...", + wantOK: false, + }, + { + name: "slash qualifier whitespace only", + line: ".SHORT TERM / /...", + wantOK: false, + }, + { + name: "slash qualifier missing required whitespace", + line: ".SHORT TERM/Through Tonight/...", + wantOK: false, + }, + { + name: "slash qualifier missing final ellipsis", + line: ".SHORT TERM /Through Tonight/..", + wantOK: false, + }, + { + name: "slash qualifier has trailing text", + line: ".SHORT TERM /Through Tonight/... more", + wantOK: false, + }, + { + name: "lowercase identity", + line: ".short term... (Tonight)", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseForecastDiscussionSectionHeading(tt.line) + if ok != tt.wantOK { + t.Fatalf("parseForecastDiscussionSectionHeading(%q) ok = %t, want %t", tt.line, ok, tt.wantOK) + } + if !ok { + return + } + if got.section != tt.wantSection { + t.Fatalf("section = %q, want %q", got.section, tt.wantSection) + } + if got.qualifier != tt.wantQualifier { + t.Fatalf("qualifier = %q, want %q", got.qualifier, tt.wantQualifier) + } + }) + } +} + func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) { raw := loadForecastDiscussionSampleHTML(t)