diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index 8dee24c..caccddd 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -492,7 +492,7 @@ func parseAlert(raw json.RawMessage) (parsedAlert, bool) { Headline: firstStringField(fields, "headline", "title"), Severity: stringField(fields, "severity"), Description: stringField(fields, "description"), - Instruction: stringField(fields, "instruction"), + Instruction: collapseWhitespace(stringField(fields, "instruction")), } start, startOK := firstTimeField(fields, "onset", "startsAt", "startTime", "effective", "sent") end, endOK := firstTimeField(fields, "ends", "endsAt", "endTime", "expires") @@ -542,6 +542,10 @@ func firstTimeField(fields map[string]json.RawMessage, names ...string) (time.Ti return time.Time{}, false } +func collapseWhitespace(value string) string { + return strings.Join(strings.Fields(value), " ") +} + func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period { start := left.Start if right.Start.After(start) { diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index c0df4f7..501a96d 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -211,7 +211,7 @@ func TestAlertOverlap(t *testing.T) { } func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) { - raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`) + raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.\n\nUse extra\tcaution.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`) alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}} period := timeutil.Period{ Start: mustParse("2026-06-17T12:00:00-05:00"), @@ -237,11 +237,34 @@ func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) { if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." { t.Fatalf("Description = %q, want preserved description", overlaps[0].Description) } - if overlaps[0].Instruction != "Secure outdoor objects." { + if overlaps[0].Instruction != "Secure outdoor objects. Use extra caution." { t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction) } } +func TestCollapseWhitespace(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "newlines", value: "Secure outdoor objects.\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."}, + {name: "crlf", value: "Secure outdoor objects.\r\nUse extra caution.", want: "Secure outdoor objects. Use extra caution."}, + {name: "tabs and repeated spaces", value: "Secure\toutdoor objects.", want: "Secure outdoor objects."}, + {name: "leading trailing", value: " Secure outdoor objects. ", want: "Secure outdoor objects."}, + {name: "empty", value: "", want: ""}, + {name: "all whitespace", value: " \n\t\r\n ", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := collapseWhitespace(tt.value); got != tt.want { + t.Fatalf("collapseWhitespace(%q) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) { location, err := time.LoadLocation("America/Chicago") if err != nil {