Add SPC convective outlook data contracts
This commit is contained in:
@@ -20,7 +20,8 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `facts.CollectedFacts` with normalized source facts plus separate source
|
- `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.
|
||||||
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
|
||||||
daily summaries, daypart summaries, and Storm Report window summary
|
daily summaries, daypart summaries, and Storm Report window summary
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ and inspection.
|
|||||||
derivation error for reports that require daily summaries.
|
derivation error for reports that require daily summaries.
|
||||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||||
produces empty or nil derived fields.
|
produces empty or nil derived fields.
|
||||||
|
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ The registry recognizes these IDs:
|
|||||||
Every registered module has a builder. Report composition entries that refer to
|
Every registered module has a builder. Report composition entries that refer to
|
||||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||||
|
|
||||||
|
The package also defines `spc_convective_outlooks` and
|
||||||
|
`spc_convective_discussion` IDs and empty option structs for the collected
|
||||||
|
contract. They are not registered modules in the briefing registry.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
Most modules use an empty options struct. `area_forecast_discussion` accepts:
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Outputs:
|
|||||||
|
|
||||||
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
|
||||||
narrative forecast, active alerts, discussion, latest weather story, source
|
narrative forecast, active alerts, discussion, latest weather story, source
|
||||||
records, and source warnings
|
records, source warnings, and an optional typed SPC convective outlook field
|
||||||
|
when that source has been populated
|
||||||
- optional saved bundle JSON through app fetch helpers
|
- optional saved bundle JSON through app fetch helpers
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
|
|||||||
return ctx.Collected.Discussion != nil
|
return ctx.Collected.Discussion != nil
|
||||||
case module.CollectedWeatherStory:
|
case module.CollectedWeatherStory:
|
||||||
return ctx.Collected.WeatherStory != nil
|
return ctx.Collected.WeatherStory != nil
|
||||||
|
case module.CollectedSPCConvectiveOutlooks:
|
||||||
|
return ctx.Collected.SPCConvectiveOutlooks != nil
|
||||||
case module.CollectedSourceMetadata:
|
case module.CollectedSourceMetadata:
|
||||||
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
|
||||||
@@ -128,6 +130,18 @@ 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 noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
||||||
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CollectedFacts struct {
|
type CollectedFacts struct {
|
||||||
FetchedAt time.Time
|
FetchedAt time.Time
|
||||||
Observation *weatherdata.Observation
|
Observation *weatherdata.Observation
|
||||||
Current *weatherdata.Current
|
Current *weatherdata.Current
|
||||||
Hourly *weatherdata.ForecastRun
|
Hourly *weatherdata.ForecastRun
|
||||||
Narrative *weatherdata.ForecastRun
|
Narrative *weatherdata.ForecastRun
|
||||||
Alerts *weatherdata.AlertRun
|
Alerts *weatherdata.AlertRun
|
||||||
Discussion *weatherdata.Discussion
|
Discussion *weatherdata.Discussion
|
||||||
Daily *weatherdata.ForecastRun
|
Daily *weatherdata.ForecastRun
|
||||||
WeatherStory *weatherdata.WeatherStory
|
WeatherStory *weatherdata.WeatherStory
|
||||||
|
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
|
||||||
|
|
||||||
SourceProvenance []weatherdata.Source
|
SourceProvenance []weatherdata.Source
|
||||||
SourceWarnings []weatherdata.SourceWarning
|
SourceWarnings []weatherdata.SourceWarning
|
||||||
@@ -31,33 +32,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
|||||||
return CollectedFacts{}
|
return CollectedFacts{}
|
||||||
}
|
}
|
||||||
return CollectedFacts{
|
return CollectedFacts{
|
||||||
FetchedAt: bundle.FetchedAt,
|
FetchedAt: bundle.FetchedAt,
|
||||||
Observation: bundle.Observation,
|
Observation: bundle.Observation,
|
||||||
Current: bundle.Current,
|
Current: bundle.Current,
|
||||||
Hourly: bundle.Hourly,
|
Hourly: bundle.Hourly,
|
||||||
Narrative: bundle.Narrative,
|
Narrative: bundle.Narrative,
|
||||||
Alerts: bundle.Alerts,
|
Alerts: bundle.Alerts,
|
||||||
Discussion: bundle.Discussion,
|
Discussion: bundle.Discussion,
|
||||||
Daily: bundle.Daily,
|
Daily: bundle.Daily,
|
||||||
WeatherStory: bundle.WeatherStory,
|
WeatherStory: bundle.WeatherStory,
|
||||||
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
|
||||||
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
|
||||||
|
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
|
||||||
return &weatherdata.Bundle{
|
return &weatherdata.Bundle{
|
||||||
FetchedAt: f.FetchedAt,
|
FetchedAt: f.FetchedAt,
|
||||||
Observation: f.Observation,
|
Observation: f.Observation,
|
||||||
Current: f.Current,
|
Current: f.Current,
|
||||||
Hourly: f.Hourly,
|
Hourly: f.Hourly,
|
||||||
Narrative: f.Narrative,
|
Narrative: f.Narrative,
|
||||||
Alerts: f.Alerts,
|
Alerts: f.Alerts,
|
||||||
Discussion: f.Discussion,
|
Discussion: f.Discussion,
|
||||||
Daily: f.Daily,
|
Daily: f.Daily,
|
||||||
WeatherStory: f.WeatherStory,
|
WeatherStory: f.WeatherStory,
|
||||||
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
|
||||||
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
|
||||||
|
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
FetchedAt: fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
|
||||||
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
|
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)
|
collected := BuildCollected(bundle)
|
||||||
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
|
||||||
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
|
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" {
|
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||||
}
|
}
|
||||||
@@ -36,6 +46,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
|||||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
|
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)
|
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) {
|
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
AlertDigest ID = "alert_digest"
|
AlertDigest ID = "alert_digest"
|
||||||
AreaForecastDiscussion ID = "area_forecast_discussion"
|
AreaForecastDiscussion ID = "area_forecast_discussion"
|
||||||
WeatherStory ID = "weather_story"
|
WeatherStory ID = "weather_story"
|
||||||
|
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
|
||||||
|
SPCConvectiveDiscussion ID = "spc_convective_discussion"
|
||||||
OutdoorWindows ID = "outdoor_windows"
|
OutdoorWindows ID = "outdoor_windows"
|
||||||
TomorrowPlanning ID = "tomorrow_planning"
|
TomorrowPlanning ID = "tomorrow_planning"
|
||||||
)
|
)
|
||||||
@@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
|
|||||||
type FactRequirement string
|
type FactRequirement string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
|
||||||
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
|
||||||
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
|
||||||
CollectedAlerts FactRequirement = "collected.alerts"
|
CollectedAlerts FactRequirement = "collected.alerts"
|
||||||
CollectedDiscussion FactRequirement = "collected.discussion"
|
CollectedDiscussion FactRequirement = "collected.discussion"
|
||||||
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
CollectedWeatherStory FactRequirement = "collected.weather_story"
|
||||||
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
|
||||||
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
|
||||||
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
|
||||||
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
|
||||||
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
|
||||||
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
|
||||||
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
|
||||||
|
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
|
||||||
|
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MissingDataBehavior string
|
type MissingDataBehavior string
|
||||||
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
|
|||||||
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
|
||||||
}
|
}
|
||||||
type WeatherStoryOptions struct{}
|
type WeatherStoryOptions struct{}
|
||||||
|
type SPCConvectiveOutlooksOptions struct{}
|
||||||
|
type SPCConvectiveDiscussionOptions struct{}
|
||||||
type OutdoorWindowsOptions struct{}
|
type OutdoorWindowsOptions struct{}
|
||||||
type TomorrowPlanningOptions struct{}
|
type TomorrowPlanningOptions struct{}
|
||||||
|
|||||||
@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
|
|||||||
t.Fatal("StanzaValue(missing) found = true, want false")
|
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{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Bundle struct {
|
type Bundle struct {
|
||||||
FetchedAt time.Time `json:"fetchedAt"`
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
Observation *Observation `json:"observation,omitempty"`
|
Observation *Observation `json:"observation,omitempty"`
|
||||||
Current *Current `json:"current,omitempty"`
|
Current *Current `json:"current,omitempty"`
|
||||||
Hourly *ForecastRun `json:"hourly,omitempty"`
|
Hourly *ForecastRun `json:"hourly,omitempty"`
|
||||||
Narrative *ForecastRun `json:"narrative,omitempty"`
|
Narrative *ForecastRun `json:"narrative,omitempty"`
|
||||||
Alerts *AlertRun `json:"alerts,omitempty"`
|
Alerts *AlertRun `json:"alerts,omitempty"`
|
||||||
Discussion *Discussion `json:"discussion,omitempty"`
|
Discussion *Discussion `json:"discussion,omitempty"`
|
||||||
Daily *ForecastRun `json:"daily,omitempty"`
|
Daily *ForecastRun `json:"daily,omitempty"`
|
||||||
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
|
||||||
Sources []Source `json:"sources"`
|
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
|
||||||
Warnings []SourceWarning `json:"warnings,omitempty"`
|
Sources []Source `json:"sources"`
|
||||||
|
Warnings []SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Source struct {
|
type Source struct {
|
||||||
@@ -166,3 +167,42 @@ type WeatherStory struct {
|
|||||||
Order int `json:"order"`
|
Order int `json:"order"`
|
||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
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