Compare commits
3 Commits
v0.5.2
...
cfe6748330
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe6748330 | |||
| 5a1134b955 | |||
| 2a33fe01cf |
17
docs/api.md
17
docs/api.md
@@ -194,7 +194,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 +213,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`.
|
||||||
|
|
||||||
|
|||||||
@@ -105,8 +105,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
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ successful responses with `data: null`.
|
|||||||
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
- `CurrentConditions`: aggregates recent rows from `observations` using the
|
||||||
application-provided observation window.
|
application-provided observation 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
|
||||||
|
|||||||
56
docs/roadmap/current.md
Normal file
56
docs/roadmap/current.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Current Conditions Condition-Code Selection
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Improve `/conditions/current` so numeric conditions continue to aggregate from recent observations, but `conditionCode` is selected by source-balanced WMO family consensus instead of numeric maximum. The goal is to prevent a single bad high WMO code, such as an erroneous thunderstorm code, from dominating current conditions while still returning a useful code when providers report semantically similar conditions.
|
||||||
|
|
||||||
|
## Target Behavior
|
||||||
|
|
||||||
|
Current conditions continue to use the application observation window, currently `app.ObservationWindowMinutesDefault`.
|
||||||
|
|
||||||
|
Numeric and directional fields remain aggregate values over recent `observations` rows:
|
||||||
|
|
||||||
|
- temperature, apparent temperature, dewpoint, relative humidity, and wind speed use averages;
|
||||||
|
- wind direction uses circular averaging;
|
||||||
|
- `isDay` comes from the latest row in the window.
|
||||||
|
|
||||||
|
`conditionCode` uses source-balanced consensus:
|
||||||
|
|
||||||
|
1. Select the latest observation per `event_source` within the current window.
|
||||||
|
2. Each source contributes at most one WMO condition-code vote.
|
||||||
|
3. Map each voted WMO code to a semantic family.
|
||||||
|
4. Select the family with the highest source vote count.
|
||||||
|
5. If the family vote is tied, return `model.WMOUnknown`.
|
||||||
|
6. Within the winning family, select the most frequent exact WMO code.
|
||||||
|
7. If exact-code vote is tied within the winning family, select the first code by family-specific representative ranking.
|
||||||
|
8. If there are no recognized condition-code votes, return `model.WMOUnknown`.
|
||||||
|
|
||||||
|
Family mapping and tie ranking:
|
||||||
|
|
||||||
|
| Family | Codes / tie ranking |
|
||||||
|
| --- | --- |
|
||||||
|
| `clear_or_cloud` | `0`, `1`, `2`, `3` |
|
||||||
|
| `fog` | `45`, `48` |
|
||||||
|
| `drizzle` | `51`, `53`, `55`, `56`, `57` |
|
||||||
|
| `rain` | `61`, `63`, `65`, `80`, `81`, `82`, `66`, `67` |
|
||||||
|
| `snow` | `71`, `73`, `75`, `85`, `86`, `77` |
|
||||||
|
| `thunderstorm` | `95`, `96`, `99` |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Source votes | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `0`, `1`, `2` | `0` |
|
||||||
|
| `1`, `2`, `95` | `1` |
|
||||||
|
| `0`, `95` | `model.WMOUnknown` |
|
||||||
|
| `61`, `63`, `80` | `61` |
|
||||||
|
| `61`, `95`, `0` | `model.WMOUnknown` |
|
||||||
|
| only `95` | `95` |
|
||||||
|
|
||||||
|
## Policy Decisions
|
||||||
|
|
||||||
|
- No public response schema change is required.
|
||||||
|
- No weatherfeeder change or database migration is required because `observations.event_source`, `condition_code`, `observed_at`, and `event_emitted_at` already exist.
|
||||||
|
- Provider-specific blacklists, trust weights, and source priorities are intentionally out of scope for this change.
|
||||||
|
- `WMOUnknown` is preferable to falsely choosing between tied precipitation, thunderstorm, and clear/cloud families.
|
||||||
|
- Current conditions remain a latest-window read model, not a durable derived table.
|
||||||
110
docs/roadmap/implementation.md
Normal file
110
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Implement Source-Balanced Current Conditions Condition Codes
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Implement the target behavior in `docs/roadmap/current.md`: keep `/conditions/current` response shape unchanged, continue aggregating numeric observations over the current window, and replace SQL `MAX(condition_code)` with Go-based source-balanced WMO family consensus.
|
||||||
|
|
||||||
|
This is a behavior change only. Do not add public fields, change query parameters, alter weatherfeeder tables, or add provider-specific blacklists.
|
||||||
|
|
||||||
|
## Stage 1: Add Condition-Code Consensus Helpers
|
||||||
|
|
||||||
|
Add package-local helpers in `internal/adapters/outbound/postgres` for current-conditions condition-code selection.
|
||||||
|
|
||||||
|
Required data shape:
|
||||||
|
|
||||||
|
- define a small candidate row/type containing `event_source` and `condition_code`;
|
||||||
|
- the selector accepts latest-per-source candidates and returns `model.WMOCode`.
|
||||||
|
|
||||||
|
Required selector behavior:
|
||||||
|
|
||||||
|
- use the family mapping and tie ranking from `docs/roadmap/current.md` exactly;
|
||||||
|
- ignore unrecognized WMO codes for family voting;
|
||||||
|
- return `model.WMOUnknown` when no recognized candidates exist;
|
||||||
|
- count each source at most once;
|
||||||
|
- return `model.WMOUnknown` on tied family votes;
|
||||||
|
- inside the winning family, choose the most frequent exact code;
|
||||||
|
- when exact codes tie inside the winning family, choose by family ranking.
|
||||||
|
|
||||||
|
Add focused unit tests for the selector before wiring SQL changes.
|
||||||
|
|
||||||
|
## Stage 2: Split Current-Conditions Condition-Code Querying
|
||||||
|
|
||||||
|
Update the Postgres current-conditions read path so condition-code selection is no longer computed with `MAX(condition_code)`.
|
||||||
|
|
||||||
|
Required SQL behavior:
|
||||||
|
|
||||||
|
- keep the existing aggregate query for sample count, numeric averages, circular wind direction, and latest `is_day`;
|
||||||
|
- remove `MAX(condition_code)` from the aggregate query result;
|
||||||
|
- add a separate query that returns one latest condition-code candidate per `event_source` inside the same observation window;
|
||||||
|
- latest per source is ordered by `observed_at DESC, event_emitted_at DESC`;
|
||||||
|
- candidate columns should include `event_source` and `condition_code`; timestamp columns may remain SQL-only if used only for ordering.
|
||||||
|
|
||||||
|
Required repository flow:
|
||||||
|
|
||||||
|
1. query aggregate current conditions;
|
||||||
|
2. return `nil, nil` when the aggregate sample count maps to no data, preserving current behavior;
|
||||||
|
3. query condition-code candidates using the same observation window;
|
||||||
|
4. run the Go selector;
|
||||||
|
5. map numeric aggregate fields plus selected condition code into `app.CurrentConditions`.
|
||||||
|
|
||||||
|
Keep row DTOs and mappers local to the Postgres adapter. Do not move SQL or row types into `internal/app`.
|
||||||
|
|
||||||
|
## Stage 3: Preserve API And Presentation Behavior
|
||||||
|
|
||||||
|
Keep all existing `/conditions/current` API behavior except condition-code selection.
|
||||||
|
|
||||||
|
Required invariants:
|
||||||
|
|
||||||
|
- `conditionCode` remains present in JSON/XML/text responses through the existing presenter path;
|
||||||
|
- `conditionText` continues to derive from selected `conditionCode` and `isDay`;
|
||||||
|
- `format`, `units`, and `precision` behavior is unchanged;
|
||||||
|
- no `tz` support is added;
|
||||||
|
- no public response fields are added or removed.
|
||||||
|
|
||||||
|
Update existing endpoint or presenter tests only if expected condition codes need to change because of the new selector.
|
||||||
|
|
||||||
|
## Stage 4: Update Current-Behavior Documentation
|
||||||
|
|
||||||
|
After implementation is complete, update current-behavior docs outside roadmap:
|
||||||
|
|
||||||
|
- `docs/api.md`: current conditions aggregate numeric fields and choose `conditionCode` by source-balanced WMO family consensus;
|
||||||
|
- `docs/internal/postgres-repository.md`: current conditions use an aggregate row plus a latest-per-source condition-code candidate query;
|
||||||
|
- `docs/integrations/weatherfeeder-postgres.md`: current conditions use `observations.event_source` for source-balanced condition-code selection.
|
||||||
|
|
||||||
|
Do not document any unimplemented options such as provider blacklists, trust weights, or source priorities.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
Selector tests:
|
||||||
|
|
||||||
|
- `0`, `1`, `2` returns `0` by clear/cloud ranking;
|
||||||
|
- `1`, `2`, `95` returns `1` because clear/cloud family wins;
|
||||||
|
- `0`, `95` returns `model.WMOUnknown` because families tie;
|
||||||
|
- `61`, `63`, `80` returns `61` by rain ranking;
|
||||||
|
- `61`, `95`, `0` returns `model.WMOUnknown` because three families tie;
|
||||||
|
- only `95` returns `95`;
|
||||||
|
- unrecognized-only candidates return `model.WMOUnknown`;
|
||||||
|
- duplicate candidates from the same source do not produce multiple votes if the selector receives them.
|
||||||
|
|
||||||
|
Repository tests:
|
||||||
|
|
||||||
|
- current conditions no longer chooses the highest numeric condition code;
|
||||||
|
- latest condition-code candidate per source uses `observed_at DESC, event_emitted_at DESC`;
|
||||||
|
- aggregate no-sample behavior still returns nil;
|
||||||
|
- numeric aggregate fields, wind direction, and `isDay` mapping remain unchanged;
|
||||||
|
- SQL/query errors from the candidate query include operation context.
|
||||||
|
|
||||||
|
Verification commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./internal/adapters/outbound/postgres ./internal/app
|
||||||
|
go test ./internal/adapters/inbound/httpapi ./internal/adapters/inbound/httpapi/presenter
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- `observations.event_source` is non-null in the weatherfeeder-owned schema and is safe to use as the source identity.
|
||||||
|
- The latest-per-source query can be implemented with existing Postgres features and does not require a migration.
|
||||||
|
- Current conditions remain repository-owned because they are a Postgres aggregate read model; no new app service method or public interface is needed.
|
||||||
|
- The implementation should remain standard-library and SQL based; do not add dependencies.
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -2448,7 +2457,7 @@ 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": "Conditions text",
|
||||||
} {
|
} {
|
||||||
tmpl, err := template.New(name).Parse(body)
|
tmpl, err := template.New(name).Parse(body)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -246,6 +246,36 @@ 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,
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user