Add convective outlook presentation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user