Compare commits
10 Commits
2aba52f552
...
c3a051d372
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a051d372 | |||
| 3482551360 | |||
| d7a72f8581 | |||
| c09b7410ce | |||
| 96ce0edd46 | |||
| 7cd68ff222 | |||
| 16680e3f61 | |||
| 3389d4fa93 | |||
| 0041845935 | |||
| 3bcccb4a7b |
@@ -130,7 +130,9 @@ name the variable only; they should not contain the token value.
|
||||
- `sources`: optional map of source-specific overrides, using the same policy values.
|
||||
|
||||
Hourly forecast data is required for generated reports. Optional sources use
|
||||
the missing-source policy.
|
||||
the missing-source policy. Source override keys include `observations`,
|
||||
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
|
||||
`spc_convective_outlooks`.
|
||||
|
||||
### `scriptorium`
|
||||
|
||||
@@ -197,10 +199,13 @@ reports:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- narrative_forecast
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
- hourly_forecast
|
||||
```
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ generation.
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
|
||||
For `/outlooks/convective`, `data: null` means no latest run is available and
|
||||
follows missing-source policy. A non-null run with empty `outlooks` and
|
||||
`discussions` arrays is checked empty data, not a missing source.
|
||||
|
||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
they fail the fetch; optional malformed sources follow the missing-source policy.
|
||||
@@ -49,11 +53,66 @@ The adapter sends these query parameters:
|
||||
- `units`: from `weather_api.units`
|
||||
- `precision`: from `weather_api.precision` on observations, current
|
||||
conditions, hourly forecast, and narrative forecast requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
|
||||
discussion, and SPC convective outlook requests
|
||||
|
||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||
`format=json`.
|
||||
`format=json`. SPC convective outlook requests receive only `format=json` and
|
||||
`tz`; they do not receive `units` or `precision`.
|
||||
|
||||
## SPC Convective Outlooks
|
||||
|
||||
The adapter fetches SPC convective outlook data from:
|
||||
|
||||
```text
|
||||
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
|
||||
```
|
||||
|
||||
The response uses the standard `data` envelope. `data: null` means no latest
|
||||
run is available and follows missing-source policy. A non-null object with
|
||||
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
|
||||
|
||||
Run fields consumed by weatherreporter:
|
||||
|
||||
- `locationId`
|
||||
- `locationName`
|
||||
- `asOf`
|
||||
- `issuedAt`
|
||||
- `updatedAt`
|
||||
- `product`
|
||||
- `outlooks`
|
||||
- `discussions`
|
||||
|
||||
Outlook fields consumed:
|
||||
|
||||
- `id`
|
||||
- `provider`
|
||||
- `product`
|
||||
- `day`
|
||||
- `outlookType`
|
||||
- `label`
|
||||
- `labelText`
|
||||
- `forecaster`
|
||||
- `severityRank`
|
||||
- `validFrom`
|
||||
- `validTo`
|
||||
- `issuedAt`
|
||||
- `expiresAt`
|
||||
- `sourceUrl`
|
||||
- `imageUrl`
|
||||
- `containsLocation`
|
||||
- `geometry`
|
||||
|
||||
Discussion fields consumed:
|
||||
|
||||
- `day`
|
||||
- `headline`
|
||||
- `summary`
|
||||
- `discussion`
|
||||
- `updatedAt`
|
||||
|
||||
GeoJSON `geometry` is decoded into collected weather facts and persisted in
|
||||
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
|
||||
|
||||
## Endpoints Used
|
||||
|
||||
@@ -66,6 +125,7 @@ The adapter fetches these endpoints once per bundle:
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
- `/weatherstories/latest`
|
||||
- `/outlooks/convective`
|
||||
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
@@ -88,6 +148,7 @@ source-specific `missing_source.sources` policy:
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
- `weather_story` for `/weatherstories/latest`
|
||||
- `spc_convective_outlooks` for `/outlooks/convective`
|
||||
|
||||
Policy behavior:
|
||||
|
||||
@@ -99,6 +160,9 @@ For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
||||
follows the relevant error path, but explicit `data: null` is not a
|
||||
missing-source condition.
|
||||
|
||||
For `/outlooks/convective`, a non-null data object with empty outlook and
|
||||
discussion arrays is accepted as checked empty data.
|
||||
|
||||
## Source Identity
|
||||
|
||||
For source payloads accepted into the bundle, including the explicit `null`
|
||||
@@ -116,8 +180,8 @@ warnings list.
|
||||
|
||||
## Compatibility Assumptions
|
||||
|
||||
The adapter expects payload fields compatible with the internal forecast bundle
|
||||
types in `internal/forecast/bundle.go`, including:
|
||||
The adapter expects payload fields compatible with the internal weather data
|
||||
bundle types in `internal/weatherdata/bundle.go`, including:
|
||||
|
||||
- observation timestamps and observation values
|
||||
- current condition values
|
||||
@@ -126,6 +190,8 @@ types in `internal/forecast/bundle.go`, including:
|
||||
- discussion metadata, key messages, and short/long-term section text
|
||||
- latest weather story title, description, timing, priority, order, alt text,
|
||||
and download URL
|
||||
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
|
||||
geometry
|
||||
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
|
||||
@@ -28,7 +28,9 @@ Outputs:
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||
`alert_digest`, `spc_convective_outlooks`,
|
||||
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||
`weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
@@ -59,6 +61,17 @@ builders run. Configured `location` values are prompt context only; Weather API
|
||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||
subset of discussion fields.
|
||||
|
||||
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
||||
such as risk label, severity rank, valid times, source URL, image URL, and
|
||||
whether the outlook contains the configured location. It does not emit GeoJSON
|
||||
geometry.
|
||||
|
||||
`spc_convective_discussion` uses the same derived report-period outlooks and
|
||||
discussion records. It is omitted unless at least one retained outlook has
|
||||
severity rank `3` or higher and matching discussion text exists.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
None directly.
|
||||
@@ -86,6 +99,11 @@ return an error for invalid required inputs.
|
||||
forecast discussion, and weather story stanzas.
|
||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||
source data.
|
||||
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||
fields.
|
||||
- SPC convective discussion output is omitted unless a retained outlook has
|
||||
severity rank `3` or higher and matching discussion text is available.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -20,14 +20,23 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
||||
provenance and warnings
|
||||
provenance and warnings. SPC convective outlook source data is carried
|
||||
through when present in the bundle, including upstream geometry and source
|
||||
provenance.
|
||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||
daily summaries, daypart summaries, and Storm Report window summary
|
||||
report-period SPC convective outlooks and discussions, daily summaries,
|
||||
daypart summaries, and Storm Report window summary
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
report run.
|
||||
- SPC convective outlook derivation selects already-collected outlooks whose
|
||||
half-open valid intervals overlap the resolved report period and retains
|
||||
discussions for represented outlook days.
|
||||
- Derived SPC outlook records preserve the collected outlook fields, including
|
||||
geometry, for downstream components that need source-level facts. Prompt
|
||||
modules decide which fields are exposed to Scriptorium.
|
||||
- It does not fetch upstream data, build prompt wording, compare prior
|
||||
snapshots, write workflow state, invoke Scriptorium, or define modules.
|
||||
|
||||
@@ -55,6 +64,9 @@ and inspection.
|
||||
derivation error for reports that require daily summaries.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||
- A present SPC convective outlook source with no report-period matches
|
||||
produces non-nil empty derived outlook and discussion slices.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -67,6 +79,8 @@ Inspect:
|
||||
|
||||
- Collected facts are built once from a fetched bundle.
|
||||
- Derived facts are scoped to one resolved report.
|
||||
- SPC convective outlook selection uses the resolved report period and the
|
||||
already-collected outlook run.
|
||||
- Source provenance and warnings stay separate from ordinary fact fields.
|
||||
- Prompt-specific wording and one-off presentation decisions stay outside this
|
||||
package.
|
||||
|
||||
@@ -37,7 +37,9 @@ The registry recognizes these IDs:
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `spc_convective_outlooks`
|
||||
- `area_forecast_discussion`
|
||||
- `spc_convective_discussion`
|
||||
- `weather_story`
|
||||
- `outdoor_windows`
|
||||
- `tomorrow_planning`
|
||||
@@ -47,7 +49,9 @@ unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## Options
|
||||
|
||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||
Most modules use an empty options struct, including
|
||||
`spc_convective_outlooks` and `spc_convective_discussion`.
|
||||
`area_forecast_discussion` accepts:
|
||||
|
||||
```yaml
|
||||
sections:
|
||||
@@ -61,6 +65,28 @@ An omitted or empty `sections` list includes all available discussion sections.
|
||||
Invalid option shapes fail during config normalization or composition
|
||||
validation.
|
||||
|
||||
## SPC Convective Module Outputs
|
||||
|
||||
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
|
||||
|
||||
- `checked`
|
||||
- `as_of`
|
||||
- `issued_at`
|
||||
- `location_id`
|
||||
- `location_name`
|
||||
- `outlook_count`
|
||||
- `outlooks`
|
||||
|
||||
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||
`severity_rank`, `valid_start`, `valid_end`, `issued_at`, `expires_at`,
|
||||
`contains_location`, `source_url`, and `image_url`. It omits GeoJSON geometry.
|
||||
|
||||
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||
report-period outlook has severity rank `3` or higher and matching discussion
|
||||
text is available. Its output includes `included_because` and `discussions`;
|
||||
each discussion may include `day`, `headline`, `summary`, `discussion`, and
|
||||
`updated_at`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns module identifiers, config item envelopes, output
|
||||
|
||||
@@ -41,6 +41,7 @@ briefing:
|
||||
metadata: {}
|
||||
applicable_risk_products:
|
||||
alert_digest: {}
|
||||
spc_convective_outlooks: {}
|
||||
derived_summaries:
|
||||
derived_daily_summary: {}
|
||||
derived_daypart_summaries: {}
|
||||
@@ -49,6 +50,7 @@ briefing:
|
||||
narrative_products:
|
||||
narrative_forecast: {}
|
||||
area_forecast_discussion: {}
|
||||
spc_convective_discussion: {}
|
||||
weather_story: {}
|
||||
raw_data:
|
||||
current_conditions: {}
|
||||
@@ -66,9 +68,13 @@ Within each category, stanza order follows the module snapshot output order.
|
||||
Current categories are:
|
||||
|
||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||
discussions, and similar risk products.
|
||||
and similar risk products. Current stanzas include `alert_digest` and
|
||||
`spc_convective_outlooks`.
|
||||
- `derived_summaries`: deterministic summaries and calculated report facts.
|
||||
- `narrative_products`: official narrative text products and forecast stories.
|
||||
Current stanzas include `narrative_forecast`,
|
||||
`area_forecast_discussion`, `spc_convective_discussion`, and
|
||||
`weather_story`.
|
||||
- `raw_data`: minimally transformed underlying weather data.
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -4,10 +4,10 @@ This document describes Weather API ingestion into `weatherdata.Bundle`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and module builders. Module builders expose normalized current
|
||||
conditions and weather story context when those sources are available.
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from the
|
||||
configured Weather API and assembles the bundle consumed by forecast derivation
|
||||
and module builders. Module builders expose normalized current conditions and
|
||||
weather story context when those sources are available.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -21,7 +21,8 @@ Outputs:
|
||||
|
||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, latest weather story, source
|
||||
records, and source warnings
|
||||
records, source warnings, and typed SPC convective outlook data when that
|
||||
optional source is available
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
|
||||
## Boundaries
|
||||
@@ -54,9 +55,19 @@ contract used by this project.
|
||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||
represent a checked empty active-alert list, not a missing source.
|
||||
represent a checked empty active-alert list, not a missing source. Successful
|
||||
non-null `/outlooks/convective` responses with empty outlook and discussion
|
||||
arrays represent checked empty outlook data.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
SPC convective outlook data is stored on
|
||||
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
|
||||
run metadata, location identifiers, ordered outlook records, discussion
|
||||
records, and each outlook's raw GeoJSON geometry. Source provenance for this
|
||||
payload uses the `spc_convective_outlooks` source name, endpoint
|
||||
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
|
||||
of the raw `data` object.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
No resume behavior. Optional missing or malformed sources may be omitted,
|
||||
@@ -72,6 +83,8 @@ data is required and cannot be skipped.
|
||||
- Optional sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
- Explicit `data: null` from `/outlooks/convective` follows optional
|
||||
missing-source policy.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ reports:
|
||||
- derived_daypart_summaries
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
@@ -84,6 +85,7 @@ reports:
|
||||
- key_messages
|
||||
- short_term
|
||||
- long_term
|
||||
- spc_convective_discussion
|
||||
- weather_story
|
||||
- outdoor_windows
|
||||
- hourly_forecast
|
||||
|
||||
@@ -21,6 +21,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const (
|
||||
convectiveOutlooksEndpoint = "/outlooks/convective"
|
||||
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL *url.URL
|
||||
httpClient *http.Client
|
||||
@@ -112,6 +117,9 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return builder.bundle, nil
|
||||
}
|
||||
@@ -264,6 +272,29 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
|
||||
}
|
||||
var run weatherdata.ConvectiveOutlookRun
|
||||
if err := decodeSource(raw, &run); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
if run.IssuedAt != nil {
|
||||
source.IssuedAt = run.IssuedAt
|
||||
} else {
|
||||
source.IssuedAt = run.AsOf
|
||||
}
|
||||
source.UpdatedAt = run.UpdatedAt
|
||||
b.bundle.SPCConvectiveOutlooks = &run
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
|
||||
source.Missing = true
|
||||
if required {
|
||||
|
||||
@@ -55,8 +55,17 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if len(bundle.Sources) != 7 {
|
||||
t.Fatalf("Sources length = %d, want 7", len(bundle.Sources))
|
||||
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 0 {
|
||||
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
|
||||
@@ -70,6 +79,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if !containsPath(requested, "/weatherstories/latest") {
|
||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||
}
|
||||
if !containsPath(requested, convectiveOutlooksEndpoint) {
|
||||
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -92,6 +104,12 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing units=us", rawURL)
|
||||
}
|
||||
@@ -127,6 +145,25 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||
}
|
||||
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if outlooks.Endpoint != convectiveOutlooksEndpoint {
|
||||
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
|
||||
}
|
||||
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
|
||||
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
|
||||
}
|
||||
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
|
||||
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
|
||||
}
|
||||
if outlooks.Missing {
|
||||
t.Fatal("convective outlook source Missing = true, want false")
|
||||
}
|
||||
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
|
||||
}
|
||||
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
|
||||
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
@@ -189,6 +226,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks != nil {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.SPCConvectiveOutlooks == nil {
|
||||
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
|
||||
}
|
||||
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
|
||||
if source.Missing || len(source.Warnings) != 0 {
|
||||
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
|
||||
}
|
||||
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
|
||||
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Source == sourceSPCConvectiveOutlooks {
|
||||
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -363,13 +453,14 @@ type handlerOverride struct {
|
||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||
t.Helper()
|
||||
fixtures := map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
convectiveOutlooksEndpoint: "convective_outlooks.json",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
|
||||
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
50
internal/adapters/weatherapi/testdata/convective_outlooks.json
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"data": {
|
||||
"locationId": "nws-lsx-grid-90-74",
|
||||
"locationName": "St. Louis, MO",
|
||||
"asOf": "2026-05-29T16:00:00Z",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"updatedAt": "2026-05-29T16:05:00Z",
|
||||
"product": "convective_outlook",
|
||||
"outlooks": [
|
||||
{
|
||||
"id": "day1-categorical-slight",
|
||||
"provider": "spc",
|
||||
"product": "convective_outlook",
|
||||
"day": 1,
|
||||
"outlookType": "categorical",
|
||||
"label": "SLGT",
|
||||
"labelText": "Slight Risk",
|
||||
"forecaster": "Smith",
|
||||
"severityRank": 3,
|
||||
"validFrom": "2026-05-29T13:00:00-05:00",
|
||||
"validTo": "2026-05-30T07:00:00-05:00",
|
||||
"issuedAt": "2026-05-29T15:45:00Z",
|
||||
"expiresAt": "2026-05-30T07:00:00-05:00",
|
||||
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
"containsLocation": true,
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[-91.0, 38.0],
|
||||
[-90.0, 38.5],
|
||||
[-89.5, 37.8],
|
||||
[-91.0, 38.0]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"discussions": [
|
||||
{
|
||||
"day": 1,
|
||||
"headline": "Severe storms possible",
|
||||
"summary": "Scattered severe storms are possible.",
|
||||
"discussion": "A few storms may become severe during the afternoon.",
|
||||
"updatedAt": "2026-05-29T16:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -167,9 +169,13 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
!strings.Contains(string(data), "current_conditions:") ||
|
||||
!strings.Contains(string(data), "narrative_forecast:") ||
|
||||
!strings.Contains(string(data), "hourly_forecast:") ||
|
||||
!strings.Contains(string(data), "area_forecast_discussion:") {
|
||||
!strings.Contains(string(data), "area_forecast_discussion:") ||
|
||||
!strings.Contains(string(data), "spc_convective_outlooks:") {
|
||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "spc_convective_discussion:") {
|
||||
t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "source_warnings:") {
|
||||
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
|
||||
}
|
||||
@@ -182,9 +188,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
storyIndex := strings.Index(string(data), " weather_story:")
|
||||
currentIndex := strings.Index(string(data), " current_conditions:")
|
||||
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
||||
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||
outlookIndex := strings.Index(string(data), " spc_convective_outlooks:")
|
||||
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
|
||||
!(riskIndex < alertIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
|
||||
}
|
||||
savedDataPackage, err := promptinput.LoadYAML(data)
|
||||
@@ -197,6 +204,10 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
||||
t.Fatal("data package metadata stanza missing")
|
||||
}
|
||||
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
|
||||
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
|
||||
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
|
||||
}
|
||||
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
|
||||
if !ok || current["condition_text"] != "Clear" {
|
||||
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
||||
@@ -244,6 +255,86 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
||||
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
|
||||
result := generateDailyReportForTest(t, cfg)
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
|
||||
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok {
|
||||
t.Fatal("module snapshot missing spc_convective_discussion stanza")
|
||||
}
|
||||
data := readDataPackageForTest(t, result)
|
||||
text := string(data)
|
||||
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
|
||||
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
" spc_convective_outlooks:",
|
||||
" spc_convective_discussion:",
|
||||
" included_because: severity_rank >= 3",
|
||||
" label_text: Slight Risk",
|
||||
" severity_rank: 3",
|
||||
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("data package missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
riskIndex := strings.Index(text, " applicable_risk_products:")
|
||||
alertIndex := strings.Index(text, " alert_digest:")
|
||||
outlookIndex := strings.Index(text, " spc_convective_outlooks:")
|
||||
derivedIndex := strings.Index(text, " derived_summaries:")
|
||||
narrativeIndex := strings.Index(text, " narrative_products:")
|
||||
forecastIndex := strings.Index(text, " narrative_forecast:")
|
||||
afdIndex := strings.Index(text, " area_forecast_discussion:")
|
||||
discussionIndex := strings.Index(text, " spc_convective_discussion:")
|
||||
storyIndex := strings.Index(text, " weather_story:")
|
||||
rawIndex := strings.Index(text, " raw_data:")
|
||||
if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 ||
|
||||
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) ||
|
||||
!(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) {
|
||||
t.Fatalf("data package category order is wrong:\n%s", text)
|
||||
}
|
||||
|
||||
loaded, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
|
||||
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
||||
}
|
||||
if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok {
|
||||
t.Fatal("loaded package missing spc_convective_discussion stanza")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) {
|
||||
server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
|
||||
result := generateDailyReportForTest(t, cfg)
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
|
||||
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
|
||||
}
|
||||
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok {
|
||||
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
|
||||
}
|
||||
text := string(readDataPackageForTest(t, result))
|
||||
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") || !strings.Contains(text, " severity_rank: 2") {
|
||||
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
|
||||
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
|
||||
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
@@ -1117,6 +1208,11 @@ func moduleIDsForTest(ids []module.ID) []string {
|
||||
}
|
||||
|
||||
func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
|
||||
}
|
||||
|
||||
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
@@ -1134,6 +1230,8 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(convectiveResponse))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -1142,6 +1240,12 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
|
||||
|
||||
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
|
||||
|
||||
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
|
||||
|
||||
func TestResolveGenerateStorm(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
@@ -1335,6 +1439,37 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
||||
t.Helper()
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(result.DataPackagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func assertPathsExist(t *testing.T, paths ...string) {
|
||||
t.Helper()
|
||||
for _, path := range paths {
|
||||
|
||||
@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
||||
return ctx.Collected.Discussion != nil
|
||||
case module.CollectedWeatherStory:
|
||||
return ctx.Collected.WeatherStory != nil
|
||||
case module.CollectedSPCConvectiveOutlooks:
|
||||
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||
case module.CollectedSourceMetadata:
|
||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||
default:
|
||||
@@ -183,6 +185,8 @@ func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext)
|
||||
return len(ctx.Derived.DaypartSummaries) > 0
|
||||
case module.RequiresDerivedPrecipTiming:
|
||||
return true
|
||||
case module.RequiresDerivedSPCConvectiveOutlooks:
|
||||
return ctx.Derived.SPCConvectiveOutlooks != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -323,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
@@ -332,6 +346,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildAreaForecastDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildSPCConvectiveDiscussionModule,
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: "weather_story",
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||
@@ -128,6 +130,30 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = true, want false without source")
|
||||
}
|
||||
|
||||
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
||||
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
||||
ctx := ModuleContext{}
|
||||
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
||||
}
|
||||
|
||||
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
||||
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
||||
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
||||
}
|
||||
}
|
||||
|
||||
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||
}
|
||||
|
||||
71
internal/briefing/spc_convective_discussion_module.go
Normal file
71
internal/briefing/spc_convective_discussion_module.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||
|
||||
type SPCConvectiveDiscussionModule struct {
|
||||
IncludedBecause string `json:"included_because"`
|
||||
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
|
||||
}
|
||||
|
||||
type SPCConvectiveDiscussionRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) {
|
||||
return nil, nil
|
||||
}
|
||||
records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||
Discussions: records,
|
||||
}
|
||||
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||
}
|
||||
|
||||
func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool {
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||
retainedDays := map[int]struct{}{}
|
||||
for _, outlook := range outlooks {
|
||||
retainedDays[outlook.Day] = struct{}{}
|
||||
}
|
||||
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
if _, ok := retainedDays[discussion.Day]; !ok {
|
||||
continue
|
||||
}
|
||||
if discussion.Discussion == "" {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveDiscussionRecord{
|
||||
Day: discussion.Day,
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
155
internal/briefing/spc_convective_discussion_module_test.go
Normal file
155
internal/briefing/spc_convective_discussion_module_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(discussion) error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
|
||||
}
|
||||
|
||||
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule(outlooks) error = %v", err)
|
||||
}
|
||||
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
|
||||
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
|
||||
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
|
||||
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||
}
|
||||
discussion := value.Discussions[0]
|
||||
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||
}
|
||||
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
|
||||
t.Fatalf("value = %#v, want above-threshold discussion", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
|
||||
})
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
ctx.Derived.SPCConvectiveDiscussions = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want omitted for missing optional source", output)
|
||||
}
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
|
||||
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = discussions
|
||||
return ctx
|
||||
}
|
||||
|
||||
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
|
||||
return weatherdata.ConvectiveOutlookDiscussion{
|
||||
Day: day,
|
||||
Headline: headline,
|
||||
Summary: summary,
|
||||
Discussion: discussion,
|
||||
UpdatedAt: ptrModuleTime(updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func ptrModuleTime(value string) *time.Time {
|
||||
parsed := mustParseModuleTime(value)
|
||||
return &parsed
|
||||
}
|
||||
93
internal/briefing/spc_convective_outlooks_module.go
Normal file
93
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type SPCConvectiveOutlooksModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
LocationID string `json:"location_id,omitempty"`
|
||||
LocationName string `json:"location_name,omitempty"`
|
||||
OutlookCount int `json:"outlook_count"`
|
||||
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlook_type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
SeverityRank *int `json:"severity_rank,omitempty"`
|
||||
ValidStart string `json:"valid_start,omitempty"`
|
||||
ValidEnd string `json:"valid_end,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContainsLocation bool `json:"contains_location"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := SPCConvectiveOutlooksModule{}
|
||||
run := ctx.Collected.SPCConvectiveOutlooks
|
||||
if run != nil {
|
||||
value.Checked = true
|
||||
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||
value.LocationID = run.LocationID
|
||||
value.LocationName = run.LocationName
|
||||
}
|
||||
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||
}
|
||||
if value.Checked && value.IssuedAt == "" && ok {
|
||||
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||
}
|
||||
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone)
|
||||
value.OutlookCount = len(value.Outlooks)
|
||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord {
|
||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
records = append(records, SPCConvectiveOutlookRecord{
|
||||
Day: outlook.Day,
|
||||
OutlookType: outlook.OutlookType,
|
||||
Label: outlook.Label,
|
||||
LabelText: outlook.LabelText,
|
||||
SeverityRank: copyInt(outlook.SeverityRank),
|
||||
ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone),
|
||||
ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone),
|
||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||
ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone),
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(*value, timezone)
|
||||
}
|
||||
|
||||
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
return source, true
|
||||
}
|
||||
}
|
||||
return weatherdata.Source{}, false
|
||||
}
|
||||
141
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
141
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 3
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
IssuedAt: &issuedAt,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||
}
|
||||
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||
}
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
got := value.Outlooks[0]
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.SeverityRank == nil || *got.SeverityRank != 3 {
|
||||
t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank)
|
||||
}
|
||||
if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
}
|
||||
if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and source/image URLs", got)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "outlooks") {
|
||||
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||
Name: string(module.SPCConvectiveOutlooks),
|
||||
Missing: true,
|
||||
}}
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||
}
|
||||
}
|
||||
@@ -996,6 +996,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -126,10 +126,13 @@ reports:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- current_conditions
|
||||
- alert_digest
|
||||
- spc_convective_outlooks
|
||||
- id: area_forecast_discussion
|
||||
options:
|
||||
sections:
|
||||
- short_term
|
||||
- spc_convective_discussion
|
||||
`)
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
@@ -138,15 +141,20 @@ reports:
|
||||
}
|
||||
overrides := cfg.ReportModuleOverrides()
|
||||
items := overrides[report.DailyToday]
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("daily override length = %d, want 3", len(items))
|
||||
if len(items) != 6 {
|
||||
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||
}
|
||||
if items[0].ID != module.Metadata || items[1].ID != module.CurrentConditions || items[2].ID != module.AreaForecastDiscussion {
|
||||
if items[0].ID != module.Metadata ||
|
||||
items[1].ID != module.CurrentConditions ||
|
||||
items[2].ID != module.AlertDigest ||
|
||||
items[3].ID != module.SPCConvectiveOutlooks ||
|
||||
items[4].ID != module.AreaForecastDiscussion ||
|
||||
items[5].ID != module.SPCConvectiveDiscussion {
|
||||
t.Fatalf("daily override = %#v, want configured module order", items)
|
||||
}
|
||||
options, ok := items[2].Options.(module.AreaForecastDiscussionOptions)
|
||||
options, ok := items[4].Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[2].Options)
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", items[4].Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != "short_term" {
|
||||
t.Fatalf("AFD sections = %#v, want short_term", options.Sections)
|
||||
|
||||
@@ -3,6 +3,7 @@ package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
@@ -12,15 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type CollectedFacts struct {
|
||||
FetchedAt time.Time
|
||||
Observation *weatherdata.Observation
|
||||
Current *weatherdata.Current
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Discussion *weatherdata.Discussion
|
||||
Daily *weatherdata.ForecastRun
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
FetchedAt time.Time
|
||||
Observation *weatherdata.Observation
|
||||
Current *weatherdata.Current
|
||||
Hourly *weatherdata.ForecastRun
|
||||
Narrative *weatherdata.ForecastRun
|
||||
Alerts *weatherdata.AlertRun
|
||||
Discussion *weatherdata.Discussion
|
||||
Daily *weatherdata.ForecastRun
|
||||
WeatherStory *weatherdata.WeatherStory
|
||||
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||
|
||||
SourceProvenance []weatherdata.Source
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
@@ -31,33 +33,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
||||
return CollectedFacts{}
|
||||
}
|
||||
return CollectedFacts{
|
||||
FetchedAt: bundle.FetchedAt,
|
||||
Observation: bundle.Observation,
|
||||
Current: bundle.Current,
|
||||
Hourly: bundle.Hourly,
|
||||
Narrative: bundle.Narrative,
|
||||
Alerts: bundle.Alerts,
|
||||
Discussion: bundle.Discussion,
|
||||
Daily: bundle.Daily,
|
||||
WeatherStory: bundle.WeatherStory,
|
||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||
FetchedAt: bundle.FetchedAt,
|
||||
Observation: bundle.Observation,
|
||||
Current: bundle.Current,
|
||||
Hourly: bundle.Hourly,
|
||||
Narrative: bundle.Narrative,
|
||||
Alerts: bundle.Alerts,
|
||||
Discussion: bundle.Discussion,
|
||||
Daily: bundle.Daily,
|
||||
WeatherStory: bundle.WeatherStory,
|
||||
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||
}
|
||||
}
|
||||
|
||||
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: f.FetchedAt,
|
||||
Observation: f.Observation,
|
||||
Current: f.Current,
|
||||
Hourly: f.Hourly,
|
||||
Narrative: f.Narrative,
|
||||
Alerts: f.Alerts,
|
||||
Discussion: f.Discussion,
|
||||
Daily: f.Daily,
|
||||
WeatherStory: f.WeatherStory,
|
||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||
FetchedAt: f.FetchedAt,
|
||||
Observation: f.Observation,
|
||||
Current: f.Current,
|
||||
Hourly: f.Hourly,
|
||||
Narrative: f.Narrative,
|
||||
Alerts: f.Alerts,
|
||||
Discussion: f.Discussion,
|
||||
Daily: f.Daily,
|
||||
WeatherStory: f.WeatherStory,
|
||||
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +77,8 @@ type DerivedFacts struct {
|
||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []forecast.AlertOverlap
|
||||
SPCConvectiveOutlooks []weatherdata.ConvectiveOutlook
|
||||
SPCConvectiveDiscussions []weatherdata.ConvectiveOutlookDiscussion
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
PrecipTiming forecast.PrecipTiming
|
||||
@@ -96,11 +102,14 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
}
|
||||
bundle := req.Collected.Bundle()
|
||||
period := req.Resolved.ValidPeriod
|
||||
spcOutlooks, spcDiscussions := selectSPCConvectiveOutlooks(req.Collected.SPCConvectiveOutlooks, period)
|
||||
derived := DerivedFacts{
|
||||
ValidPeriodHourlyPeriods: forecast.SelectHourlyPeriods(req.Collected.Hourly, period),
|
||||
ValidPeriodNarrativePeriods: forecast.SelectHourlyPeriods(req.Collected.Narrative, period),
|
||||
ValidPeriodDailyPeriods: forecast.SelectHourlyPeriods(req.Collected.Daily, period),
|
||||
AlertOverlaps: forecast.AlertOverlaps(req.Collected.Alerts, period),
|
||||
SPCConvectiveOutlooks: spcOutlooks,
|
||||
SPCConvectiveDiscussions: spcDiscussions,
|
||||
}
|
||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||
|
||||
@@ -139,3 +148,81 @@ func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func selectSPCConvectiveOutlooks(run *weatherdata.ConvectiveOutlookRun, period timeutil.Period) ([]weatherdata.ConvectiveOutlook, []weatherdata.ConvectiveOutlookDiscussion) {
|
||||
if run == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
outlooks := make([]weatherdata.ConvectiveOutlook, 0, len(run.Outlooks))
|
||||
days := map[int]struct{}{}
|
||||
for _, outlook := range run.Outlooks {
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(period) {
|
||||
continue
|
||||
}
|
||||
outlooks = append(outlooks, outlook)
|
||||
days[outlook.Day] = struct{}{}
|
||||
}
|
||||
sort.SliceStable(outlooks, func(i, j int) bool {
|
||||
left := outlooks[i]
|
||||
right := outlooks[j]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.OutlookType != right.OutlookType {
|
||||
return left.OutlookType < right.OutlookType
|
||||
}
|
||||
leftRank, leftRankOK := severityRank(left)
|
||||
rightRank, rightRankOK := severityRank(right)
|
||||
if leftRankOK != rightRankOK {
|
||||
return leftRankOK
|
||||
}
|
||||
if leftRankOK && leftRank != rightRank {
|
||||
return leftRank > rightRank
|
||||
}
|
||||
if !left.ValidFrom.Equal(right.ValidFrom) {
|
||||
return left.ValidFrom.Before(right.ValidFrom)
|
||||
}
|
||||
if left.Label != right.Label {
|
||||
return left.Label < right.Label
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
|
||||
discussions := make([]weatherdata.ConvectiveOutlookDiscussion, 0, len(run.Discussions))
|
||||
for _, discussion := range run.Discussions {
|
||||
if _, ok := days[discussion.Day]; ok {
|
||||
discussions = append(discussions, discussion)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(discussions, func(i, j int) bool {
|
||||
left := discussions[i]
|
||||
right := discussions[j]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.UpdatedAt != nil && right.UpdatedAt != nil && !left.UpdatedAt.Equal(*right.UpdatedAt) {
|
||||
return left.UpdatedAt.Before(*right.UpdatedAt)
|
||||
}
|
||||
if (left.UpdatedAt != nil) != (right.UpdatedAt != nil) {
|
||||
return left.UpdatedAt != nil
|
||||
}
|
||||
if left.Headline != right.Headline {
|
||||
return left.Headline < right.Headline
|
||||
}
|
||||
if left.Summary != right.Summary {
|
||||
return left.Summary < right.Summary
|
||||
}
|
||||
return left.Discussion < right.Discussion
|
||||
})
|
||||
|
||||
return outlooks, discussions
|
||||
}
|
||||
|
||||
func severityRank(outlook weatherdata.ConvectiveOutlook) (int, bool) {
|
||||
if outlook.SeverityRank == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *outlook.SeverityRank, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package facts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,14 +17,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
FetchedAt: fetchedAt,
|
||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||
Product: "convective_outlook",
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{{
|
||||
ID: "day1-categorical-slight",
|
||||
Label: "SLGT",
|
||||
}},
|
||||
},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
||||
}
|
||||
|
||||
collected := BuildCollected(bundle)
|
||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
||||
}
|
||||
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
|
||||
}
|
||||
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||
}
|
||||
@@ -36,6 +47,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
||||
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||
}
|
||||
|
||||
roundTrip := collected.Bundle()
|
||||
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
|
||||
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||
@@ -166,6 +182,108 @@ func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||
location := testLocation()
|
||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||
bundle := testBundle(location)
|
||||
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resolved report.Resolved
|
||||
wantOutlookIDs []string
|
||||
wantDiscussion []string
|
||||
}{
|
||||
{
|
||||
name: "daily today",
|
||||
resolved: resolveForTest(t, report.DailyToday, now, location),
|
||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||
},
|
||||
{
|
||||
name: "daily tomorrow",
|
||||
resolved: resolveForTest(t, report.DailyTomorrow, now, location),
|
||||
wantOutlookIDs: []string{"sat-enhanced"},
|
||||
wantDiscussion: []string{"day2"},
|
||||
},
|
||||
{
|
||||
name: "three day",
|
||||
resolved: resolveForTest(t, report.ThreeDay, now, location),
|
||||
wantOutlookIDs: []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic", "sat-enhanced", "sun-slight"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late", "day2", "day3"},
|
||||
},
|
||||
{
|
||||
name: "weekend",
|
||||
resolved: resolveForTest(t, report.Weekend, now, location),
|
||||
wantOutlookIDs: []string{"sat-enhanced", "sun-slight"},
|
||||
wantDiscussion: []string{"day2", "day3"},
|
||||
},
|
||||
{
|
||||
name: "storm",
|
||||
resolved: resolveStormForTest(t, location),
|
||||
wantOutlookIDs: []string{"fri-storm", "fri-low"},
|
||||
wantDiscussion: []string{"day1 early", "day1 late"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: tt.resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
if got := outlookIDs(derived.SPCConvectiveOutlooks); strings.Join(got, ",") != strings.Join(tt.wantOutlookIDs, ",") {
|
||||
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, tt.wantOutlookIDs)
|
||||
}
|
||||
if got := discussionHeadlines(derived.SPCConvectiveDiscussions); strings.Join(got, ",") != strings.Join(tt.wantDiscussion, ",") {
|
||||
t.Fatalf("SPCConvectiveDiscussions = %#v, want %#v", got, tt.wantDiscussion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
bundle := testBundle(location)
|
||||
bundle.SPCConvectiveOutlooks = nil
|
||||
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(missing source) error = %v", err)
|
||||
}
|
||||
if derived.SPCConvectiveOutlooks != nil || derived.SPCConvectiveDiscussions != nil {
|
||||
t.Fatalf("missing source derived outlooks=%#v discussions=%#v, want nil slices", derived.SPCConvectiveOutlooks, derived.SPCConvectiveDiscussions)
|
||||
}
|
||||
|
||||
bundle.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{Outlooks: []weatherdata.ConvectiveOutlook{}, Discussions: []weatherdata.ConvectiveOutlookDiscussion{}}
|
||||
derived, err = BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(checked empty source) error = %v", err)
|
||||
}
|
||||
if derived.SPCConvectiveOutlooks == nil || len(derived.SPCConvectiveOutlooks) != 0 {
|
||||
t.Fatalf("checked empty outlooks = %#v, want non-nil empty slice", derived.SPCConvectiveOutlooks)
|
||||
}
|
||||
if derived.SPCConvectiveDiscussions == nil || len(derived.SPCConvectiveDiscussions) != 0 {
|
||||
t.Fatalf("checked empty discussions = %#v, want non-nil empty slice", derived.SPCConvectiveDiscussions)
|
||||
}
|
||||
}
|
||||
|
||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||
@@ -193,6 +311,65 @@ func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
}
|
||||
}
|
||||
|
||||
func testConvectiveOutlookRun(location *time.Location) *weatherdata.ConvectiveOutlookRun {
|
||||
rank1 := 1
|
||||
rank2 := 2
|
||||
rank3 := 3
|
||||
rank4 := 4
|
||||
rank5 := 5
|
||||
return &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{
|
||||
convectiveOutlook(location, "mon-outside", 4, "categorical", "MDT", &rank5, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00"),
|
||||
convectiveOutlook(location, "sun-slight", 3, "categorical", "SLGT", &rank3, "2026-05-31T06:00:00-05:00", "2026-06-01T00:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-low", 1, "categorical", "MRGL", &rank1, "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-missing-rank", 1, "categorical", "GEN", nil, "2026-05-29T08:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||
convectiveOutlook(location, "sat-enhanced", 2, "categorical", "ENH", &rank4, "2026-05-30T01:00:00-05:00", "2026-05-30T12:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-high", 1, "categorical", "SLGT", &rank3, "2026-05-29T07:00:00-05:00", "2026-05-29T11:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-probabilistic", 1, "probabilistic", "5%", &rank2, "2026-05-29T05:00:00-05:00", "2026-05-29T10:00:00-05:00"),
|
||||
convectiveOutlook(location, "fri-storm", 1, "categorical", "SLGT", &rank2, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00"),
|
||||
},
|
||||
Discussions: []weatherdata.ConvectiveOutlookDiscussion{
|
||||
{Day: 4, Headline: "day4", UpdatedAt: ptrTime(mustParse("2026-05-31T10:00:00-05:00"))},
|
||||
{Day: 1, Headline: "day1 late", UpdatedAt: ptrTime(mustParse("2026-05-29T09:00:00-05:00"))},
|
||||
{Day: 3, Headline: "day3", UpdatedAt: ptrTime(mustParse("2026-05-31T08:00:00-05:00"))},
|
||||
{Day: 1, Headline: "day1 early", UpdatedAt: ptrTime(mustParse("2026-05-29T08:00:00-05:00"))},
|
||||
{Day: 2, Headline: "day2", UpdatedAt: ptrTime(mustParse("2026-05-30T08:00:00-05:00"))},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convectiveOutlook(location *time.Location, id string, day int, outlookType string, label string, rank *int, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
|
||||
return weatherdata.ConvectiveOutlook{
|
||||
ID: id,
|
||||
Day: day,
|
||||
OutlookType: outlookType,
|
||||
Label: label,
|
||||
SeverityRank: rank,
|
||||
ValidFrom: mustParse(validFrom).In(location),
|
||||
ValidTo: mustParse(validTo).In(location),
|
||||
}
|
||||
}
|
||||
|
||||
func outlookIDs(outlooks []weatherdata.ConvectiveOutlook) []string {
|
||||
out := make([]string, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
out = append(out, outlook.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func discussionHeadlines(discussions []weatherdata.ConvectiveOutlookDiscussion) []string {
|
||||
out := make([]string, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
out = append(out, discussion.Headline)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||
temperature := 70.0
|
||||
return weatherdata.ForecastPeriod{
|
||||
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
AlertDigest ID = "alert_digest"
|
||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||
WeatherStory ID = "weather_story"
|
||||
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||
OutdoorWindows ID = "outdoor_windows"
|
||||
TomorrowPlanning ID = "tomorrow_planning"
|
||||
)
|
||||
@@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
||||
type FactRequirement string
|
||||
|
||||
const (
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||
CollectedAlerts FactRequirement = "collected.alerts"
|
||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||
)
|
||||
|
||||
type MissingDataBehavior string
|
||||
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
|
||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||
}
|
||||
type WeatherStoryOptions struct{}
|
||||
type SPCConvectiveOutlooksOptions struct{}
|
||||
type SPCConvectiveDiscussionOptions struct{}
|
||||
type OutdoorWindowsOptions struct{}
|
||||
type TomorrowPlanningOptions struct{}
|
||||
|
||||
@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
|
||||
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
|
||||
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
|
||||
}
|
||||
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
|
||||
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
|
||||
}
|
||||
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
|
||||
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
|
||||
}
|
||||
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
|
||||
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
|
||||
}
|
||||
|
||||
_ = SPCConvectiveOutlooksOptions{}
|
||||
_ = SPCConvectiveDiscussionOptions{}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ var briefingCategoryOrder = []string{
|
||||
|
||||
var briefingStanzaCategories = map[string]string{
|
||||
string(module.AlertDigest): categoryApplicableRiskProducts,
|
||||
string(module.SPCConvectiveOutlooks): categoryApplicableRiskProducts,
|
||||
string(module.DerivedDailySummary): categoryDerivedSummaries,
|
||||
string(module.DerivedDaypartSummaries): categoryDerivedSummaries,
|
||||
string(module.PrecipTiming): categoryDerivedSummaries,
|
||||
@@ -42,6 +43,7 @@ var briefingStanzaCategories = map[string]string{
|
||||
string(module.TomorrowPlanning): categoryDerivedSummaries,
|
||||
string(module.NarrativeForecast): categoryNarrativeProducts,
|
||||
string(module.AreaForecastDiscussion): categoryNarrativeProducts,
|
||||
string(module.SPCConvectiveDiscussion): categoryNarrativeProducts,
|
||||
string(module.WeatherStory): categoryNarrativeProducts,
|
||||
string(module.CurrentConditions): categoryRawData,
|
||||
string(module.HourlyForecast): categoryRawData,
|
||||
|
||||
@@ -164,6 +164,50 @@ func TestMarshalYAMLIsDeterministicAndGroupsNamedStanzas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalYAMLPlacesSPCConvectiveStanzasInPromptCategories(t *testing.T) {
|
||||
req := validBuildRequest(t)
|
||||
req.Modules = snapshotWithOutputs(t,
|
||||
module.Output{ID: module.Metadata, StanzaName: "metadata", Value: map[string]string{"run_id": req.Metadata.RunID}},
|
||||
module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: map[string]any{"discussions": []string{"day1"}}},
|
||||
module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: map[string]any{"outlook_count": 1}},
|
||||
)
|
||||
|
||||
pkg, err := Build(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
data, err := MarshalYAML(pkg)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalYAML() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
|
||||
riskIndex := strings.Index(text, " applicable_risk_products:\n")
|
||||
outlookIndex := strings.Index(text, " spc_convective_outlooks:\n")
|
||||
narrativeIndex := strings.Index(text, " narrative_products:\n")
|
||||
discussionIndex := strings.Index(text, " spc_convective_discussion:\n")
|
||||
if riskIndex < 0 || outlookIndex < 0 || narrativeIndex < 0 || discussionIndex < 0 {
|
||||
t.Fatalf("YAML output missing SPC convective category placement:\n%s", text)
|
||||
}
|
||||
if !(riskIndex < outlookIndex && outlookIndex < narrativeIndex && narrativeIndex < discussionIndex) {
|
||||
t.Fatalf("YAML output placed SPC convective stanzas in wrong order:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, " raw_data:\n spc_convective") || strings.Contains(text, " derived_summaries:\n spc_convective") {
|
||||
t.Fatalf("YAML output placed SPC convective stanzas in wrong category:\n%s", text)
|
||||
}
|
||||
|
||||
loaded, err := LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveOutlooks)]; !ok {
|
||||
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
||||
}
|
||||
if _, ok := loaded.Briefing.Values[string(module.SPCConvectiveDiscussion)]; !ok {
|
||||
t.Fatal("loaded package missing spc_convective_discussion stanza")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadYAMLRoundTrip(t *testing.T) {
|
||||
pkg, err := Build(validBuildRequest(t))
|
||||
if err != nil {
|
||||
|
||||
@@ -46,7 +46,9 @@ func dailyTodayModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
@@ -62,7 +64,9 @@ func dailyTomorrowModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
|
||||
@@ -267,7 +267,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.HourlyForecast,
|
||||
@@ -283,7 +285,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
@@ -298,7 +302,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -311,7 +317,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
},
|
||||
@@ -323,7 +331,9 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.CurrentConditions,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,9 @@ func stormModules() []module.ConfigItem {
|
||||
module.CurrentConditions,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ func threeDayModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,9 @@ func weekendModules() []module.ConfigItem {
|
||||
module.DerivedDaypartSummaries,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
module.OutdoorWindows,
|
||||
)
|
||||
|
||||
@@ -7,17 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type Bundle struct {
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Observation *Observation `json:"observation,omitempty"`
|
||||
Current *Current `json:"current,omitempty"`
|
||||
Hourly *ForecastRun `json:"hourly,omitempty"`
|
||||
Narrative *ForecastRun `json:"narrative,omitempty"`
|
||||
Alerts *AlertRun `json:"alerts,omitempty"`
|
||||
Discussion *Discussion `json:"discussion,omitempty"`
|
||||
Daily *ForecastRun `json:"daily,omitempty"`
|
||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||
Sources []Source `json:"sources"`
|
||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Observation *Observation `json:"observation,omitempty"`
|
||||
Current *Current `json:"current,omitempty"`
|
||||
Hourly *ForecastRun `json:"hourly,omitempty"`
|
||||
Narrative *ForecastRun `json:"narrative,omitempty"`
|
||||
Alerts *AlertRun `json:"alerts,omitempty"`
|
||||
Discussion *Discussion `json:"discussion,omitempty"`
|
||||
Daily *ForecastRun `json:"daily,omitempty"`
|
||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
|
||||
Sources []Source `json:"sources"`
|
||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
@@ -166,3 +167,42 @@ type WeatherStory struct {
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlookRun struct {
|
||||
LocationID string `json:"locationId,omitempty"`
|
||||
LocationName string `json:"locationName,omitempty"`
|
||||
AsOf *time.Time `json:"asOf,omitempty"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
|
||||
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlook struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlookType,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"labelText,omitempty"`
|
||||
Forecaster string `json:"forecaster,omitempty"`
|
||||
SeverityRank *int `json:"severityRank,omitempty"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo time.Time `json:"validTo"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
ContainsLocation bool `json:"containsLocation"`
|
||||
Geometry json.RawMessage `json:"geometry,omitempty"`
|
||||
}
|
||||
|
||||
type ConvectiveOutlookDiscussion struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
78
internal/weatherdata/bundle_test.go
Normal file
78
internal/weatherdata/bundle_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package weatherdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) {
|
||||
severity := 3
|
||||
validFrom := mustParseBundleTime("2026-06-12T13:00:00Z")
|
||||
validTo := mustParseBundleTime("2026-06-13T12:00:00Z")
|
||||
issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z")
|
||||
updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z")
|
||||
run := ConvectiveOutlookRun{
|
||||
LocationID: "test-grid",
|
||||
LocationName: "Brentwood",
|
||||
AsOf: &updatedAt,
|
||||
IssuedAt: &issuedAt,
|
||||
Product: "convective_outlook",
|
||||
Outlooks: []ConvectiveOutlook{{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &severity,
|
||||
ValidFrom: validFrom,
|
||||
ValidTo: validTo,
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &validTo,
|
||||
SourceURL: "https://example.test/source",
|
||||
ImageURL: "https://example.test/image.png",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`),
|
||||
}},
|
||||
Discussions: []ConvectiveOutlookDiscussion{{
|
||||
Day: 1,
|
||||
Headline: "Severe storms possible",
|
||||
Summary: "Scattered severe storms are possible.",
|
||||
Discussion: "Discussion text.",
|
||||
UpdatedAt: &updatedAt,
|
||||
}},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(run)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var decoded ConvectiveOutlookRun
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" {
|
||||
t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks)
|
||||
}
|
||||
if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` {
|
||||
t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got)
|
||||
}
|
||||
if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity {
|
||||
t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity)
|
||||
}
|
||||
if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" {
|
||||
t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseBundleTime(value string) time.Time {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
Reference in New Issue
Block a user