Added support for Area Forecast Discussions issued by the NWS
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -19,6 +19,7 @@ func TestRegisterBuiltinsOrder(t *testing.T) {
|
||||
want := []fknormalize.Normalizer{
|
||||
nws.ObservationNormalizer{},
|
||||
nws.ForecastNormalizer{},
|
||||
nws.ForecastDiscussionNormalizer{},
|
||||
nws.AlertsNormalizer{},
|
||||
openmeteo.ObservationNormalizer{},
|
||||
openmeteo.ForecastNormalizer{},
|
||||
|
||||
72
internal/normalizers/nws/forecast_discussion.go
Normal file
72
internal/normalizers/nws/forecast_discussion.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package nws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
|
||||
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
type ForecastDiscussionNormalizer struct{}
|
||||
|
||||
func (ForecastDiscussionNormalizer) Match(e event.Event) bool {
|
||||
return strings.TrimSpace(e.Schema) == standards.SchemaRawNWSForecastDiscussionV1
|
||||
}
|
||||
|
||||
func (ForecastDiscussionNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
|
||||
_ = ctx
|
||||
|
||||
rawHTML, err := decodeStringPayload(in.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nws forecast discussion normalize: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := nwscommon.ParseForecastDiscussionHTML(rawHTML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nws forecast discussion normalize: build: %w", err)
|
||||
}
|
||||
|
||||
payload := model.WeatherForecastDiscussion{
|
||||
OfficeID: strings.TrimSpace(parsed.OfficeID),
|
||||
OfficeName: strings.TrimSpace(parsed.OfficeName),
|
||||
Product: model.ForecastDiscussionProduct(strings.TrimSpace(parsed.Product)),
|
||||
IssuedAt: parsed.IssuedAt.UTC(),
|
||||
UpdatedAt: parsed.UpdatedAt,
|
||||
KeyMessages: append([]string(nil), parsed.KeyMessages...),
|
||||
ShortTerm: mapForecastDiscussionSection(parsed.ShortTerm),
|
||||
LongTerm: mapForecastDiscussionSection(parsed.LongTerm),
|
||||
}
|
||||
|
||||
out, err := normcommon.Finalize(in, standards.SchemaWeatherForecastDiscussionV1, payload, payload.IssuedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nws forecast discussion normalize: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapForecastDiscussionSection(in *nwscommon.ForecastDiscussionSection) *model.WeatherForecastDiscussionSection {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.WeatherForecastDiscussionSection{
|
||||
Qualifier: strings.TrimSpace(in.Qualifier),
|
||||
IssuedAt: in.IssuedAt,
|
||||
Text: strings.TrimSpace(in.Text),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStringPayload(payload any) (string, error) {
|
||||
switch v := payload.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case []byte:
|
||||
return string(v), nil
|
||||
default:
|
||||
return "", fmt.Errorf("extract payload: expected string payload, got %T", payload)
|
||||
}
|
||||
}
|
||||
130
internal/normalizers/nws/forecast_discussion_test.go
Normal file
130
internal/normalizers/nws/forecast_discussion_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package nws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
||||
rawHTML := loadForecastDiscussionSampleHTML(t)
|
||||
|
||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||
ID: "evt-discussion-1",
|
||||
Kind: event.Kind("forecast_discussion"),
|
||||
Source: "nws-discussion-test",
|
||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: rawHTML,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("Normalize() returned nil output")
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||
}
|
||||
if out.Kind != event.Kind("forecast_discussion") {
|
||||
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||
}
|
||||
|
||||
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||
if !ok {
|
||||
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||
}
|
||||
if payload.OfficeID != "LSX" {
|
||||
t.Fatalf("OfficeID = %q, want LSX", payload.OfficeID)
|
||||
}
|
||||
if payload.Product != model.ForecastDiscussionProductAFD {
|
||||
t.Fatalf("Product = %q, want %q", payload.Product, model.ForecastDiscussionProductAFD)
|
||||
}
|
||||
if len(payload.KeyMessages) != 3 {
|
||||
t.Fatalf("KeyMessages len = %d, want 3", len(payload.KeyMessages))
|
||||
}
|
||||
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
||||
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||
}
|
||||
if payload.ShortTerm.Qualifier != "(Through Late Sunday Night)" {
|
||||
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
||||
}
|
||||
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
||||
t.Fatalf("ShortTerm.Text = %q, want normalized prose", payload.ShortTerm.Text)
|
||||
}
|
||||
if strings.Contains(payload.ShortTerm.Text, "BRC") {
|
||||
t.Fatalf("ShortTerm.Text contains signature: %q", payload.ShortTerm.Text)
|
||||
}
|
||||
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "WATCHES/WARNINGS/ADVISORIES") {
|
||||
t.Fatalf("LongTerm.Text includes downstream sections: %q", payload.LongTerm.Text)
|
||||
}
|
||||
wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
|
||||
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
||||
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
||||
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||
ID: "evt-discussion-bad",
|
||||
Kind: event.Kind("forecast_discussion"),
|
||||
Source: "nws-discussion-test",
|
||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: "<html><body><pre class=\"glossaryProduct\">National Weather Service Saint Louis MO</pre></body></html>",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Normalize() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "issue time") {
|
||||
t.Fatalf("error = %q, want issue time context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastDiscussionNormalizerWireShapeHasNoUnexpectedKeys(t *testing.T) {
|
||||
rawHTML := loadForecastDiscussionSampleHTML(t)
|
||||
|
||||
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||
ID: "evt-discussion-2",
|
||||
Kind: event.Kind("forecast_discussion"),
|
||||
Source: "nws-discussion-test",
|
||||
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||
Payload: rawHTML,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
|
||||
b, err := json.Marshal(out.Payload)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"sections", "aviation"} {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_sample.html")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
var builtins = []fknormalize.Normalizer{
|
||||
ObservationNormalizer{},
|
||||
ForecastNormalizer{},
|
||||
ForecastDiscussionNormalizer{},
|
||||
AlertsNormalizer{},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user