Add convective outlook presentation

This commit is contained in:
2026-06-11 15:41:38 +00:00
parent d6734d5ffc
commit e897ae52df
4 changed files with 224 additions and 3 deletions

View File

@@ -1355,6 +1355,43 @@ func TestOutlookNoDataReturnsNullEnvelopeData(t *testing.T) {
}
}
func TestOutlookTextResponseUsesTemplate(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=text", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, 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"))
}
body := w.Body.String()
if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") {
t.Fatalf("expected outlook text template body, got %q", body)
}
}
func TestOutlookXMLResponseRenders(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=xml", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") {
t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), "<LocationID>stl</LocationID>") {
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
}
}
func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
run := testOutlookRun()
run.Outlooks = []model.WeatherOutlook{}
@@ -2174,7 +2211,7 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"outlooks_convective.txt.tmpl": "Convective Outlook",
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}{{else}}No convective outlook data available.{{end}}",
"weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text",

View File

@@ -3,14 +3,64 @@
package presenter
import (
"encoding/json"
"time"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, _ *time.Location) any {
func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, tz *time.Location) any {
if run == nil {
return nil
}
return run
out := model.WeatherOutlookRun{
LocationID: run.LocationID,
LocationName: run.LocationName,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
AsOf: inLocationTime(run.AsOf, tz),
IssuedAt: inLocationTimePtr(run.IssuedAt, tz),
Outlooks: make([]model.WeatherOutlook, 0, len(run.Outlooks)),
}
for _, outlook := range run.Outlooks {
out.Outlooks = append(out.Outlooks, copyOutlook(outlook, tz))
}
return &out
}
func copyOutlook(outlook model.WeatherOutlook, tz *time.Location) model.WeatherOutlook {
out := model.WeatherOutlook{
ID: outlook.ID,
Provider: outlook.Provider,
Product: outlook.Product,
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
SeverityRank: copyIntPtr(outlook.SeverityRank),
ValidFrom: inLocationTime(outlook.ValidFrom, tz),
ValidTo: inLocationTime(outlook.ValidTo, tz),
IssuedAt: inLocationTime(outlook.IssuedAt, tz),
ExpiresAt: inLocationTime(outlook.ExpiresAt, tz),
Forecaster: outlook.Forecaster,
Headline: outlook.Headline,
Summary: outlook.Summary,
Discussion: outlook.Discussion,
SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL,
ContainsLocation: outlook.ContainsLocation,
}
if outlook.Geometry != nil {
out.Geometry = json.RawMessage(append([]byte(nil), outlook.Geometry...))
}
return out
}
func copyIntPtr(v *int) *int {
if v == nil {
return nil
}
out := *v
return &out
}

View File

@@ -430,6 +430,110 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
assertApprox(t, us.Longitude, -90.199456, 0.000001)
}
func TestOutlookRunPayloadNilInputReturnsNil(t *testing.T) {
if OutlookRunPayload(nil, UnitsUS, time.UTC) != nil {
t.Fatalf("expected nil outlook run input to return nil payload")
}
}
func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC)
issuedAt := asOf.Add(-1 * time.Hour)
severityRank := 5
latitude := 38.627123
longitude := -90.199456
geometry := json.RawMessage(`{"type":"Point","coordinates":[-90.2,38.6]}`)
run := &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
Latitude: &latitude,
Longitude: &longitude,
AsOf: asOf,
IssuedAt: &issuedAt,
Outlooks: []model.WeatherOutlook{{
ID: "cat-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &severityRank,
ValidFrom: asOf,
ValidTo: asOf.Add(6 * time.Hour),
IssuedAt: issuedAt,
ExpiresAt: asOf.Add(6 * time.Hour),
Forecaster: "DIAL",
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: geometry,
}},
}
payload := OutlookRunPayload(run, UnitsUS, loc)
out, ok := payload.(*model.WeatherOutlookRun)
if !ok {
t.Fatalf("expected *model.WeatherOutlookRun payload, got %T", payload)
}
if out == run {
t.Fatalf("expected outlook run payload to be copied")
}
if out.Latitude == run.Latitude || out.Longitude == run.Longitude || out.IssuedAt == run.IssuedAt {
t.Fatalf("expected run pointers to be copied")
}
if len(out.Outlooks) != 1 {
t.Fatalf("expected one outlook, got %d", len(out.Outlooks))
}
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
t.Fatalf("expected severity rank pointer to be copied")
}
if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] {
t.Fatalf("expected geometry bytes to be copied")
}
assertOffsetSeconds(t, out.AsOf, -5*60*60)
assertOffsetSeconds(t, *out.IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ValidFrom, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60)
assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60)
if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) {
t.Fatalf("expected timezone conversion to preserve instants")
}
if *out.Latitude != latitude || *out.Longitude != longitude {
t.Fatalf("expected latitude/longitude preserved, got %v %v", out.Latitude, out.Longitude)
}
if out.Outlooks[0].SeverityRank == nil || *out.Outlooks[0].SeverityRank != severityRank {
t.Fatalf("expected severity rank preserved, got %v", out.Outlooks[0].SeverityRank)
}
if string(out.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry)
}
*out.Latitude = 99
*out.Longitude = -99
*out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
*out.Outlooks[0].SeverityRank = 99
out.Outlooks[0].Geometry[0] = '['
if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) {
t.Fatalf("expected source run pointers not to mutate")
}
if *run.Outlooks[0].SeverityRank != severityRank {
t.Fatalf("expected source severity rank not to mutate")
}
if string(run.Outlooks[0].Geometry) != string(geometry) {
t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry)
}
assertOffsetSeconds(t, run.AsOf, 0)
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
}
func float64Ptr(v float64) *float64 {
return &v
}

View File

@@ -0,0 +1,30 @@
{{- if .Data -}}
Convective Outlook
Location ID: {{if .Data.LocationID}}{{.Data.LocationID}}{{else}}n/a{{end}}
Location Name: {{if .Data.LocationName}}{{.Data.LocationName}}{{else}}n/a{{end}}
As Of: {{.Data.AsOf}}
{{- if .Data.IssuedAt}}
Issued At: {{.Data.IssuedAt}}
{{- end}}
Outlooks: {{len .Data.Outlooks}}
{{- range $i, $outlook := .Data.Outlooks}}
[{{$i}}] Day {{$outlook.Day}} {{$outlook.OutlookType}} {{$outlook.Label}}
Valid: {{$outlook.ValidFrom}} -> {{$outlook.ValidTo}}
Contains Location: {{$outlook.ContainsLocation}}
{{- if $outlook.LabelText}}
Label Text: {{$outlook.LabelText}}
{{- end}}
{{- if $outlook.Headline}}
Headline: {{$outlook.Headline}}
{{- end}}
{{- if $outlook.Summary}}
Summary: {{$outlook.Summary}}
{{- end}}
{{- if $outlook.SourceURL}}
Source URL: {{$outlook.SourceURL}}
{{- end}}
{{- end}}
{{- else -}}
No convective outlook data available.
{{- end}}