From 626df6bfc11bde75024386cc79f3815724793146 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 28 May 2026 07:46:57 -0500 Subject: [PATCH] Made forecast-period conditionCode optional --- .../inbound/httpapi/endpoints_test.go | 61 ++++++++++--------- .../inbound/httpapi/presenter/forecast.go | 48 +++++++-------- .../inbound/httpapi/presenter/helpers.go | 10 +++ .../inbound/httpapi/presenter/payload_test.go | 13 ++-- .../outbound/postgres/forecast_mapper.go | 16 ++++- .../outbound/postgres/forecast_rows.go | 2 +- .../outbound/postgres/repository_test.go | 17 +++++- templates/forecast_hourly.txt.tmpl | 2 + templates/forecast_narrative.txt.tmpl | 2 + 9 files changed, 111 insertions(+), 60 deletions(-) diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 5537d90..ee2b2e2 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -334,7 +334,7 @@ func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), - ConditionCode: 1, + ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10), }}, }, @@ -367,7 +367,7 @@ func TestForecastPrecisionTwo(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), TemperatureC: float64Ptr(12.345), }}, }, @@ -412,7 +412,7 @@ func TestForecastJSONOmitsLegacyDescriptionFields(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Cloudy", }}, }, @@ -463,7 +463,7 @@ func TestForecastTimezoneOffsetUppercaseTZConvertsAllTimes(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: periodStart, EndTime: periodEnd, - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") @@ -498,7 +498,7 @@ func TestForecastTimezoneAbbreviationCDT(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") @@ -525,7 +525,7 @@ func TestForecastTimezoneCityAliasChicago(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") @@ -587,9 +587,9 @@ func TestForecastHourlyTodayFiltersByStartDateUTCDefault(t *testing.T) { Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 10, 0, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 1, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 2, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 10, 0, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 1, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 2, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/today") @@ -622,9 +622,9 @@ func TestForecastHourlyTomorrowFiltersByStartDateUTCDefault(t *testing.T) { Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 15, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 15, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/tomorrow") @@ -657,9 +657,9 @@ func TestForecastHourlyTodayTimezoneAffectsDaySlice(t *testing.T) { Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/today") @@ -690,8 +690,8 @@ func TestForecastHourlyTomorrowTimezoneAffectsDaySlice(t *testing.T) { Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/tomorrow") @@ -724,7 +724,7 @@ func TestForecastHourlyTodaySupportsSameFlags(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 14, 0, 0, 0, time.UTC), - ConditionCode: 1, + ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10.123), }}, }, @@ -755,7 +755,7 @@ func TestForecastHourlyTomorrowSupportsSameFlags(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC), - ConditionCode: 1, + ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10.123), }}, }, @@ -835,7 +835,7 @@ func TestForecastHourlyTodayNoMatchingPeriodsReturnsDataWithEmptyPeriods(t *test Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly/today") @@ -888,7 +888,7 @@ func TestForecastNarrativePopulatedJSONEnvelope(t *testing.T) { StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(12 * time.Hour), Name: "Tonight", - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Mostly clear overnight.", }}, }, @@ -952,7 +952,7 @@ func TestForecastNarrativeSupportsSameFlags(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), - ConditionCode: 1, + ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(20.123), }}, }, @@ -1012,9 +1012,9 @@ func TestForecastNarrativeTodayTimezoneAffectsDaySlice(t *testing.T) { Product: model.ForecastProductNarrative, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 15, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 17, 30, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 15, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 17, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/narrative/today") @@ -1045,9 +1045,9 @@ func TestForecastNarrativeTomorrowFiltersByStartDateUTCDefault(t *testing.T) { Product: model.ForecastProductNarrative, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ - {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, - {StartTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC), ConditionCode: model.WMOUnknown}, + {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, + {StartTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/narrative/tomorrow") @@ -1806,6 +1806,11 @@ func float64Ptr(v float64) *float64 { return &v } +func wmoCodePtr(v model.WMOCode) *model.WMOCode { + out := v + return &out +} + type forecastTimePayload struct { Data struct { IssuedAt time.Time `json:"issuedAt"` diff --git a/internal/adapters/inbound/httpapi/presenter/forecast.go b/internal/adapters/inbound/httpapi/presenter/forecast.go index 45d67f4..d9b2440 100644 --- a/internal/adapters/inbound/httpapi/presenter/forecast.go +++ b/internal/adapters/inbound/httpapi/presenter/forecast.go @@ -23,28 +23,28 @@ type WeatherForecastRunUS struct { // WeatherForecastPeriodUS is the US-customary response shape for forecast periods. type WeatherForecastPeriodUS struct { - StartTime time.Time `json:"startTime" xml:"startTime"` - EndTime time.Time `json:"endTime" xml:"endTime"` - Name string `json:"name,omitempty" xml:"name,omitempty"` - IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` - ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"` - TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` - TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` - TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"` - TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"` - DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` - RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` - WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` - WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` - WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` - BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` - VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` - ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` - CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty" xml:"cloudCoverPercent,omitempty"` - ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty" xml:"probabilityOfPrecipitationPercent,omitempty"` - PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty" xml:"precipitationAmountIn,omitempty"` - SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty" xml:"snowfallDepthIn,omitempty"` - UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` + StartTime time.Time `json:"startTime" xml:"startTime"` + EndTime time.Time `json:"endTime" xml:"endTime"` + Name string `json:"name,omitempty" xml:"name,omitempty"` + IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` + ConditionCode *model.WMOCode `json:"conditionCode,omitempty" xml:"conditionCode,omitempty"` + TextDescription string `json:"textDescription,omitempty" xml:"textDescription,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"` + TemperatureFMin *float64 `json:"temperatureFMin,omitempty" xml:"temperatureFMin,omitempty"` + TemperatureFMax *float64 `json:"temperatureFMax,omitempty" xml:"temperatureFMax,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty" xml:"windGustMph,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty" xml:"barometricPressureInHg,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty" xml:"visibilityMiles,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"` + CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty" xml:"cloudCoverPercent,omitempty"` + ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty" xml:"probabilityOfPrecipitationPercent,omitempty"` + PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty" xml:"precipitationAmountIn,omitempty"` + SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty" xml:"snowfallDepthIn,omitempty"` + UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` } func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any { @@ -69,7 +69,7 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, EndTime: inLocationTime(p.EndTime, tz), Name: p.Name, IsDay: copyBoolPtr(p.IsDay), - ConditionCode: p.ConditionCode, + ConditionCode: copyWMOCodePtr(p.ConditionCode), TextDescription: p.TextDescription, TemperatureF: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureC), precision), TemperatureFMin: roundedPtr(celsiusToFahrenheitPtr(p.TemperatureCMin), precision), @@ -110,7 +110,7 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, EndTime: inLocationTime(p.EndTime, tz), Name: p.Name, IsDay: copyBoolPtr(p.IsDay), - ConditionCode: p.ConditionCode, + ConditionCode: copyWMOCodePtr(p.ConditionCode), TextDescription: p.TextDescription, TemperatureC: roundedPtr(copyFloat64Ptr(p.TemperatureC), precision), TemperatureCMin: roundedPtr(copyFloat64Ptr(p.TemperatureCMin), precision), diff --git a/internal/adapters/inbound/httpapi/presenter/helpers.go b/internal/adapters/inbound/httpapi/presenter/helpers.go index ebd22c1..6050cff 100644 --- a/internal/adapters/inbound/httpapi/presenter/helpers.go +++ b/internal/adapters/inbound/httpapi/presenter/helpers.go @@ -5,6 +5,8 @@ package presenter import ( "math" "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) func celsiusToFahrenheitPtr(v *float64) *float64 { @@ -47,6 +49,14 @@ func copyTimePtr(v *time.Time) *time.Time { return &out } +func copyWMOCodePtr(v *model.WMOCode) *model.WMOCode { + if v == nil { + return nil + } + out := *v + return &out +} + func inLocationTime(v time.Time, loc *time.Location) time.Time { if loc == nil { return v diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index e37d85f..5558afb 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -55,7 +55,7 @@ func TestForecastPayloadUS(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(1 * time.Hour), - ConditionCode: 63, + ConditionCode: wmoCodePtr(63), TemperatureC: float64Ptr(0), TemperatureCMin: float64Ptr(-5), TemperatureCMax: float64Ptr(5), @@ -91,7 +91,7 @@ func TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) { 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: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Cloudy", }}, } @@ -111,7 +111,7 @@ func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt.Add(1 * time.Hour), EndTime: issuedAt.Add(2 * time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, } @@ -153,7 +153,7 @@ func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) { Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), - ConditionCode: model.WMOUnknown, + ConditionCode: wmoCodePtr(model.WMOUnknown), }}, } @@ -438,6 +438,11 @@ 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 { diff --git a/internal/adapters/outbound/postgres/forecast_mapper.go b/internal/adapters/outbound/postgres/forecast_mapper.go index 9e720b3..790264b 100644 --- a/internal/adapters/outbound/postgres/forecast_mapper.go +++ b/internal/adapters/outbound/postgres/forecast_mapper.go @@ -2,7 +2,11 @@ // Layer: adapters/outbound/postgres forecast feature. package postgres -import "gitea.maximumdirect.net/ejr/weatherfeeder/model" +import ( + "database/sql" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) func mapForecastParentRow(row forecastParentRow) model.WeatherForecastRun { return model.WeatherForecastRun{ @@ -23,7 +27,7 @@ func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod { EndTime: row.EndTime.UTC(), Name: stringValue(row.Name), IsDay: boolPtr(row.IsDay), - ConditionCode: model.WMOCode(row.ConditionCode), + ConditionCode: wmoCodePtr(row.ConditionCode), TextDescription: stringValue(row.TextDescription), TemperatureC: float64Ptr(row.TemperatureC), TemperatureCMin: float64Ptr(row.TemperatureCMin), @@ -43,3 +47,11 @@ func mapForecastPeriodRow(row forecastPeriodRow) model.WeatherForecastPeriod { UVIndex: float64Ptr(row.UVIndex), } } + +func wmoCodePtr(v sql.NullInt64) *model.WMOCode { + if !v.Valid { + return nil + } + out := model.WMOCode(v.Int64) + return &out +} diff --git a/internal/adapters/outbound/postgres/forecast_rows.go b/internal/adapters/outbound/postgres/forecast_rows.go index 2efe8bc..3bd6501 100644 --- a/internal/adapters/outbound/postgres/forecast_rows.go +++ b/internal/adapters/outbound/postgres/forecast_rows.go @@ -25,7 +25,7 @@ type forecastPeriodRow struct { EndTime time.Time Name sql.NullString IsDay sql.NullBool - ConditionCode int + ConditionCode sql.NullInt64 TextDescription sql.NullString TemperatureC sql.NullFloat64 TemperatureCMin sql.NullFloat64 diff --git a/internal/adapters/outbound/postgres/repository_test.go b/internal/adapters/outbound/postgres/repository_test.go index 1c64170..64b56b1 100644 --- a/internal/adapters/outbound/postgres/repository_test.go +++ b/internal/adapters/outbound/postgres/repository_test.go @@ -66,7 +66,7 @@ func TestMapForecastPeriodRowNullables(t *testing.T) { period := mapForecastPeriodRow(forecastPeriodRow{ StartTime: start, EndTime: end, - ConditionCode: 80, + ConditionCode: sql.NullInt64{Int64: 80, Valid: true}, Name: sql.NullString{String: "Midnight", Valid: true}, TemperatureC: sql.NullFloat64{Float64: 12.5, Valid: true}, TemperatureCMin: sql.NullFloat64{Valid: false}, @@ -84,6 +84,21 @@ func TestMapForecastPeriodRowNullables(t *testing.T) { if !period.StartTime.Equal(start) || !period.EndTime.Equal(end) { t.Fatalf("unexpected time range: %s - %s", period.StartTime, period.EndTime) } + if period.ConditionCode == nil || *period.ConditionCode != 80 { + t.Fatalf("expected condition code pointer 80, got %v", period.ConditionCode) + } +} + +func TestMapForecastPeriodRowConditionCodeNullable(t *testing.T) { + period := mapForecastPeriodRow(forecastPeriodRow{ + StartTime: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC), + ConditionCode: sql.NullInt64{Valid: false}, + }) + + if period.ConditionCode != nil { + t.Fatalf("expected nil condition code, got %v", period.ConditionCode) + } } func TestMapDiscussionParentRowNullables(t *testing.T) { diff --git a/templates/forecast_hourly.txt.tmpl b/templates/forecast_hourly.txt.tmpl index fda7df8..e461a79 100644 --- a/templates/forecast_hourly.txt.tmpl +++ b/templates/forecast_hourly.txt.tmpl @@ -7,7 +7,9 @@ Periods: {{len .Data.Periods}} {{- range $i, $period := .Data.Periods}} [{{$i}}] {{$period.StartTime}} -> {{$period.EndTime}} +{{- if $period.ConditionCode}} Condition Code: {{$period.ConditionCode}} +{{- end}} {{- if $period.TextDescription}} Summary: {{$period.TextDescription}} {{- end}} diff --git a/templates/forecast_narrative.txt.tmpl b/templates/forecast_narrative.txt.tmpl index 75af1a2..1529e80 100644 --- a/templates/forecast_narrative.txt.tmpl +++ b/templates/forecast_narrative.txt.tmpl @@ -10,7 +10,9 @@ Periods: {{len .Data.Periods}} {{- if $period.Name}} Name: {{$period.Name}} {{- end}} +{{- if $period.ConditionCode}} Condition Code: {{$period.ConditionCode}} +{{- end}} {{- if $period.TextDescription}} Summary: {{$period.TextDescription}} {{- end}}