Files

646 lines
23 KiB
Go

// payload_test.go validates presenter conversion and payload shaping behavior.
// Layer: adapters/inbound/httpapi/presenter unit and schema tests.
package presenter
import (
"encoding/json"
"math"
"testing"
"time"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestObservationPayloadUS(t *testing.T) {
obs := &model.WeatherObservation{
StationID: "KSTL",
Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
ConditionCode: 2,
TemperatureC: float64Ptr(20),
DewpointC: float64Ptr(10),
WindSpeedKmh: float64Ptr(100),
WindGustKmh: float64Ptr(80),
BarometricPressurePa: float64Ptr(101325),
VisibilityMeters: float64Ptr(1609.344),
ApparentTemperatureC: float64Ptr(25),
RelativeHumidityPercent: float64Ptr(50),
}
payload := ObservationPayload(obs, UnitsUS, 2)
converted, ok := payload.(WeatherObservationUS)
if !ok {
t.Fatalf("expected WeatherObservationUS payload, got %T", payload)
}
assertApprox(t, converted.TemperatureF, 68.0, 0.0001)
assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001)
assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001)
assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001)
if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil {
t.Fatalf("expected converted Fahrenheit fields to be populated")
}
}
func TestForecastPayloadUS(t *testing.T) {
issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(1 * time.Hour)
run := &model.WeatherForecastRun{
LocationID: "stl",
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Product: model.ForecastProductHourly,
ElevationMeters: float64Ptr(1000),
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(1 * time.Hour),
ConditionCode: wmoCodePtr(63),
TemperatureC: float64Ptr(0),
TemperatureCMin: float64Ptr(-5),
TemperatureCMax: float64Ptr(5),
WindSpeedKmh: float64Ptr(64.37376),
PrecipitationAmountMm: float64Ptr(25.4),
SnowfallDepthMM: float64Ptr(50.8),
}},
}
payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
}
assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001)
if len(converted.Periods) != 1 {
t.Fatalf("expected 1 period, got %d", len(converted.Periods))
}
period := converted.Periods[0]
assertApprox(t, period.TemperatureF, 32.0, 0.0001)
assertApprox(t, period.TemperatureFMin, 23.0, 0.0001)
assertApprox(t, period.TemperatureFMax, 41.0, 0.0001)
assertApprox(t, period.WindSpeedMph, 40.0, 0.0001)
assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001)
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}
func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) {
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
Periods: []model.WeatherForecastPeriod{{
StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC),
ConditionCode: wmoCodePtr(model.WMOUnknown),
TextDescription: "Cloudy",
}},
}
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil))
assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil))
}
func TestForecastPayloadTimezoneConversionMetricAndUS(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)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt.Add(1 * time.Hour),
EndTime: issuedAt.Add(2 * time.Hour),
ConditionCode: wmoCodePtr(model.WMOUnknown),
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
if !metric.IssuedAt.UTC().Equal(issuedAt) {
t.Fatalf("expected metric issuedAt to preserve instant")
}
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
// Source model remains untouched.
assertOffsetSeconds(t, run.IssuedAt, 0)
assertOffsetSeconds(t, *run.UpdatedAt, 0)
}
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(time.Hour)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(time.Hour),
ConditionCode: wmoCodePtr(model.WMOUnknown),
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
if metric == run {
t.Fatalf("expected metric payload to be copied")
}
if metric.UpdatedAt == run.UpdatedAt {
t.Fatalf("expected updatedAt pointer to be copied")
}
if !metric.IssuedAt.Equal(run.IssuedAt) {
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
}
assertOffsetSeconds(t, metric.IssuedAt, 0)
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}
func TestDiscussionPayloadTimezoneConversionAndCopySemantics(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)
run := &model.WeatherForecastDiscussion{
OfficeID: "LSX",
Product: model.ForecastDiscussionProductAFD,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
KeyMessages: []string{"msg one"},
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
}
payload := DiscussionPayload(run, UnitsMetric, loc)
discussion, ok := payload.(*model.WeatherForecastDiscussion)
if !ok {
t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload)
}
if discussion == run {
t.Fatalf("expected discussion payload to be copied")
}
if discussion.ShortTerm == run.ShortTerm {
t.Fatalf("expected shortTerm pointer to be copied")
}
assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60)
if discussion.KeyMessages[0] != "msg one" {
t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages)
}
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),
}
metric := ObservationPayload(obs, UnitsMetric, 0)
metricObs, ok := metric.(*model.WeatherObservation)
if !ok {
t.Fatalf("expected metric payload to remain model type, got %T", metric)
}
if metricObs == obs {
t.Fatalf("expected metric payload to be copied")
}
assertApprox(t, metricObs.TemperatureC, 21, 0.0001)
assertApprox(t, obs.TemperatureC, 20.6, 0.0001)
if metricObs.TemperatureC == obs.TemperatureC {
t.Fatalf("expected temperature pointer copy, got same pointer")
}
if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload")
}
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload")
}
if AlertsPayload(nil, UnitsUS) != nil {
t.Fatalf("expected nil alerts input to return nil payload")
}
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")
}
}
func TestAlertsPayloadPassThrough(t *testing.T) {
run := &model.WeatherAlertRun{
LocationID: "stl",
AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
Alerts: []model.WeatherAlert{{
ID: "alert-1",
Headline: "Storm warning",
}},
}
payload := AlertsPayload(run, UnitsUS)
if payload != run {
t.Fatalf("expected alerts payload to pass through input run")
}
}
func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
conditions := &app.CurrentConditions{
TemperatureC: float64Ptr(20),
ApparentTemperatureC: float64Ptr(18),
DewpointC: float64Ptr(10),
RelativeHumidityPercent: float64Ptr(55),
WindSpeedKmh: float64Ptr(100),
WindDirectionDegrees: float64Ptr(225),
ConditionCode: 0,
IsDay: boolPtr(true),
}
metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2)
metric, ok := metricPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload)
}
assertApprox(t, metric.TemperatureC, 20, 0.0001)
assertApprox(t, metric.WindSpeedKmh, 100, 0.0001)
if metric.TemperatureF != nil || metric.WindSpeedMph != nil {
t.Fatalf("expected US fields omitted for metric payload")
}
if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
}
if metric.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload)
}
assertApprox(t, us.TemperatureF, 68, 0.0001)
assertApprox(t, us.WindSpeedMph, 62.14, 0.0001)
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload")
}
if us.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
}
}
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
night := false
payload := CurrentConditionsPayload(&app.CurrentConditions{
ConditionCode: 0,
IsDay: &night,
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
if metric.ConditionText != "Clear" {
t.Fatalf("expected condition text Clear, got %q", metric.ConditionText)
}
}
func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) {
payload := CurrentConditionsPayload(&app.CurrentConditions{
TemperatureC: float64Ptr(-1.5),
}, UnitsMetric, 0)
metric, ok := payload.(CurrentConditionsResponse)
if !ok {
t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload)
}
assertApprox(t, metric.TemperatureC, -2, 0.0001)
}
func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
run := &model.WeatherForecastRun{
Latitude: float64Ptr(38.627123),
Longitude: float64Ptr(-90.199456),
ElevationMeters: float64Ptr(10.499),
Product: model.ForecastProductHourly,
IssuedAt: time.Now().UTC(),
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertApprox(t, metric.Latitude, 38.627123, 0.000001)
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertApprox(t, us.Latitude, 38.627123, 0.000001)
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)
discussionUpdatedAt := asOf.Add(-30 * time.Minute)
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",
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: geometry,
}},
Discussions: []model.WeatherOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &discussionUpdatedAt,
}},
}
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 len(out.Discussions) != 1 {
t.Fatalf("expected one discussion, got %d", len(out.Discussions))
}
if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank {
t.Fatalf("expected severity rank pointer to be copied")
}
if out.Discussions[0].UpdatedAt == run.Discussions[0].UpdatedAt {
t.Fatalf("expected discussion updatedAt 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)
assertOffsetSeconds(t, *out.Discussions[0].UpdatedAt, -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)
}
if out.Discussions[0].Day != 1 || out.Discussions[0].Headline != "Severe storms possible" ||
out.Discussions[0].Summary != "Scattered severe storms are possible." ||
out.Discussions[0].Discussion != "Discussion text." {
t.Fatalf("expected discussion fields preserved, got %+v", out.Discussions[0])
}
*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.Discussions[0].UpdatedAt = time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)
out.Outlooks[0].Geometry[0] = '['
out.Discussions[0].Headline = "changed"
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)
}
if !run.Discussions[0].UpdatedAt.Equal(discussionUpdatedAt) {
t.Fatalf("expected source discussion updatedAt not to mutate, got %v", run.Discussions[0].UpdatedAt)
}
if run.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("expected source discussion headline not to mutate, got %q", run.Discussions[0].Headline)
}
assertOffsetSeconds(t, run.AsOf, 0)
assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0)
assertOffsetSeconds(t, *run.Discussions[0].UpdatedAt, 0)
}
func float64Ptr(v float64) *float64 {
return &v
}
func boolPtr(v bool) *bool {
return &v
}
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
out := v
return &out
}
func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Helper()
if got == nil {
t.Fatalf("expected value near %f, got nil", want)
}
if math.Abs(*got-want) > eps {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
}
}
func assertOffsetSeconds(t *testing.T, ts time.Time, want int) {
t.Helper()
_, got := ts.Zone()
if got != want {
t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339))
}
}
func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) {
t.Helper()
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("json.Marshal(payload) error = %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("json.Unmarshal(payload) error = %v", err)
}
periodsRaw, ok := root["periods"].([]any)
if !ok || len(periodsRaw) == 0 {
t.Fatalf("expected non-empty periods in payload: %#v", root["periods"])
}
period, ok := periodsRaw[0].(map[string]any)
if !ok {
t.Fatalf("expected first period map, got %#v", periodsRaw[0])
}
for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} {
if _, exists := period[key]; exists {
t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period)
}
}
if period["textDescription"] != "Cloudy" {
t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"])
}
}