Scan NWS forecast discussion blocks once
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user