Handle additional NWS forecast discussion formats
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
This commit is contained in:
@@ -215,7 +215,7 @@ func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerSupportsNumberedAndKeyPointsFixtures(t *testing.T) {
|
||||
func TestForecastDiscussionNormalizerSupportsCrossOfficeKeyMessageFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
@@ -249,6 +249,42 @@ func TestForecastDiscussionNormalizerSupportsNumberedAndKeyPointsFixtures(t *tes
|
||||
"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 {
|
||||
|
||||
@@ -550,6 +550,7 @@ func isForecastDiscussionHorizontalWhitespace(b byte) bool {
|
||||
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
|
||||
var blocks []forecastDiscussionSectionBlock
|
||||
var active *forecastDiscussionSectionBlock
|
||||
embeddedHeadings := false
|
||||
|
||||
finish := func() {
|
||||
if active == nil {
|
||||
@@ -567,6 +568,7 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
|
||||
}
|
||||
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
|
||||
finish()
|
||||
embeddedHeadings = false
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -574,8 +576,17 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
|
||||
if ok {
|
||||
finish()
|
||||
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||
embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section)
|
||||
continue
|
||||
}
|
||||
if embeddedHeadings {
|
||||
heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw)
|
||||
if ok {
|
||||
finish()
|
||||
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if active != nil {
|
||||
active.body = append(active.body, raw)
|
||||
}
|
||||
@@ -585,6 +596,18 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
|
||||
return blocks
|
||||
}
|
||||
|
||||
func isForecastDiscussionEmbeddedSectionWrapper(section string) bool {
|
||||
return section == "PREV DISCUSSION"
|
||||
}
|
||||
|
||||
func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line[0] == '.' {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return parseForecastDiscussionSectionHeading("." + line)
|
||||
}
|
||||
|
||||
func parseForecastDiscussionKeyMessages(body []string) []string {
|
||||
body = removeForecastDiscussionPresentationMarkers(body)
|
||||
body = trimBlankLines(body)
|
||||
@@ -716,7 +739,32 @@ func isForecastDiscussionKeyMessageMetadataLine(line string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return isForecastDiscussionKeyMessageAsOfLine(line)
|
||||
}
|
||||
|
||||
func isForecastDiscussionKeyMessageAsOfLine(line string) bool {
|
||||
const label = "As of"
|
||||
|
||||
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
||||
return false
|
||||
}
|
||||
remainder := strings.TrimSpace(line[len(label):])
|
||||
if !strings.HasSuffix(remainder, "...") {
|
||||
return false
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(strings.TrimSuffix(remainder, "...")))
|
||||
if len(fields) != 3 {
|
||||
return false
|
||||
}
|
||||
if _, _, err := parseForecastDiscussionClock(fields[0], fields[1]); err != nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(fields[2]) {
|
||||
case "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
|
||||
@@ -740,11 +788,23 @@ func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
|
||||
}
|
||||
|
||||
func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool) {
|
||||
digitStart := 0
|
||||
digitEnd := 0
|
||||
parenthesized := len(line) > 0 && line[0] == '('
|
||||
if parenthesized {
|
||||
digitStart = 1
|
||||
digitEnd = 1
|
||||
}
|
||||
for digitEnd < len(line) && line[digitEnd] >= '0' && line[digitEnd] <= '9' {
|
||||
digitEnd++
|
||||
}
|
||||
if digitEnd == 0 || digitEnd == len(line) || (line[digitEnd] != ')' && line[digitEnd] != '.') {
|
||||
if digitEnd == digitStart || digitEnd == len(line) {
|
||||
return "", false
|
||||
}
|
||||
if parenthesized && line[digitEnd] != ')' {
|
||||
return "", false
|
||||
}
|
||||
if !parenthesized && line[digitEnd] != ')' && line[digitEnd] != '.' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -752,7 +812,7 @@ func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool)
|
||||
if markerEnd < len(line) && !isForecastDiscussionHorizontalWhitespace(line[markerEnd]) {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.ParseUint(line[:digitEnd], 10, 0)
|
||||
value, err := strconv.ParseUint(line[digitStart:digitEnd], 10, 0)
|
||||
if err != nil || value == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -391,6 +391,58 @@ func TestParseForecastDiscussionSectionBlocksUsesGenericHeadingsAsBoundaries(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForecastDiscussionSectionBlocksRecognizesEmbeddedHeadingsInPreviousDiscussion(t *testing.T) {
|
||||
got := parseForecastDiscussionSectionBlocks([]string{
|
||||
".PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/",
|
||||
"KEY MESSAGES...",
|
||||
"* First embedded message.",
|
||||
"DISCUSSION...",
|
||||
"Discussion prose.",
|
||||
"&&",
|
||||
})
|
||||
want := []forecastDiscussionSectionBlock{
|
||||
{
|
||||
heading: forecastDiscussionSectionHeading{
|
||||
section: "PREV DISCUSSION",
|
||||
qualifier: "/Issued 319 PM PDT Sun Aug 2 2026/",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"},
|
||||
body: []string{"* First embedded message."},
|
||||
},
|
||||
{
|
||||
heading: forecastDiscussionSectionHeading{section: "DISCUSSION"},
|
||||
body: []string{"Discussion prose."},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("blocks = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForecastDiscussionSectionBlocksKeepsUndottedHeadingLikeLinesOutsideWrappers(t *testing.T) {
|
||||
got := parseForecastDiscussionSectionBlocks([]string{
|
||||
".SHORT TERM...",
|
||||
"Short-term prose.",
|
||||
"KEY MESSAGES...",
|
||||
"Still short-term prose.",
|
||||
})
|
||||
want := []forecastDiscussionSectionBlock{
|
||||
{
|
||||
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
|
||||
body: []string{
|
||||
"Short-term prose.",
|
||||
"KEY MESSAGES...",
|
||||
"Still short-term prose.",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("blocks = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForecastDiscussionSectionBlocksUsesParenthesizedTerminalHeadingAsBoundary(t *testing.T) {
|
||||
got := parseForecastDiscussionSectionBlocks([]string{
|
||||
".SHORT TERM... (Tonight)",
|
||||
@@ -756,6 +808,16 @@ func TestIsForecastDiscussionKeyMessageMetadataLine(t *testing.T) {
|
||||
line: "uPdAtEd At 300 PM CDT Sat Mar 28 2026",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "as of weekday preamble",
|
||||
line: "As of 1235 PM Sunday...",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "as of ASCII case insensitive",
|
||||
line: "aS oF 1235 pm SuNdAy...",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "label without whitespace boundary",
|
||||
line: "Updated at300 PM CDT Sat Mar 28 2026",
|
||||
@@ -766,6 +828,26 @@ func TestIsForecastDiscussionKeyMessageMetadataLine(t *testing.T) {
|
||||
line: "Updated at not a timestamp",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "as of invalid clock",
|
||||
line: "As of 2535 PM Sunday...",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "as of invalid weekday",
|
||||
line: "As of 1235 PM Someday...",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "as of without terminal ellipsis",
|
||||
line: "As of 1235 PM Sunday",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "as of prefix collision",
|
||||
line: "As often happens, storms weaken overnight.",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nonmetadata prose",
|
||||
line: "Updated atmospheric conditions remain unsettled.",
|
||||
@@ -794,13 +876,18 @@ func TestStripForecastDiscussionKeyMessageMarker(t *testing.T) {
|
||||
{name: "asterisk", line: "* First message.", want: "First message.", ok: true},
|
||||
{name: "numeric parenthesis", line: "1) First message.", want: "First message.", ok: true},
|
||||
{name: "numeric dot", line: "2. Second message.", want: "Second message.", ok: true},
|
||||
{name: "parenthesized numeric", line: "(1) First message.", want: "First message.", ok: true},
|
||||
{name: "composite hyphen", line: "- 1. First message.", want: "First message.", ok: true},
|
||||
{name: "composite asterisk", line: "* 1) First message.", want: "First message.", ok: true},
|
||||
{name: "composite parenthesized numeric", line: "- (1) First message.", want: "First message.", ok: true},
|
||||
{name: "composite asterisk parenthesized numeric", line: "* (1) First message.", want: "First message.", ok: true},
|
||||
{name: "multi digit numeric", line: "12. Twelfth message.", want: "Twelfth message.", ok: true},
|
||||
{name: "hyphen only", line: "-", want: "", ok: true},
|
||||
{name: "numeric marker only", line: "1)", want: "", ok: true},
|
||||
{name: "zero numeric marker", line: "0) Not a marker.", want: "", ok: false},
|
||||
{name: "zero parenthesized numeric marker", line: "(0) Not a marker.", want: "", ok: false},
|
||||
{name: "overflow numeric marker", line: "999999999999999999999999999999. Too big.", want: "", ok: false},
|
||||
{name: "parenthesized numeric marker without boundary", line: "(1)Not a marker.", want: "", ok: false},
|
||||
{name: "alphanumeric numeric prefix", line: "1x Not a marker.", want: "", ok: false},
|
||||
{name: "numeric marker without boundary", line: "1)Not a marker.", want: "", ok: false},
|
||||
{name: "numeric marker with unsupported punctuation", line: "1, Not a marker.", want: "", ok: false},
|
||||
@@ -877,6 +964,14 @@ func TestParseForecastDiscussionKeyMessagesClassifiesListsAndMetadata(t *testing
|
||||
},
|
||||
want: []string{"First message."},
|
||||
},
|
||||
{
|
||||
name: "leading as of metadata",
|
||||
body: []string{
|
||||
"As of 1235 PM Sunday...",
|
||||
"- First message.",
|
||||
},
|
||||
want: []string{"First message."},
|
||||
},
|
||||
{
|
||||
name: "updated atmospheric prose remains content",
|
||||
body: []string{
|
||||
@@ -901,6 +996,14 @@ func TestParseForecastDiscussionKeyMessagesClassifiesListsAndMetadata(t *testing
|
||||
},
|
||||
want: []string{"First message. Updated at 300 PM CDT Sat Mar 28 2026"},
|
||||
},
|
||||
{
|
||||
name: "later as of metadata remains content",
|
||||
body: []string{
|
||||
"- First message.",
|
||||
"As of 1235 PM Sunday...",
|
||||
},
|
||||
want: []string{"First message. As of 1235 PM Sunday..."},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1089,7 +1192,7 @@ func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForecastDiscussionHTMLParsesNumberedAndKeyPointsFixtures(t *testing.T) {
|
||||
func TestParseForecastDiscussionHTMLParsesCrossOfficeKeyMessageFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
@@ -1127,6 +1230,48 @@ func TestParseForecastDiscussionHTMLParsesNumberedAndKeyPointsFixtures(t *testin
|
||||
"*", "DISCUSSION", "(Today through Thursday)", "Discussion details must not be included with key points.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "as of preamble",
|
||||
filename: "forecast_discussion_rah_as_of_sample.html",
|
||||
officeID: "RAH",
|
||||
officeName: "National Weather Service Raleigh NC",
|
||||
issuedAt: 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.",
|
||||
},
|
||||
unwanted: []string{
|
||||
"As of", "1)", "2)", "DISCUSSION", "Discussion details remain boundary-only content.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parenthesized numeric markers",
|
||||
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
|
||||
officeID: "LWX",
|
||||
officeName: "National Weather Service Baltimore MD/Washington DC",
|
||||
issuedAt: 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.",
|
||||
},
|
||||
unwanted: []string{
|
||||
"(1)", "(2)", "AVIATION", "Aviation details remain boundary-only content.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "embedded key messages in previous discussion",
|
||||
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
|
||||
officeID: "MFR",
|
||||
officeName: "National Weather Service Medford OR",
|
||||
issuedAt: 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.",
|
||||
},
|
||||
unwanted: []string{
|
||||
"PREV DISCUSSION", "Issued 319 PM", "*", "DISCUSSION", "Discussion details must not be included with key messages.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative LWX-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS61 KLWX 021800
|
||||
AFDLWX
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Baltimore MD/Washington DC
|
||||
200 PM EDT Sun Aug 2 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
- (1) Thunderstorms remain possible near the Blue Ridge this evening.
|
||||
- (2) Seasonably warm conditions continue Monday.
|
||||
|
||||
&&
|
||||
|
||||
.AVIATION...
|
||||
Aviation details remain boundary-only content.
|
||||
|
||||
$$
|
||||
|
||||
WFO LWX
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative current MFR previous-discussion wrapper layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS66 KMFR 022219
|
||||
AFDMFR
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Medford OR
|
||||
319 PM PDT Sun Aug 2 2026
|
||||
|
||||
.PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/
|
||||
|
||||
KEY MESSAGES...
|
||||
|
||||
* Heat returns to inland valleys Monday.
|
||||
* Gusty afternoon winds develop east of the Cascades.
|
||||
|
||||
DISCUSSION...
|
||||
Discussion details must not be included with key messages.
|
||||
|
||||
&&
|
||||
|
||||
.MFR WATCHES/WARNINGS/ADVISORIES...
|
||||
None.
|
||||
|
||||
$$
|
||||
|
||||
WFO MFR
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Representative RAH-style layout; prose is concise edited test data, not an archived product. -->
|
||||
<html>
|
||||
<body>
|
||||
<pre class="glossaryProduct">
|
||||
FXUS62 KRAH 021635
|
||||
AFDRAH
|
||||
|
||||
Area Forecast Discussion
|
||||
National Weather Service Raleigh NC
|
||||
1235 PM EDT Sun Aug 2 2026
|
||||
|
||||
.KEY MESSAGES...
|
||||
As of 1235 PM Sunday...
|
||||
|
||||
1) Scattered storms may produce locally heavy rain this afternoon.
|
||||
2) Drier weather arrives Monday.
|
||||
|
||||
&&
|
||||
|
||||
.DISCUSSION...
|
||||
Discussion details remain boundary-only content.
|
||||
|
||||
$$
|
||||
|
||||
WFO RAH
|
||||
</pre>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user