Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3136131519 | |||
| 8578db99b3 | |||
| 86fd848a6f | |||
| 6f8e01729d | |||
| 3c7ba89e15 | |||
| cfe6748330 | |||
| 5a1134b955 | |||
| 2a33fe01cf |
24
docs/api.md
24
docs/api.md
@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
|
|||||||
GET /conditions/current
|
GET /conditions/current
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns current conditions aggregated from recent `observations` rows. The
|
Returns current conditions from recent `observations` rows. Numeric fields are
|
||||||
implemented observation window is 30 minutes.
|
aggregated over the implemented 30-minute observation window. `conditionCode`
|
||||||
|
is selected from the latest observation per source in that window by
|
||||||
|
source-balanced WMO family consensus.
|
||||||
|
|
||||||
Query parameters: `format`, `units`, `precision`.
|
Query parameters: `format`, `units`, `precision`.
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ Common `data` fields:
|
|||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
|
||||||
| `conditionText` | string | optional text derived from WMO code and day/night flag |
|
| `conditionText` | string | optional text derived from WMO code and day/night flag |
|
||||||
| `isDay` | boolean | optional |
|
| `isDay` | boolean | optional |
|
||||||
| `relativeHumidityPercent` | number | optional |
|
| `relativeHumidityPercent` | number | optional |
|
||||||
@@ -194,7 +197,8 @@ GET /conditions/current?format=json&precision=0
|
|||||||
GET /alerts/active
|
GET /alerts/active
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the latest stored alert run filtered to alerts active at request time.
|
Returns the latest stored alert run filtered to alerts active at request time,
|
||||||
|
omitting older alerts superseded by newer alert references in the same run.
|
||||||
|
|
||||||
Query parameters: `format`, `units`.
|
Query parameters: `format`, `units`.
|
||||||
|
|
||||||
@@ -212,14 +216,20 @@ Run `data` fields:
|
|||||||
| `alerts` | array | active alerts, possibly empty |
|
| `alerts` | array | active alerts, possibly empty |
|
||||||
|
|
||||||
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
Alerts are active when `messageType` is not `Cancel`, `effective` is absent or
|
||||||
at or before request time, and `expires` is absent or after request time.
|
at or before request time, and the alert end boundary is absent or after request
|
||||||
|
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
|
||||||
|
a fallback for older rows or providers that do not supply an alert-period end.
|
||||||
`onset` is presented when available but is not used as the active boundary.
|
`onset` is presented when available but is not used as the active boundary.
|
||||||
|
After active-time filtering, alerts referenced by another alert in the same run
|
||||||
|
are omitted as superseded. References from update and cancel messages are both
|
||||||
|
honored, even when the referencing alert is not itself returned.
|
||||||
|
|
||||||
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
`certainty`, `status`, `messageType`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `areaDescription`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`senderName`, and `references`. Most alert fields are optional except `id` when
|
`areaDescription`, `senderName`, and `references`. Most alert fields are
|
||||||
an alert item is present.
|
optional except `id` when an alert item is present. `ends` is the alert-period
|
||||||
|
end; `expires` is provider expiration metadata.
|
||||||
|
|
||||||
Reference fields are `id`, `identifier`, `sender`, and `sent`.
|
Reference fields are `id`, `identifier`, `sender`, and `sent`.
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ Latest parent resources use these ordering rules:
|
|||||||
story_order ASC, story_index ASC`.
|
story_order ASC, story_index ASC`.
|
||||||
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
- convective outlook runs: `as_of DESC, event_emitted_at DESC`.
|
||||||
|
|
||||||
Current conditions aggregate `observations` rows where `observed_at` is inside
|
Current conditions aggregate numeric values from `observations` rows where
|
||||||
the application-provided observation window.
|
`observed_at` is inside the application-provided observation window. They also
|
||||||
|
use the latest row per `event_source` in that window to select `condition_code`
|
||||||
|
by source-balanced WMO family consensus.
|
||||||
|
|
||||||
## Child Ordering
|
## Child Ordering
|
||||||
|
|
||||||
@@ -79,15 +81,16 @@ The repository reads only these columns.
|
|||||||
|
|
||||||
### `observations`
|
### `observations`
|
||||||
|
|
||||||
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`,
|
`event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
|
||||||
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
`condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
|
||||||
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
|
||||||
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
|
||||||
`apparent_temperature_c`, and `event_emitted_at`.
|
`apparent_temperature_c`, and `event_emitted_at`.
|
||||||
|
|
||||||
Current conditions additionally aggregate recent `observations` values for
|
Current conditions additionally read recent `observations` values for
|
||||||
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
|
temperature, apparent temperature, dewpoint, humidity, wind speed, wind
|
||||||
direction, condition code, and latest `is_day`.
|
direction, latest `is_day`, and latest condition-code candidates per
|
||||||
|
`event_source`.
|
||||||
|
|
||||||
### `observation_present_weather`
|
### `observation_present_weather`
|
||||||
|
|
||||||
@@ -105,8 +108,8 @@ and `event_emitted_at`.
|
|||||||
|
|
||||||
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
`alert_index`, `alert_id`, `event`, `headline`, `severity`, `urgency`,
|
||||||
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
`certainty`, `status`, `message_type`, `category`, `response`, `description`,
|
||||||
`instruction`, `sent`, `effective`, `onset`, `expires`, `area_description`,
|
`instruction`, `sent`, `effective`, `onset`, `ends`, `expires`,
|
||||||
`sender_name`, and `run_event_id`.
|
`area_description`, `sender_name`, and `run_event_id`.
|
||||||
|
|
||||||
### `alert_references`
|
### `alert_references`
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,12 @@ behavior deterministic.
|
|||||||
|
|
||||||
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
`/alerts/active` uses the shared `format` and `units` binder. The handler calls
|
||||||
the application service with `alertNow().UTC()` so active alert filtering uses
|
the application service with `alertNow().UTC()` so active alert filtering uses
|
||||||
the request-time instant while remaining deterministic in endpoint tests.
|
the request-time instant while remaining deterministic in endpoint tests. The
|
||||||
|
application service prefers alert `ends` over `expires` when deciding whether an
|
||||||
|
alert has ended. The application service also suppresses alerts referenced by
|
||||||
|
another alert in the same latest run. This supersession rule uses alert
|
||||||
|
references from update and cancel messages, even when the referencing message is
|
||||||
|
not returned by `/alerts/active`.
|
||||||
|
|
||||||
## Outlook Filters
|
## Outlook Filters
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ successful responses with `data: null`.
|
|||||||
|
|
||||||
- `LatestObservation`: latest row from `observations`, then present-weather
|
- `LatestObservation`: latest row from `observations`, then present-weather
|
||||||
rows from `observation_present_weather`.
|
rows from `observation_present_weather`.
|
||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: reads an aggregate row from recent `observations` using
|
||||||
application-provided observation window.
|
the application-provided observation window, then reads the latest
|
||||||
|
condition-code candidate per `event_source` in the same window.
|
||||||
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
|
||||||
`alert_references`. This is the latest stored alert snapshot; active-time
|
`alert_references`. This is the latest stored alert snapshot. The repository
|
||||||
filtering is performed by the application service.
|
maps both `ends` and `expires`; active-time filtering is performed by the
|
||||||
|
application service.
|
||||||
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
- `LatestHourlyForecast`: latest `forecasts` row where `product = 'hourly'`,
|
||||||
then child `forecast_periods`.
|
then child `forecast_periods`.
|
||||||
- `LatestNarrativeForecast`: latest `forecasts` row where
|
- `LatestNarrativeForecast`: latest `forecasts` row where
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,7 +1,7 @@
|
|||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0 h1:ZB5QWKD5DPFV3P7vyeJqXPMcSWN9qHkDUHw1LgN9hwY=
|
||||||
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
gitea.maximumdirect.net/ejr/feedapi v0.1.0/go.mod h1:3fIaFFx4ywt0TWbN8DIIBAHJn7ZQUm6PNcceqRgy3bw=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0 h1:U3yln3o2rGqfMvWVRwOGgQeqYuqMm+/p0XIRhK8TDUQ=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1 h1:dZYDpOd0vEIk6QljsrhFyrOY0Lt4WyEoazhqDFIZKNQ=
|
||||||
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.0/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
gitea.maximumdirect.net/ejr/weatherfeeder v0.12.1/go.mod h1:VVtuwrbddWdUu21ovCSSojhH5J9P6kk0/dfnFqC4/Lw=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -324,12 +324,14 @@ func TestObservationUSUnitsChangesFieldNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
||||||
|
ends := time.Date(2026, 6, 11, 14, 0, 0, 0, time.UTC)
|
||||||
h := newHandler(t, &fakeService{
|
h := newHandler(t, &fakeService{
|
||||||
alerts: &model.WeatherAlertRun{
|
alerts: &model.WeatherAlertRun{
|
||||||
AsOf: time.Now().UTC(),
|
AsOf: time.Now().UTC(),
|
||||||
Alerts: []model.WeatherAlert{{
|
Alerts: []model.WeatherAlert{{
|
||||||
ID: "abc",
|
ID: "abc",
|
||||||
Headline: "A headline",
|
Headline: "A headline",
|
||||||
|
Ends: &ends,
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}, "/alerts/active")
|
}, "/alerts/active")
|
||||||
@@ -359,6 +361,9 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) {
|
|||||||
if first["id"] != "abc" {
|
if first["id"] != "abc" {
|
||||||
t.Fatalf("expected alert id abc, got %#v", first["id"])
|
t.Fatalf("expected alert id abc, got %#v", first["id"])
|
||||||
}
|
}
|
||||||
|
if _, ok := first["ends"].(string); !ok {
|
||||||
|
t.Fatalf("expected alert ends string, got %#v", first["ends"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAlertsRouteRegistered(t *testing.T) {
|
func TestAlertsRouteRegistered(t *testing.T) {
|
||||||
@@ -456,8 +461,10 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
setAlertNowForTest(t, activeAt)
|
setAlertNowForTest(t, activeAt)
|
||||||
effective := activeAt.Add(-1 * time.Hour)
|
effective := activeAt.Add(-1 * time.Hour)
|
||||||
activeExpires := activeAt.Add(1 * time.Hour)
|
activeEnds := activeAt.Add(1 * time.Hour)
|
||||||
expiredAtBoundary := activeAt
|
expiredEndsAtBoundary := activeAt
|
||||||
|
expiredProviderMetadata := activeAt.Add(-30 * time.Minute)
|
||||||
|
activeProviderMetadata := activeAt.Add(30 * time.Minute)
|
||||||
repo := &alertRepository{
|
repo := &alertRepository{
|
||||||
alerts: &model.WeatherAlertRun{
|
alerts: &model.WeatherAlertRun{
|
||||||
AsOf: activeAt,
|
AsOf: activeAt,
|
||||||
@@ -467,21 +474,23 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
Headline: "Active warning",
|
Headline: "Active warning",
|
||||||
MessageType: "Alert",
|
MessageType: "Alert",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &activeExpires,
|
Ends: &activeEnds,
|
||||||
|
Expires: &expiredProviderMetadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "expired-alert",
|
ID: "expired-alert",
|
||||||
Headline: "Expired warning",
|
Headline: "Expired warning",
|
||||||
MessageType: "Alert",
|
MessageType: "Alert",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &expiredAtBoundary,
|
Ends: &expiredEndsAtBoundary,
|
||||||
|
Expires: &activeProviderMetadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "canceled-alert",
|
ID: "canceled-alert",
|
||||||
Headline: "Canceled warning",
|
Headline: "Canceled warning",
|
||||||
MessageType: " cancel ",
|
MessageType: " cancel ",
|
||||||
Effective: &effective,
|
Effective: &effective,
|
||||||
Expires: &activeExpires,
|
Ends: &activeEnds,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -496,7 +505,7 @@ func TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d", w.Code)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning"} {
|
for _, want := range []string{"Alerts: 1", "active-alert", "Active warning", "Ends:"} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Fatalf("expected %q in text body, got %q", want, body)
|
t.Fatalf("expected %q in text body, got %q", want, body)
|
||||||
}
|
}
|
||||||
@@ -1352,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
|
|||||||
if payload.Data["conditionText"] != "Rain" {
|
if payload.Data["conditionText"] != "Rain" {
|
||||||
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
|
||||||
}
|
}
|
||||||
|
if payload.Data["conditionCode"] != float64(63) {
|
||||||
|
t.Fatalf("expected conditionCode 63, got %#v", payload.Data["conditionCode"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCurrentConditionsUSJSON(t *testing.T) {
|
func TestCurrentConditionsUSJSON(t *testing.T) {
|
||||||
@@ -1417,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
|||||||
if !strings.Contains(w.Body.String(), "temperatureF") {
|
if !strings.Contains(w.Body.String(), "temperatureF") {
|
||||||
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "<conditionCode>2</conditionCode>") {
|
||||||
|
t.Fatalf("expected conditionCode in XML payload: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
|
||||||
@@ -1428,7 +1443,7 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
|
|||||||
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
|
||||||
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
|
||||||
}
|
}
|
||||||
if !strings.Contains(w.Body.String(), "Conditions text") {
|
if !strings.Contains(w.Body.String(), "Condition Code: 2") {
|
||||||
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
|
t.Fatalf("expected rendered text template body, got %q", w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2448,8 +2463,8 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
"outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}",
|
||||||
"weatherstories.txt.tmpl": "Weather Stories",
|
"weatherstories.txt.tmpl": "Weather Stories",
|
||||||
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
|
||||||
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
|
||||||
"conditions_current.txt.tmpl": "Conditions text",
|
"conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,25 +4,27 @@ package presenter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CurrentConditionsResponse is the response shape for /conditions/current.
|
// CurrentConditionsResponse is the response shape for /conditions/current.
|
||||||
// Unit-bearing fields are populated according to the requested unit mode.
|
// Unit-bearing fields are populated according to the requested unit mode.
|
||||||
type CurrentConditionsResponse struct {
|
type CurrentConditionsResponse struct {
|
||||||
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
|
TemperatureC *float64 `json:"temperatureC,omitempty" xml:"temperatureC,omitempty"`
|
||||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
|
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty" xml:"apparentTemperatureC,omitempty"`
|
||||||
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
|
DewpointC *float64 `json:"dewpointC,omitempty" xml:"dewpointC,omitempty"`
|
||||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
|
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty" xml:"windSpeedKmh,omitempty"`
|
||||||
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
|
TemperatureF *float64 `json:"temperatureF,omitempty" xml:"temperatureF,omitempty"`
|
||||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
|
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty" xml:"apparentTemperatureF,omitempty"`
|
||||||
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
|
DewpointF *float64 `json:"dewpointF,omitempty" xml:"dewpointF,omitempty"`
|
||||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
|
||||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
|
||||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
|
||||||
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
|
||||||
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
|
||||||
IsDayText string `json:"-" xml:"-"`
|
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
|
||||||
|
IsDayText string `json:"-" xml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
|
func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, precision int) any {
|
||||||
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
|
|||||||
out := CurrentConditionsResponse{
|
out := CurrentConditionsResponse{
|
||||||
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
|
||||||
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
|
||||||
|
ConditionCode: conditions.ConditionCode,
|
||||||
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
|
||||||
IsDay: copyBoolPtr(conditions.IsDay),
|
IsDay: copyBoolPtr(conditions.IsDay),
|
||||||
IsDayText: boolText(conditions.IsDay),
|
IsDayText: boolText(conditions.IsDay),
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if metric.ConditionText != "Sunny" {
|
if metric.ConditionText != "Sunny" {
|
||||||
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
|
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)
|
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
|
||||||
us, ok := usPayload.(CurrentConditionsResponse)
|
us, ok := usPayload.(CurrentConditionsResponse)
|
||||||
@@ -389,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
|
|||||||
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
if us.TemperatureC != nil || us.WindSpeedKmh != nil {
|
||||||
t.Fatalf("expected metric fields omitted for US payload")
|
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) {
|
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func mapAlertRow(row alertRow) indexedAlert {
|
|||||||
Sent: timePtr(row.Sent),
|
Sent: timePtr(row.Sent),
|
||||||
Effective: timePtr(row.Effective),
|
Effective: timePtr(row.Effective),
|
||||||
Onset: timePtr(row.Onset),
|
Onset: timePtr(row.Onset),
|
||||||
|
Ends: timePtr(row.Ends),
|
||||||
Expires: timePtr(row.Expires),
|
Expires: timePtr(row.Expires),
|
||||||
AreaDescription: stringValue(row.AreaDescription),
|
AreaDescription: stringValue(row.AreaDescription),
|
||||||
SenderName: stringValue(row.SenderName),
|
SenderName: stringValue(row.SenderName),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ SELECT
|
|||||||
sent,
|
sent,
|
||||||
effective,
|
effective,
|
||||||
onset,
|
onset,
|
||||||
|
ends,
|
||||||
expires,
|
expires,
|
||||||
area_description,
|
area_description,
|
||||||
sender_name
|
sender_name
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ func (r *Repository) loadAlerts(ctx context.Context, eventID string) ([]model.We
|
|||||||
&row.Sent,
|
&row.Sent,
|
||||||
&row.Effective,
|
&row.Effective,
|
||||||
&row.Onset,
|
&row.Onset,
|
||||||
|
&row.Ends,
|
||||||
&row.Expires,
|
&row.Expires,
|
||||||
&row.AreaDescription,
|
&row.AreaDescription,
|
||||||
&row.SenderName,
|
&row.SenderName,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type alertRow struct {
|
|||||||
Sent sql.NullTime
|
Sent sql.NullTime
|
||||||
Effective sql.NullTime
|
Effective sql.NullTime
|
||||||
Onset sql.NullTime
|
Onset sql.NullTime
|
||||||
|
Ends sql.NullTime
|
||||||
Expires sql.NullTime
|
Expires sql.NullTime
|
||||||
AreaDescription sql.NullString
|
AreaDescription sql.NullString
|
||||||
SenderName sql.NullString
|
SenderName sql.NullString
|
||||||
|
|||||||
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
107
internal/adapters/outbound/postgres/conditions_codes.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// conditions_codes.go selects current-conditions WMO codes from source candidates.
|
||||||
|
// Layer: adapters/outbound/postgres conditions feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
|
||||||
|
type currentConditionsConditionCodeCandidate struct {
|
||||||
|
EventSource string
|
||||||
|
ConditionCode model.WMOCode
|
||||||
|
}
|
||||||
|
|
||||||
|
type conditionCodeFamily int
|
||||||
|
|
||||||
|
const (
|
||||||
|
conditionCodeFamilyUnknown conditionCodeFamily = iota
|
||||||
|
conditionCodeFamilyClearOrCloud
|
||||||
|
conditionCodeFamilyFog
|
||||||
|
conditionCodeFamilyDrizzle
|
||||||
|
conditionCodeFamilyRain
|
||||||
|
conditionCodeFamilySnow
|
||||||
|
conditionCodeFamilyThunderstorm
|
||||||
|
)
|
||||||
|
|
||||||
|
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
|
||||||
|
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
|
||||||
|
conditionCodeFamilyFog: {45, 48},
|
||||||
|
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
|
||||||
|
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
|
||||||
|
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
|
||||||
|
conditionCodeFamilyThunderstorm: {95, 96, 99},
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
|
||||||
|
|
||||||
|
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
|
||||||
|
families := make(map[model.WMOCode]conditionCodeFamily)
|
||||||
|
for family, ranking := range currentConditionsConditionCodeRankings {
|
||||||
|
for _, code := range ranking {
|
||||||
|
families[code] = family
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return families
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
|
||||||
|
seenSources := make(map[string]struct{})
|
||||||
|
familyCounts := make(map[conditionCodeFamily]int)
|
||||||
|
codeCounts := make(map[model.WMOCode]int)
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if _, seen := seenSources[candidate.EventSource]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenSources[candidate.EventSource] = struct{}{}
|
||||||
|
|
||||||
|
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
familyCounts[family]++
|
||||||
|
codeCounts[candidate.ConditionCode]++
|
||||||
|
}
|
||||||
|
|
||||||
|
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
|
||||||
|
if !ok {
|
||||||
|
return model.WMOUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
|
||||||
|
winningFamily := conditionCodeFamilyUnknown
|
||||||
|
winningCount := 0
|
||||||
|
tied := false
|
||||||
|
|
||||||
|
for family, count := range counts {
|
||||||
|
if count > winningCount {
|
||||||
|
winningFamily = family
|
||||||
|
winningCount = count
|
||||||
|
tied = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == winningCount {
|
||||||
|
tied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if winningCount == 0 || tied {
|
||||||
|
return conditionCodeFamilyUnknown, false
|
||||||
|
}
|
||||||
|
return winningFamily, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
|
||||||
|
winningCode := model.WMOUnknown
|
||||||
|
winningCount := 0
|
||||||
|
|
||||||
|
for _, code := range currentConditionsConditionCodeRankings[family] {
|
||||||
|
if counts[code] > winningCount {
|
||||||
|
winningCode = code
|
||||||
|
winningCount = counts[code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return winningCode
|
||||||
|
}
|
||||||
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
116
internal/adapters/outbound/postgres/conditions_codes_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// conditions_codes_test.go tests current-conditions WMO code selection.
|
||||||
|
// Layer: adapters/outbound/postgres conditions feature.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
candidates []currentConditionsConditionCodeCandidate
|
||||||
|
want model.WMOCode
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clear cloud ranking breaks exact tie",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear cloud family wins over thunderstorm",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tied families return unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
0,
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rain ranking breaks exact tie",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
61,
|
||||||
|
63,
|
||||||
|
80,
|
||||||
|
),
|
||||||
|
want: 61,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three tied families return unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
61,
|
||||||
|
95,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single thunderstorm code wins",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
95,
|
||||||
|
),
|
||||||
|
want: 95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrecognized only returns unknown",
|
||||||
|
candidates: conditionCodeCandidates(
|
||||||
|
4,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
want: model.WMOUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate source only votes once",
|
||||||
|
candidates: []currentConditionsConditionCodeCandidate{
|
||||||
|
{EventSource: "source-1", ConditionCode: 95},
|
||||||
|
{EventSource: "source-1", ConditionCode: 95},
|
||||||
|
{EventSource: "source-2", ConditionCode: 0},
|
||||||
|
{EventSource: "source-3", ConditionCode: 0},
|
||||||
|
},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact code frequency wins before ranking",
|
||||||
|
candidates: []currentConditionsConditionCodeCandidate{
|
||||||
|
{EventSource: "source-1", ConditionCode: 61},
|
||||||
|
{EventSource: "source-2", ConditionCode: 63},
|
||||||
|
{EventSource: "source-3", ConditionCode: 63},
|
||||||
|
},
|
||||||
|
want: 63,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := selectCurrentConditionsConditionCode(tt.candidates)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("expected condition code %d, got %d", tt.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
|
||||||
|
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
|
||||||
|
for i, code := range codes {
|
||||||
|
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||||
|
EventSource: string(rune('a' + i)),
|
||||||
|
ConditionCode: code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
@@ -7,16 +7,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions {
|
func mapCurrentConditionsRow(row currentConditionsRow, conditionCode model.WMOCode) *app.CurrentConditions {
|
||||||
if row.SampleCount == 0 {
|
if row.SampleCount == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conditionCode := model.WMOUnknown
|
|
||||||
if row.ConditionCode.Valid {
|
|
||||||
conditionCode = model.WMOCode(row.ConditionCode.Int64)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &app.CurrentConditions{
|
return &app.CurrentConditions{
|
||||||
TemperatureC: float64Ptr(row.TemperatureC),
|
TemperatureC: float64Ptr(row.TemperatureC),
|
||||||
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ WITH windowed AS (
|
|||||||
relative_humidity_percent,
|
relative_humidity_percent,
|
||||||
wind_speed_kmh,
|
wind_speed_kmh,
|
||||||
wind_direction_degrees,
|
wind_direction_degrees,
|
||||||
condition_code,
|
|
||||||
is_day,
|
is_day,
|
||||||
observed_at
|
observed_at
|
||||||
FROM observations
|
FROM observations
|
||||||
@@ -39,7 +38,6 @@ SELECT
|
|||||||
AVG(cosd(wind_direction_degrees))
|
AVG(cosd(wind_direction_degrees))
|
||||||
)
|
)
|
||||||
END AS wind_direction_degrees,
|
END AS wind_direction_degrees,
|
||||||
MAX(condition_code) AS condition_code,
|
|
||||||
(
|
(
|
||||||
SELECT is_day
|
SELECT is_day
|
||||||
FROM windowed
|
FROM windowed
|
||||||
@@ -47,4 +45,23 @@ SELECT
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS is_day
|
) AS is_day
|
||||||
FROM windowed`
|
FROM windowed`
|
||||||
|
|
||||||
|
queryCurrentConditionsConditionCodeCandidates = `
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
event_source,
|
||||||
|
condition_code,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY event_source
|
||||||
|
ORDER BY observed_at DESC, event_emitted_at DESC
|
||||||
|
) AS source_rank
|
||||||
|
FROM observations
|
||||||
|
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
event_source,
|
||||||
|
condition_code
|
||||||
|
FROM ranked
|
||||||
|
WHERE source_rank = 1
|
||||||
|
ORDER BY event_source`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/app"
|
||||||
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
|
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
|
||||||
@@ -25,7 +26,6 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
|
|||||||
&row.RelativeHumidityPercent,
|
&row.RelativeHumidityPercent,
|
||||||
&row.WindSpeedKmh,
|
&row.WindSpeedKmh,
|
||||||
&row.WindDirectionDegrees,
|
&row.WindDirectionDegrees,
|
||||||
&row.ConditionCode,
|
|
||||||
&row.IsDay,
|
&row.IsDay,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -35,5 +35,42 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
|
|||||||
return nil, fmt.Errorf("query current conditions: %w", err)
|
return nil, fmt.Errorf("query current conditions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapCurrentConditionsRow(row), nil
|
if row.SampleCount == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var candidates []currentConditionsConditionCodeCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
eventSource string
|
||||||
|
conditionCode int64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, currentConditionsConditionCodeCandidate{
|
||||||
|
EventSource: eventSource,
|
||||||
|
ConditionCode: model.WMOCode(conditionCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|||||||
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
280
internal/adapters/outbound/postgres/conditions_read_test.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// conditions_read_test.go validates current-conditions repository read flow.
|
||||||
|
// Layer: adapters/outbound/postgres conditions read tests.
|
||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
|
||||||
|
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
|
||||||
|
{"source-a", int64(1)},
|
||||||
|
{"source-b", int64(2)},
|
||||||
|
{"source-c", int64(95)},
|
||||||
|
}, nil),
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if conditions == nil {
|
||||||
|
t.Fatal("expected current conditions")
|
||||||
|
}
|
||||||
|
if conditions.ConditionCode != 1 {
|
||||||
|
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
|
||||||
|
}
|
||||||
|
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
|
||||||
|
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
|
||||||
|
}
|
||||||
|
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
|
||||||
|
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
|
||||||
|
}
|
||||||
|
if conditions.IsDay == nil || !*conditions.IsDay {
|
||||||
|
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
|
||||||
|
}
|
||||||
|
assertCurrentConditionsTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
conditions, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if conditions != nil {
|
||||||
|
t.Fatalf("expected nil current conditions, got %+v", conditions)
|
||||||
|
}
|
||||||
|
assertCurrentConditionsTestQueriesConsumed(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
|
||||||
|
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
|
||||||
|
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
|
||||||
|
if !strings.Contains(query, want) {
|
||||||
|
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "SELECT event_source, condition_code") {
|
||||||
|
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
|
||||||
|
query := compactSQL(queryCurrentConditions)
|
||||||
|
if strings.Contains(query, "condition_code") {
|
||||||
|
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
|
||||||
|
repo, closeDB := openCurrentConditionsTestRepository(t,
|
||||||
|
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
|
||||||
|
scriptedCurrentConditionsQuery{
|
||||||
|
name: "condition code candidates",
|
||||||
|
query: queryCurrentConditionsConditionCodeCandidates,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
err: errors.New("candidate query unavailable"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
_, err := repo.CurrentConditions(context.Background(), 15)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
|
||||||
|
t.Fatalf("expected candidate query context, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
|
||||||
|
t.Helper()
|
||||||
|
currentConditionsTestScript.set(queries)
|
||||||
|
|
||||||
|
db, err := sql.Open(currentConditionsTestDriverName, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test db: %v", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
return NewRepository(db), func() {
|
||||||
|
_ = db.Close()
|
||||||
|
currentConditionsTestScript.set(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
|
||||||
|
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||||
|
return scriptedCurrentConditionsQuery{
|
||||||
|
name: "aggregate",
|
||||||
|
query: queryCurrentConditions,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
|
||||||
|
rows: [][]driver.Value{row},
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
|
||||||
|
return scriptedCurrentConditionsQuery{
|
||||||
|
name: "condition code candidates",
|
||||||
|
query: queryCurrentConditionsConditionCodeCandidates,
|
||||||
|
args: []driver.Value{int64(15)},
|
||||||
|
columns: []string{"event_source", "condition_code"},
|
||||||
|
rows: rows,
|
||||||
|
nextErr: nextErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
|
||||||
|
return []driver.Value{
|
||||||
|
sampleCount,
|
||||||
|
float64(15.5),
|
||||||
|
float64(14.2),
|
||||||
|
float64(10.1),
|
||||||
|
float64(72),
|
||||||
|
float64(24.8),
|
||||||
|
float64(182.5),
|
||||||
|
true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestDriver struct{}
|
||||||
|
|
||||||
|
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
|
||||||
|
return currentConditionsTestConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestConn struct{}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
|
||||||
|
return nil, errors.New("prepare is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
|
||||||
|
return nil, errors.New("transactions are not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
return currentConditionsTestScript.next(query, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptedCurrentConditionsQuery struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
args []driver.Value
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
err error
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestScriptState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
queries []scriptedCurrentConditionsQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentConditionsTestScript currentConditionsTestScriptState
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) remaining() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *currentConditionsTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(s.queries) == 0 {
|
||||||
|
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
|
||||||
|
}
|
||||||
|
next := s.queries[0]
|
||||||
|
s.queries = s.queries[1:]
|
||||||
|
|
||||||
|
if compactSQL(query) != compactSQL(next.query) {
|
||||||
|
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
|
||||||
|
}
|
||||||
|
if len(args) != len(next.args) {
|
||||||
|
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
|
||||||
|
}
|
||||||
|
for i, arg := range args {
|
||||||
|
if arg.Value != next.args[i] {
|
||||||
|
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.err != nil {
|
||||||
|
return nil, next.err
|
||||||
|
}
|
||||||
|
return ¤tConditionsTestRows{
|
||||||
|
columns: append([]string(nil), next.columns...),
|
||||||
|
rows: append([][]driver.Value(nil), next.rows...),
|
||||||
|
nextErr: next.nextErr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentConditionsTestRows struct {
|
||||||
|
columns []string
|
||||||
|
rows [][]driver.Value
|
||||||
|
index int
|
||||||
|
nextErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) Columns() []string {
|
||||||
|
return r.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *currentConditionsTestRows) Next(dest []driver.Value) error {
|
||||||
|
if r.index >= len(r.rows) {
|
||||||
|
if r.nextErr != nil {
|
||||||
|
err := r.nextErr
|
||||||
|
r.nextErr = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
copy(dest, r.rows[r.index])
|
||||||
|
r.index++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,5 @@ type currentConditionsRow struct {
|
|||||||
RelativeHumidityPercent sql.NullFloat64
|
RelativeHumidityPercent sql.NullFloat64
|
||||||
WindSpeedKmh sql.NullFloat64
|
WindSpeedKmh sql.NullFloat64
|
||||||
WindDirectionDegrees sql.NullFloat64
|
WindDirectionDegrees sql.NullFloat64
|
||||||
ConditionCode sql.NullInt64
|
|
||||||
IsDay sql.NullBool
|
IsDay sql.NullBool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,10 +246,40 @@ func TestAttachAlertReferencesPreservesOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapAlertRowMapsEndsAndExpires(t *testing.T) {
|
||||||
|
ends := time.Date(2026, 6, 16, 14, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
expires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600))
|
||||||
|
|
||||||
|
alert := mapAlertRow(alertRow{
|
||||||
|
AlertIndex: 1,
|
||||||
|
AlertID: "alert-1",
|
||||||
|
Ends: sql.NullTime{Time: ends, Valid: true},
|
||||||
|
Expires: sql.NullTime{Time: expires, Valid: true},
|
||||||
|
}).Alert
|
||||||
|
|
||||||
|
if alert.Ends == nil || alert.Ends.Location().String() != "UTC" || !alert.Ends.Equal(ends.UTC()) {
|
||||||
|
t.Fatalf("expected ends UTC %s, got %v", ends.UTC(), alert.Ends)
|
||||||
|
}
|
||||||
|
if alert.Expires == nil || alert.Expires.Location().String() != "UTC" || !alert.Expires.Equal(expires.UTC()) {
|
||||||
|
t.Fatalf("expected expires UTC %s, got %v", expires.UTC(), alert.Expires)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapAlertRowNullableEnds(t *testing.T) {
|
||||||
|
alert := mapAlertRow(alertRow{
|
||||||
|
AlertIndex: 1,
|
||||||
|
AlertID: "alert-1",
|
||||||
|
}).Alert
|
||||||
|
|
||||||
|
if alert.Ends != nil {
|
||||||
|
t.Fatalf("expected nil ends, got %v", alert.Ends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
|
||||||
got := mapCurrentConditionsRow(currentConditionsRow{
|
got := mapCurrentConditionsRow(currentConditionsRow{
|
||||||
SampleCount: 0,
|
SampleCount: 0,
|
||||||
})
|
}, model.WMOUnknown)
|
||||||
if got != nil {
|
if got != nil {
|
||||||
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
t.Fatalf("expected nil for empty sample window, got %+v", got)
|
||||||
}
|
}
|
||||||
@@ -265,9 +295,8 @@ func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
|
|||||||
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
|
||||||
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
|
||||||
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
|
||||||
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
|
|
||||||
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
IsDay: sql.NullBool{Bool: isDay, Valid: true},
|
||||||
})
|
}, 65)
|
||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatalf("expected mapped current conditions")
|
t.Fatalf("expected mapped current conditions")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
|
||||||
|
|
||||||
// Repository defines outbound data access used by weatherapi use cases.
|
// Repository defines outbound data access used by weatherapi use cases.
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
|
||||||
@@ -77,9 +79,10 @@ func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time)
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := cloneAlertRun(run)
|
out := cloneAlertRun(run)
|
||||||
|
supersededIDs := collectSupersededAlertIDs(out.Alerts)
|
||||||
alerts := out.Alerts[:0]
|
alerts := out.Alerts[:0]
|
||||||
for _, alert := range out.Alerts {
|
for _, alert := range out.Alerts {
|
||||||
if isActiveAlert(alert, activeAt) {
|
if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
|
||||||
alerts = append(alerts, alert)
|
alerts = append(alerts, alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +150,7 @@ func cloneAlert(alert model.WeatherAlert) model.WeatherAlert {
|
|||||||
out.Sent = copyTime(alert.Sent)
|
out.Sent = copyTime(alert.Sent)
|
||||||
out.Effective = copyTime(alert.Effective)
|
out.Effective = copyTime(alert.Effective)
|
||||||
out.Onset = copyTime(alert.Onset)
|
out.Onset = copyTime(alert.Onset)
|
||||||
|
out.Ends = copyTime(alert.Ends)
|
||||||
out.Expires = copyTime(alert.Expires)
|
out.Expires = copyTime(alert.Expires)
|
||||||
if alert.References != nil {
|
if alert.References != nil {
|
||||||
out.References = make([]model.AlertReference, len(alert.References))
|
out.References = make([]model.AlertReference, len(alert.References))
|
||||||
@@ -170,12 +174,51 @@ func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
|
|||||||
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
if alert.Effective != nil && activeAt.Before(*alert.Effective) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if alert.Expires != nil && !activeAt.Before(*alert.Expires) {
|
endBoundary := alert.Ends
|
||||||
|
if endBoundary == nil {
|
||||||
|
endBoundary = alert.Expires
|
||||||
|
}
|
||||||
|
if endBoundary != nil && !activeAt.Before(*endBoundary) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
|
||||||
|
supersededIDs := make(map[string]struct{})
|
||||||
|
for _, alert := range alerts {
|
||||||
|
for _, ref := range alert.References {
|
||||||
|
id := normalizeAlertID(referenceAlertID(ref))
|
||||||
|
if id != "" {
|
||||||
|
supersededIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return supersededIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
|
||||||
|
id := normalizeAlertID(alert.ID)
|
||||||
|
if id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := supersededIDs[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func referenceAlertID(ref model.AlertReference) string {
|
||||||
|
if strings.TrimSpace(ref.Identifier) != "" {
|
||||||
|
return ref.Identifier
|
||||||
|
}
|
||||||
|
return ref.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAlertID(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
|
||||||
out := *run
|
out := *run
|
||||||
out.Latitude = copyFloat64(run.Latitude)
|
out.Latitude = copyFloat64(run.Latitude)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) {
|
|||||||
if repo.alertRunCalls != 1 {
|
if repo.alertRunCalls != 1 {
|
||||||
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
t.Fatalf("expected one repository call, got %d", repo.alertRunCalls)
|
||||||
}
|
}
|
||||||
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"})
|
assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset", "ends-preferred"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
func TestServiceLatestActiveAlertRunNoData(t *testing.T) {
|
||||||
@@ -165,9 +165,9 @@ func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) {
|
|||||||
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) {
|
||||||
activeAt := testTime(12)
|
activeAt := testTime(12)
|
||||||
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||||
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)),
|
testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
||||||
})}
|
})}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
|
|
||||||
@@ -213,7 +213,8 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
*run.Alerts[0].Sent = testTime(1)
|
*run.Alerts[0].Sent = testTime(1)
|
||||||
*run.Alerts[0].Effective = testTime(2)
|
*run.Alerts[0].Effective = testTime(2)
|
||||||
*run.Alerts[0].Onset = testTime(3)
|
*run.Alerts[0].Onset = testTime(3)
|
||||||
*run.Alerts[0].Expires = testTime(4)
|
*run.Alerts[0].Ends = testTime(4)
|
||||||
|
*run.Alerts[0].Expires = testTime(5)
|
||||||
*run.Alerts[0].References[0].Sent = testTime(5)
|
*run.Alerts[0].References[0].Sent = testTime(5)
|
||||||
run.Alerts[0].ID = "changed"
|
run.Alerts[0].ID = "changed"
|
||||||
run.Alerts[0].References[0].ID = "changed"
|
run.Alerts[0].References[0].ID = "changed"
|
||||||
@@ -237,7 +238,10 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) {
|
||||||
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset)
|
||||||
}
|
}
|
||||||
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) {
|
if original.Alerts[0].Ends == nil || !original.Alerts[0].Ends.Equal(testTime(13)) {
|
||||||
|
t.Fatalf("expected original ends unchanged, got %v", original.Alerts[0].Ends)
|
||||||
|
}
|
||||||
|
if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(12)) {
|
||||||
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires)
|
||||||
}
|
}
|
||||||
if original.Alerts[0].References[0].ID != "ref-current" {
|
if original.Alerts[0].References[0].ID != "ref-current" {
|
||||||
@@ -246,11 +250,82 @@ func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) {
|
|||||||
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) {
|
||||||
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent)
|
||||||
}
|
}
|
||||||
if len(original.Alerts) != 8 {
|
if len(original.Alerts) != 10 {
|
||||||
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
|
testAlert("ends-after-active-expires-before", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(11)),
|
||||||
|
testAlert("expires-fallback", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(13)),
|
||||||
|
testAlert("expires-fallback-expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), nil, testTimePtr(12)),
|
||||||
|
})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:update",
|
||||||
|
"https://api.weather.gov/alerts/urn:oid:unrelated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
|
||||||
|
activeAt := testTime(12)
|
||||||
|
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
|
||||||
|
update.References = []model.AlertReference{
|
||||||
|
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
|
||||||
|
{ID: "urn:oid:id-fallback"},
|
||||||
|
}
|
||||||
|
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
|
||||||
|
svc := NewService(repo)
|
||||||
|
|
||||||
|
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
|
||||||
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
repo := &fakeRepository{outlookRun: testOutlookRun()}
|
||||||
svc := NewService(repo)
|
svc := NewService(repo)
|
||||||
@@ -509,14 +584,16 @@ func TestServicePropagatesErrors(t *testing.T) {
|
|||||||
|
|
||||||
func testAlertRun() *model.WeatherAlertRun {
|
func testAlertRun() *model.WeatherAlertRun {
|
||||||
return testAlertRunWithAlerts([]model.WeatherAlert{
|
return testAlertRunWithAlerts([]model.WeatherAlert{
|
||||||
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(12)),
|
||||||
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)),
|
testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12), testTimePtr(13)),
|
||||||
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)),
|
testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15), testTimePtr(15)),
|
||||||
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)),
|
testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13), testTimePtr(13)),
|
||||||
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)),
|
testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14), testTimePtr(14)),
|
||||||
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)),
|
testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14), testTimePtr(14)),
|
||||||
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil),
|
testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil, nil),
|
||||||
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)),
|
testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14), testTimePtr(14)),
|
||||||
|
testAlert("ends-preferred", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(14), testTimePtr(11)),
|
||||||
|
testAlert("ends-at-boundary", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(12), testTimePtr(14)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +610,7 @@ func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert {
|
func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, ends *time.Time, expires *time.Time) model.WeatherAlert {
|
||||||
refSent := testTime(8)
|
refSent := testTime(8)
|
||||||
return model.WeatherAlert{
|
return model.WeatherAlert{
|
||||||
ID: id,
|
ID: id,
|
||||||
@@ -551,6 +628,7 @@ func testAlert(id string, messageType string, sent *time.Time, effective *time.T
|
|||||||
Sent: sent,
|
Sent: sent,
|
||||||
Effective: effective,
|
Effective: effective,
|
||||||
Onset: onset,
|
Onset: onset,
|
||||||
|
Ends: ends,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
AreaDescription: "St. Louis City",
|
AreaDescription: "St. Louis City",
|
||||||
SenderName: "NWS St. Louis",
|
SenderName: "NWS St. Louis",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Headline: {{$alert.Headline}}
|
|||||||
{{- if $alert.Severity}}
|
{{- if $alert.Severity}}
|
||||||
Severity: {{$alert.Severity}}
|
Severity: {{$alert.Severity}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if $alert.Ends}}
|
||||||
|
Ends: {{$alert.Ends}}
|
||||||
|
{{- end}}
|
||||||
{{- if $alert.Expires}}
|
{{- if $alert.Expires}}
|
||||||
Expires: {{$alert.Expires}}
|
Expires: {{$alert.Expires}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{- if .Data -}}
|
{{- if .Data -}}
|
||||||
Current Conditions
|
Current Conditions
|
||||||
|
Condition Code: {{.Data.ConditionCode}}
|
||||||
{{- if .Data.ConditionText}}
|
{{- if .Data.ConditionText}}
|
||||||
Condition: {{.Data.ConditionText}}
|
Condition: {{.Data.ConditionText}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user