Add collected and derived fact contracts
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
@@ -789,10 +790,7 @@ func distributorUploadFiles(sourcePath string, bundlePaths []string) []distribut
|
||||
}
|
||||
|
||||
func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Package, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
collected := facts.BuildCollected(bundle)
|
||||
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
|
||||
for _, daypart := range req.Config.Dayparts {
|
||||
dayparts = append(dayparts, forecast.DaypartDefinition{
|
||||
@@ -801,48 +799,32 @@ func BuildBriefing(req BriefingRequest, bundle *weatherdata.Bundle) (briefing.Pa
|
||||
End: daypart.End,
|
||||
})
|
||||
}
|
||||
derived, err := facts.BuildDerived(facts.BuildDerivedRequest{
|
||||
Resolved: req.Resolved,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Dayparts: dayparts,
|
||||
Collected: collected,
|
||||
})
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
buildContext := briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: collected.Bundle(),
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
return briefing.BuildDaily(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summary)
|
||||
return briefing.BuildDaily(buildContext, derived.FirstDailySummary())
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
if req.Resolved.Definition.ID == report.Weekend {
|
||||
return briefing.BuildWeekend(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
return briefing.BuildWeekend(buildContext, derived.DailySummaries)
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
return briefing.BuildThreeDay(buildContext, derived.DailySummaries)
|
||||
case report.Storm:
|
||||
return briefing.BuildStorm(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
})
|
||||
return briefing.BuildStorm(buildContext, derived)
|
||||
default:
|
||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package briefing
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
@@ -26,7 +27,7 @@ type Storm struct {
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
func BuildStorm(ctx BuildContext, derived facts.DerivedFacts) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Storm {
|
||||
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
|
||||
}
|
||||
@@ -34,12 +35,14 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
return Package{}, fmt.Errorf("forecast bundle is required")
|
||||
}
|
||||
period := ctx.Resolved.ValidPeriod
|
||||
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
|
||||
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
|
||||
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
|
||||
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
|
||||
summary := forecast.SummarizeDaypart("storm window", period, hourly)
|
||||
summary.AlertOverlaps = alerts
|
||||
hourly := derived.ValidPeriodHourlyPeriods
|
||||
narrative := derived.ValidPeriodNarrativePeriods
|
||||
daily := derived.ValidPeriodDailyPeriods
|
||||
alerts := derived.AlertOverlaps
|
||||
if derived.StormWindowSummary == nil {
|
||||
return Package{}, fmt.Errorf("storm window summary is required")
|
||||
}
|
||||
summary := *derived.StormWindowSummary
|
||||
|
||||
storm := &Storm{
|
||||
TimingWindow: period,
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
@@ -62,7 +64,7 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
@@ -120,7 +122,7 @@ func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
|
||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
@@ -155,7 +157,7 @@ func TestStormBriefingQuietWindow(t *testing.T) {
|
||||
Sources: []weatherdata.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, stormDerivedFacts(t, resolved, bundle))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildStorm() error = %v", err)
|
||||
}
|
||||
@@ -167,3 +169,17 @@ func TestStormBriefingQuietWindow(t *testing.T) {
|
||||
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
|
||||
}
|
||||
}
|
||||
|
||||
func stormDerivedFacts(t *testing.T, resolved report.Resolved, bundle *weatherdata.Bundle) facts.DerivedFacts {
|
||||
t.Helper()
|
||||
derived, err := facts.BuildDerived(facts.BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: "America/Chicago",
|
||||
Dayparts: []forecast.DaypartDefinition{{Name: "morning", Start: "06:00", End: "12:00"}},
|
||||
Collected: facts.BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build derived facts: %v", err)
|
||||
}
|
||||
return derived
|
||||
}
|
||||
|
||||
139
internal/facts/facts.go
Normal file
139
internal/facts/facts.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package facts defines collected and derived report facts.
|
||||
package facts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
SourceProvenance []weatherdata.Source
|
||||
SourceWarnings []weatherdata.SourceWarning
|
||||
}
|
||||
|
||||
func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
|
||||
if bundle == nil {
|
||||
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...),
|
||||
}
|
||||
}
|
||||
|
||||
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...),
|
||||
}
|
||||
}
|
||||
|
||||
type BuildDerivedRequest struct {
|
||||
Resolved report.Resolved
|
||||
Timezone string
|
||||
Dayparts []forecast.DaypartDefinition
|
||||
Collected CollectedFacts
|
||||
}
|
||||
|
||||
type DerivedFacts struct {
|
||||
ValidPeriodHourlyPeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodNarrativePeriods []weatherdata.ForecastPeriod
|
||||
ValidPeriodDailyPeriods []weatherdata.ForecastPeriod
|
||||
AlertOverlaps []forecast.AlertOverlap
|
||||
DailySummaries []forecast.DailySummary
|
||||
DaypartSummaries []forecast.DaypartSummary
|
||||
StormWindowSummary *forecast.DaypartSummary
|
||||
}
|
||||
|
||||
func (f DerivedFacts) FirstDailySummary() *forecast.DailySummary {
|
||||
if len(f.DailySummaries) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &f.DailySummaries[0]
|
||||
}
|
||||
|
||||
func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
if !req.Resolved.ValidPeriod.IsValid() {
|
||||
return DerivedFacts{}, fmt.Errorf("resolved valid period is required")
|
||||
}
|
||||
location, err := timeutil.LoadLocation(req.Timezone)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, fmt.Errorf("load report timezone %q: %w", req.Timezone, err)
|
||||
}
|
||||
bundle := req.Collected.Bundle()
|
||||
period := req.Resolved.ValidPeriod
|
||||
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),
|
||||
}
|
||||
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
}
|
||||
derived.DailySummaries = []forecast.DailySummary{*summary}
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, period, location, req.Dayparts)
|
||||
if err != nil {
|
||||
return DerivedFacts{}, err
|
||||
}
|
||||
derived.DailySummaries = summaries
|
||||
case report.Storm:
|
||||
summary := forecast.SummarizeDaypart("storm window", period, derived.ValidPeriodHourlyPeriods)
|
||||
summary.AlertOverlaps = derived.AlertOverlaps
|
||||
derived.StormWindowSummary = &summary
|
||||
default:
|
||||
return DerivedFacts{}, fmt.Errorf("derived facts are not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
|
||||
derived.DaypartSummaries = collectDaypartSummaries(derived)
|
||||
return derived, nil
|
||||
}
|
||||
|
||||
func collectDaypartSummaries(derived DerivedFacts) []forecast.DaypartSummary {
|
||||
var out []forecast.DaypartSummary
|
||||
for _, summary := range derived.DailySummaries {
|
||||
out = append(out, summary.Dayparts...)
|
||||
}
|
||||
if derived.StormWindowSummary != nil {
|
||||
out = append(out, *derived.StormWindowSummary)
|
||||
}
|
||||
return out
|
||||
}
|
||||
241
internal/facts/facts_test.go
Normal file
241
internal/facts/facts_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package facts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
|
||||
fetchedAt := mustParse("2026-05-29T10:00:00Z")
|
||||
bundle := &weatherdata.Bundle{
|
||||
FetchedAt: fetchedAt,
|
||||
Current: &weatherdata.Current{ConditionText: "Clear"},
|
||||
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", 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 len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
|
||||
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
|
||||
}
|
||||
if len(collected.SourceWarnings) != 1 || collected.SourceWarnings[0].Source != "daily" {
|
||||
t.Fatalf("SourceWarnings = %#v, want daily warning", collected.SourceWarnings)
|
||||
}
|
||||
|
||||
bundle.Sources[0].Name = "changed"
|
||||
bundle.Warnings[0].Source = "changed"
|
||||
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "daily" {
|
||||
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4", len(derived.ValidPeriodHourlyPeriods))
|
||||
}
|
||||
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 1", len(derived.ValidPeriodNarrativePeriods))
|
||||
}
|
||||
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||
t.Fatalf("AlertOverlaps = %#v, want Flood Watch overlap", derived.AlertOverlaps)
|
||||
}
|
||||
if len(derived.DailySummaries) != 1 || len(derived.DailySummaries[0].Dayparts) != 3 {
|
||||
t.Fatalf("DailySummaries = %#v, want one summary with dayparts", derived.DailySummaries)
|
||||
}
|
||||
if len(derived.DaypartSummaries) != 3 {
|
||||
t.Fatalf("DaypartSummaries length = %d, want 3", len(derived.DaypartSummaries))
|
||||
}
|
||||
morning := derived.DailySummaries[0].Dayparts[0]
|
||||
if len(morning.HourlyPeriods) != 1 || morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 60 {
|
||||
t.Fatalf("morning summary = %#v, want sliced hour with precip max", morning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedOutlookBuildsPartialDaySummariesWithMissingOptionalSources(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.ThreeDay, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
bundle := testBundle(location)
|
||||
bundle.Narrative = nil
|
||||
bundle.Alerts = nil
|
||||
bundle.Discussion = nil
|
||||
bundle.WeatherStory = nil
|
||||
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.DailySummaries) != 3 {
|
||||
t.Fatalf("DailySummaries length = %d, want 3 partial-day summaries", len(derived.DailySummaries))
|
||||
}
|
||||
if len(derived.ValidPeriodNarrativePeriods) != 0 {
|
||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want 0 for missing optional source", len(derived.ValidPeriodNarrativePeriods))
|
||||
}
|
||||
if len(derived.AlertOverlaps) != 0 {
|
||||
t.Fatalf("AlertOverlaps = %#v, want none for missing optional alerts", derived.AlertOverlaps)
|
||||
}
|
||||
if derived.DailySummaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||
t.Fatalf("first summary start = %s, want valid-period start", derived.DailySummaries[0].Period.Start.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||
location := testLocation()
|
||||
for _, id := range []report.ID{report.DailyTomorrow, report.Weekend} {
|
||||
resolved := resolveForTest(t, id, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived(%s) error = %v", id, err)
|
||||
}
|
||||
if len(derived.DailySummaries) == 0 {
|
||||
t.Fatalf("BuildDerived(%s) DailySummaries length = 0, want summaries", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveStormForTest(t, location)
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.ValidPeriodHourlyPeriods) != 2 {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 2 storm-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||
}
|
||||
if len(derived.ValidPeriodDailyPeriods) != 1 {
|
||||
t.Fatalf("ValidPeriodDailyPeriods length = %d, want 1 daily period", len(derived.ValidPeriodDailyPeriods))
|
||||
}
|
||||
if derived.StormWindowSummary == nil {
|
||||
t.Fatal("StormWindowSummary = nil, want summary")
|
||||
}
|
||||
if derived.StormWindowSummary.MaxPrecipitationProbability == nil || derived.StormWindowSummary.MaxPrecipitationProbability.Value != 80 {
|
||||
t.Fatalf("StormWindowSummary = %#v, want peak precipitation", derived.StormWindowSummary)
|
||||
}
|
||||
if len(derived.StormWindowSummary.AlertOverlaps) != 1 {
|
||||
t.Fatalf("StormWindowSummary.AlertOverlaps length = %d, want 1", len(derived.StormWindowSummary.AlertOverlaps))
|
||||
}
|
||||
}
|
||||
|
||||
func testBundle(location *time.Location) *weatherdata.Bundle {
|
||||
return &weatherdata.Bundle{
|
||||
FetchedAt: mustParse("2026-05-29T10:00:00Z"),
|
||||
Current: &weatherdata.Current{ConditionText: "Cloudy"},
|
||||
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, 10),
|
||||
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "Thunderstorms", 80, 35),
|
||||
hour(location, "2026-05-29T13:00:00-05:00", "2026-05-29T14:00:00-05:00", "Heavy rain", 70, 25),
|
||||
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot", 10, 15),
|
||||
hour(location, "2026-05-30T09:00:00-05:00", "2026-05-30T10:00:00-05:00", "Clear", 0, 5),
|
||||
}},
|
||||
Narrative: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storm chances peak midday.", 70, 20),
|
||||
}},
|
||||
Daily: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
||||
hour(location, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00", "Storms", 70, 20),
|
||||
}},
|
||||
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||
}},
|
||||
Discussion: &weatherdata.Discussion{Product: "discussion", KeyMessages: []string{"Storm confidence is moderate."}},
|
||||
WeatherStory: &weatherdata.WeatherStory{Title: "Storm Risk"},
|
||||
Sources: []weatherdata.Source{{Name: "hourly"}},
|
||||
Warnings: []weatherdata.SourceWarning{{Source: "daily", Code: "missing_source"}},
|
||||
}
|
||||
}
|
||||
|
||||
func hour(location *time.Location, start string, end string, text string, precip float64, gust float64) weatherdata.ForecastPeriod {
|
||||
temperature := 70.0
|
||||
return weatherdata.ForecastPeriod{
|
||||
StartTime: mustParse(start).In(location),
|
||||
EndTime: mustParse(end).In(location),
|
||||
TextDescription: text,
|
||||
TemperatureF: &temperature,
|
||||
ProbabilityOfPrecipitationPercent: &precip,
|
||||
WindGustMph: &gust,
|
||||
}
|
||||
}
|
||||
|
||||
func testDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveForTest(t *testing.T, id report.ID, now time.Time, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(id, report.ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %s: %v", id, err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveStormForTest(t *testing.T, location *time.Location) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
StormStart: mustParse("2026-05-29T11:30:00-05:00"),
|
||||
StormEnd: mustParse("2026-05-29T13:30:00-05:00"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve storm: %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func testLocation() *time.Location {
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParse(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