diff --git a/README.md b/README.md index b04d154..e37f7f5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ A small HTTP API that serves a variety of weather-related endpoints. - `GET /conditions/current` - `GET /alerts/active` - `GET /discussion` +- `GET /discussion/key-messages` +- `GET /discussion/short-term` +- `GET /discussion/long-term` - `GET /forecast/hourly` - `GET /forecast/hourly/today` - `GET /forecast/hourly/tomorrow` diff --git a/internal/adapters/inbound/httpapi/discussion_endpoint.go b/internal/adapters/inbound/httpapi/discussion_endpoint.go index 4905391..a755196 100644 --- a/internal/adapters/inbound/httpapi/discussion_endpoint.go +++ b/internal/adapters/inbound/httpapi/discussion_endpoint.go @@ -4,25 +4,69 @@ package httpapi import ( "context" + "time" "gitea.maximumdirect.net/ejr/feedapi/endpoint" "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/response" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) -func discussionDefinition(svc Service) endpoint.Definition { +func discussionDefinitions(svc Service) []endpoint.Definition { + return []endpoint.Definition{ + discussionDefinition( + "/discussion", + "discussion.txt.tmpl", + func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any { + return presenter.DiscussionPayload(run, units, tz) + }, + svc, + ), + discussionDefinition( + "/discussion/key-messages", + "discussion_key_messages.txt.tmpl", + func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any { + return presenter.DiscussionKeyMessagesOnlyPayload(run, units, tz) + }, + svc, + ), + discussionDefinition( + "/discussion/short-term", + "discussion_short_term.txt.tmpl", + func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any { + return presenter.DiscussionShortTermOnlyPayload(run, units, tz) + }, + svc, + ), + discussionDefinition( + "/discussion/long-term", + "discussion_long_term.txt.tmpl", + func(run *model.WeatherForecastDiscussion, units presenter.Units, tz *time.Location) any { + return presenter.DiscussionLongTermOnlyPayload(run, units, tz) + }, + svc, + ), + } +} + +func discussionDefinition( + path string, + templateName string, + present func(*model.WeatherForecastDiscussion, presenter.Units, *time.Location) any, + svc Service, +) endpoint.Definition { return endpoint.GET( - "/discussion", + path, bindTimezoneQuery, func(ctx context.Context, req timezoneQueryRequest) (any, error) { run, err := svc.LatestForecastDiscussion(ctx) if err != nil { return nil, err } - return response.Envelope{Data: presenter.DiscussionPayload(run, req.Units, req.Timezone)}, nil + return response.Envelope{Data: present(run, req.Units, req.Timezone)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), - endpoint.WithTemplate("discussion.txt.tmpl"), + endpoint.WithTemplate(templateName), ) } diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index ce4827e..210c7af 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -8,9 +8,9 @@ func Definitions(svc Service) []endpoint.Definition { defs := []endpoint.Definition{ observationDefinition(svc), alertsDefinition(svc), - discussionDefinition(svc), conditionsDefinition(svc), } + defs = append(defs, discussionDefinitions(svc)...) defs = append(defs, forecastDefinitions(svc)...) return defs } diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index f859f5e..5537d90 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -1440,6 +1440,307 @@ func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) { func TestDefinitionsIncludeDiscussion(t *testing.T) { _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion") + _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/key-messages") + _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/short-term") + _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term") +} + +func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) { + for _, path := range []string{ + "/discussion/key-messages", + "/discussion/short-term", + "/discussion/long-term", + } { + t.Run(path, func(t *testing.T) { + h := newHandler(t, &fakeService{}, path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } + }) + } +} + +func TestDiscussionSubresourcesJSONEnvelopeFocusedFields(t *testing.T) { + issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC) + shortIssuedAt := issuedAt.Add(-5 * time.Minute) + longIssuedAt := issuedAt.Add(10 * time.Minute) + run := &model.WeatherForecastDiscussion{ + OfficeID: "LSX", + OfficeName: "National Weather Service Saint Louis MO", + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + KeyMessages: []string{"msg one", "msg two"}, + ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, + LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"}, + } + + t.Run("key messages", func(t *testing.T) { + h := newHandler(t, &fakeService{discussion: run}, "/discussion/key-messages") + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion/key-messages", nil) + h.ServeHTTP(w, req) + + var payload struct { + Data struct { + OfficeID string `json:"officeId"` + KeyMessages []string `json:"keyMessages"` + ShortTerm any `json:"shortTerm"` + LongTerm any `json:"longTerm"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.OfficeID != "LSX" { + t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID) + } + if len(payload.Data.KeyMessages) != 2 { + t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages)) + } + if payload.Data.ShortTerm != nil || payload.Data.LongTerm != nil { + t.Fatalf("unexpected extra fields in key messages payload") + } + }) + + t.Run("short term", func(t *testing.T) { + h := newHandler(t, &fakeService{discussion: run}, "/discussion/short-term") + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion/short-term", nil) + h.ServeHTTP(w, req) + + var payload struct { + Data struct { + ShortTerm *struct { + Text string `json:"text"` + } `json:"shortTerm"` + KeyMessages any `json:"keyMessages"` + LongTerm any `json:"longTerm"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" { + t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm) + } + if payload.Data.KeyMessages != nil || payload.Data.LongTerm != nil { + t.Fatalf("unexpected extra fields in short term payload") + } + }) + + t.Run("long term", func(t *testing.T) { + h := newHandler(t, &fakeService{discussion: run}, "/discussion/long-term") + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/discussion/long-term", nil) + h.ServeHTTP(w, req) + + var payload struct { + Data struct { + LongTerm *struct { + Text string `json:"text"` + } `json:"longTerm"` + KeyMessages any `json:"keyMessages"` + ShortTerm any `json:"shortTerm"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data.LongTerm == nil || payload.Data.LongTerm.Text != "Long term text" { + t.Fatalf("unexpected longTerm payload: %+v", payload.Data.LongTerm) + } + if payload.Data.KeyMessages != nil || payload.Data.ShortTerm != nil { + t.Fatalf("unexpected extra fields in long term payload") + } + }) +} + +func TestDiscussionSubresourcesSupportTextAndXMLFormats(t *testing.T) { + tests := []struct { + path string + textContains string + }{ + {path: "/discussion/key-messages", textContains: "Forecast Discussion Key Messages"}, + {path: "/discussion/short-term", textContains: "Forecast Discussion Short Term"}, + {path: "/discussion/long-term", textContains: "Forecast Discussion Long Term"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + hText := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, tt.path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.path+"?format=TEXT", nil) + hText.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for text request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Body.String(), tt.textContains) { + t.Fatalf("expected rendered text template body, got %q", w.Body.String()) + } + + hXML := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, tt.path) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, tt.path+"?format=XML", nil) + hXML.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for xml request, got %d", w.Code) + } + if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { + t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) + } + }) + } +} + +func TestDiscussionSubresourcesTimezoneQuery(t *testing.T) { + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + shortIssuedAt := issuedAt.Add(-15 * time.Minute) + longIssuedAt := issuedAt.Add(15 * time.Minute) + + tests := []struct { + path string + query string + want int + check func(*testing.T, discussionFocusedTimePayload, int) + }{ + { + path: "/discussion/key-messages", + query: "/discussion/key-messages?tz=CDT", + want: -5 * 60 * 60, + check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { + assertOffsetSeconds(t, payload.Data.IssuedAt, want) + assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) + }, + }, + { + path: "/discussion/short-term", + query: "/discussion/short-term?tz=Chicago", + want: -5 * 60 * 60, + check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { + assertOffsetSeconds(t, payload.Data.IssuedAt, want) + assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) + assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, want) + }, + }, + { + path: "/discussion/long-term", + query: "/discussion/long-term?TZ=-5", + want: -5 * 60 * 60, + check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { + assertOffsetSeconds(t, payload.Data.IssuedAt, want) + assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) + assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, want) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + h := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{ + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt}, + LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt}, + }, + }, tt.path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tt.query, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload discussionFocusedTimePayload + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode discussion payload: %v", err) + } + tt.check(t, payload, tt.want) + }) + } +} + +func TestDiscussionSubresourcesRejectInvalidQueryParameters(t *testing.T) { + tests := []struct { + path string + urls []string + }{ + { + path: "/discussion/key-messages", + urls: []string{ + "/discussion/key-messages?bogus=1", + "/discussion/key-messages?precision=1", + "/discussion/key-messages?tz=not-a-timezone", + "/discussion/key-messages?tz=CDT&TZ=EST", + }, + }, + { + path: "/discussion/short-term", + urls: []string{ + "/discussion/short-term?bogus=1", + "/discussion/short-term?precision=1", + "/discussion/short-term?tz=not-a-timezone", + "/discussion/short-term?tz=CDT&TZ=EST", + }, + }, + { + path: "/discussion/long-term", + urls: []string{ + "/discussion/long-term?bogus=1", + "/discussion/long-term?precision=1", + "/discussion/long-term?tz=not-a-timezone", + "/discussion/long-term?tz=CDT&TZ=EST", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + for _, rawURL := range tt.urls { + h := newHandler(t, &fakeService{ + discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, + }, tt.path) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, rawURL, nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400, got %d", rawURL, w.Code) + } + } + }) + } } func newHandler(t *testing.T, svc Service, path string) http.Handler { @@ -1476,12 +1777,15 @@ func testRenderers(t *testing.T) *render.Registry { tmplReg := templates.NewRegistry() for name, body := range map[string]string{ - "observations.txt.tmpl": "Observation text", - "discussion.txt.tmpl": "Forecast Discussion", - "forecast_hourly.txt.tmpl": "Forecast text", - "forecast_narrative.txt.tmpl": "Narrative Forecast", - "alerts_active.txt.tmpl": "Alerts text", - "conditions_current.txt.tmpl": "Conditions text", + "observations.txt.tmpl": "Observation text", + "discussion.txt.tmpl": "Forecast Discussion", + "discussion_key_messages.txt.tmpl": "Forecast Discussion Key Messages", + "discussion_short_term.txt.tmpl": "Forecast Discussion Short Term", + "discussion_long_term.txt.tmpl": "Forecast Discussion Long Term", + "forecast_hourly.txt.tmpl": "Forecast text", + "forecast_narrative.txt.tmpl": "Narrative Forecast", + "alerts_active.txt.tmpl": "Alerts text", + "conditions_current.txt.tmpl": "Conditions text", } { tmpl, err := template.New(name).Parse(body) if err != nil { @@ -1526,6 +1830,19 @@ type discussionTimePayload struct { } `json:"data"` } +type discussionFocusedTimePayload struct { + Data struct { + IssuedAt time.Time `json:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt"` + ShortTerm *struct { + IssuedAt *time.Time `json:"issuedAt"` + } `json:"shortTerm"` + LongTerm *struct { + IssuedAt *time.Time `json:"issuedAt"` + } `json:"longTerm"` + } `json:"data"` +} + func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { t.Helper() diff --git a/internal/adapters/inbound/httpapi/presenter/discussion.go b/internal/adapters/inbound/httpapi/presenter/discussion.go index 63a2c23..1b783c0 100644 --- a/internal/adapters/inbound/httpapi/presenter/discussion.go +++ b/internal/adapters/inbound/httpapi/presenter/discussion.go @@ -8,6 +8,33 @@ import ( "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) +type DiscussionKeyMessagesPayload struct { + OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"` + OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"` + Product model.ForecastDiscussionProduct `json:"product" xml:"product"` + IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"` + KeyMessages []string `json:"keyMessages" xml:"keyMessages"` +} + +type DiscussionShortTermPayload struct { + OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"` + OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"` + Product model.ForecastDiscussionProduct `json:"product" xml:"product"` + IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"` + ShortTerm *model.WeatherForecastDiscussionSection `json:"shortTerm,omitempty" xml:"shortTerm,omitempty"` +} + +type DiscussionLongTermPayload struct { + OfficeID string `json:"officeId,omitempty" xml:"officeId,omitempty"` + OfficeName string `json:"officeName,omitempty" xml:"officeName,omitempty"` + Product model.ForecastDiscussionProduct `json:"product" xml:"product"` + IssuedAt time.Time `json:"issuedAt" xml:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" xml:"updatedAt,omitempty"` + LongTerm *model.WeatherForecastDiscussionSection `json:"longTerm,omitempty" xml:"longTerm,omitempty"` +} + func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any { if run == nil { return nil @@ -26,6 +53,51 @@ func DiscussionPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.L return &out } +func DiscussionKeyMessagesOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any { + if run == nil { + return nil + } + + return DiscussionKeyMessagesPayload{ + OfficeID: run.OfficeID, + OfficeName: run.OfficeName, + Product: run.Product, + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), + KeyMessages: append([]string{}, run.KeyMessages...), + } +} + +func DiscussionShortTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any { + if run == nil { + return nil + } + + return DiscussionShortTermPayload{ + OfficeID: run.OfficeID, + OfficeName: run.OfficeName, + Product: run.Product, + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), + ShortTerm: copyDiscussionSection(run.ShortTerm, tz), + } +} + +func DiscussionLongTermOnlyPayload(run *model.WeatherForecastDiscussion, _ Units, tz *time.Location) any { + if run == nil { + return nil + } + + return DiscussionLongTermPayload{ + OfficeID: run.OfficeID, + OfficeName: run.OfficeName, + Product: run.Product, + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), + LongTerm: copyDiscussionSection(run.LongTerm, tz), + } +} + func copyDiscussionSection(in *model.WeatherForecastDiscussionSection, tz *time.Location) *model.WeatherForecastDiscussionSection { if in == nil { return nil diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index b0e469d..e37d85f 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -209,6 +209,90 @@ func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) { assertOffsetSeconds(t, run.IssuedAt, 0) } +func TestDiscussionFocusedPayloads(t *testing.T) { + loc := time.FixedZone("UTC-05:00", -5*60*60) + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + shortIssuedAt := issuedAt.Add(-15 * time.Minute) + longIssuedAt := issuedAt.Add(15 * time.Minute) + run := &model.WeatherForecastDiscussion{ + OfficeID: "LSX", + OfficeName: "National Weather Service Saint Louis MO", + Product: model.ForecastDiscussionProductAFD, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + KeyMessages: nil, + ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, + LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"}, + } + + keyMessagesPayload := DiscussionKeyMessagesOnlyPayload(run, UnitsMetric, loc) + keyMessages, ok := keyMessagesPayload.(DiscussionKeyMessagesPayload) + if !ok { + t.Fatalf("expected DiscussionKeyMessagesPayload, got %T", keyMessagesPayload) + } + if keyMessages.KeyMessages == nil { + t.Fatalf("expected keyMessages slice to be non-nil") + } + if len(keyMessages.KeyMessages) != 0 { + t.Fatalf("expected empty keyMessages slice, got %#v", keyMessages.KeyMessages) + } + assertOffsetSeconds(t, keyMessages.IssuedAt, -5*60*60) + assertOffsetSeconds(t, *keyMessages.UpdatedAt, -5*60*60) + + shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, loc) + shortTerm, ok := shortPayload.(DiscussionShortTermPayload) + if !ok { + t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload) + } + if shortTerm.ShortTerm == nil { + t.Fatalf("expected shortTerm section") + } + if shortTerm.ShortTerm == run.ShortTerm { + t.Fatalf("expected shortTerm section copy") + } + assertOffsetSeconds(t, *shortTerm.ShortTerm.IssuedAt, -5*60*60) + + longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, loc) + longTerm, ok := longPayload.(DiscussionLongTermPayload) + if !ok { + t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload) + } + if longTerm.LongTerm == nil { + t.Fatalf("expected longTerm section") + } + if longTerm.LongTerm == run.LongTerm { + t.Fatalf("expected longTerm section copy") + } + assertOffsetSeconds(t, *longTerm.LongTerm.IssuedAt, -5*60*60) +} + +func TestDiscussionFocusedPayloadsPreserveNilSections(t *testing.T) { + run := &model.WeatherForecastDiscussion{ + OfficeID: "LSX", + Product: model.ForecastDiscussionProductAFD, + IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), + } + + shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, nil) + shortTerm, ok := shortPayload.(DiscussionShortTermPayload) + if !ok { + t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload) + } + if shortTerm.ShortTerm != nil { + t.Fatalf("expected nil shortTerm, got %+v", shortTerm.ShortTerm) + } + + longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, nil) + longTerm, ok := longPayload.(DiscussionLongTermPayload) + if !ok { + t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload) + } + if longTerm.LongTerm != nil { + t.Fatalf("expected nil longTerm, got %+v", longTerm.LongTerm) + } +} + func TestMetricCopyAndNilHandling(t *testing.T) { obs := &model.WeatherObservation{ TemperatureC: float64Ptr(20.6), @@ -239,6 +323,15 @@ func TestMetricCopyAndNilHandling(t *testing.T) { if DiscussionPayload(nil, UnitsUS, nil) != nil { t.Fatalf("expected nil discussion input to return nil payload") } + if DiscussionKeyMessagesOnlyPayload(nil, UnitsUS, nil) != nil { + t.Fatalf("expected nil key messages discussion input to return nil payload") + } + if DiscussionShortTermOnlyPayload(nil, UnitsUS, nil) != nil { + t.Fatalf("expected nil short term discussion input to return nil payload") + } + if DiscussionLongTermOnlyPayload(nil, UnitsUS, nil) != nil { + t.Fatalf("expected nil long term discussion input to return nil payload") + } if CurrentConditionsPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil current conditions input to return nil payload") } diff --git a/templates/discussion_key_messages.txt.tmpl b/templates/discussion_key_messages.txt.tmpl new file mode 100644 index 0000000..30a74e6 --- /dev/null +++ b/templates/discussion_key_messages.txt.tmpl @@ -0,0 +1,17 @@ +{{- if .Data -}} +Forecast Discussion Key Messages +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}} +Product: {{.Data.Product}} +Issued At: {{.Data.IssuedAt}} +{{- if .Data.UpdatedAt}} +Updated At: {{.Data.UpdatedAt}} +{{- end}} +Key Messages: {{len .Data.KeyMessages}} +{{- range $i, $message := .Data.KeyMessages}} + +[{{$i}}] {{$message}} +{{- end}} +{{- else -}} +No key messages discussion data available. +{{- end}} diff --git a/templates/discussion_long_term.txt.tmpl b/templates/discussion_long_term.txt.tmpl new file mode 100644 index 0000000..f4c14a8 --- /dev/null +++ b/templates/discussion_long_term.txt.tmpl @@ -0,0 +1,25 @@ +{{- if .Data -}} +Forecast Discussion Long Term +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}} +Product: {{.Data.Product}} +Issued At: {{.Data.IssuedAt}} +{{- if .Data.UpdatedAt}} +Updated At: {{.Data.UpdatedAt}} +{{- end}} +{{- if .Data.LongTerm}} +{{- if .Data.LongTerm.Qualifier}} +Qualifier: {{.Data.LongTerm.Qualifier}} +{{- end}} +{{- if .Data.LongTerm.IssuedAt}} +Section Issued At: {{.Data.LongTerm.IssuedAt}} +{{- end}} +{{- if .Data.LongTerm.Text}} +Text: {{.Data.LongTerm.Text}} +{{- end}} +{{- else}} +No long term discussion data available. +{{- end}} +{{- else -}} +No long term discussion data available. +{{- end}} diff --git a/templates/discussion_short_term.txt.tmpl b/templates/discussion_short_term.txt.tmpl new file mode 100644 index 0000000..6ae8796 --- /dev/null +++ b/templates/discussion_short_term.txt.tmpl @@ -0,0 +1,25 @@ +{{- if .Data -}} +Forecast Discussion Short Term +Office ID: {{if .Data.OfficeID}}{{.Data.OfficeID}}{{else}}n/a{{end}} +Office Name: {{if .Data.OfficeName}}{{.Data.OfficeName}}{{else}}n/a{{end}} +Product: {{.Data.Product}} +Issued At: {{.Data.IssuedAt}} +{{- if .Data.UpdatedAt}} +Updated At: {{.Data.UpdatedAt}} +{{- end}} +{{- if .Data.ShortTerm}} +{{- if .Data.ShortTerm.Qualifier}} +Qualifier: {{.Data.ShortTerm.Qualifier}} +{{- end}} +{{- if .Data.ShortTerm.IssuedAt}} +Section Issued At: {{.Data.ShortTerm.IssuedAt}} +{{- end}} +{{- if .Data.ShortTerm.Text}} +Text: {{.Data.ShortTerm.Text}} +{{- end}} +{{- else}} +No short term discussion data available. +{{- end}} +{{- else -}} +No short term discussion data available. +{{- end}}