From e9508089abf4fe42658b71e4aa4c3d2aa28613df Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:23:53 +0000 Subject: [PATCH] Add collected and derived fact contracts --- docs/internal/app-orchestration.md | 17 +- docs/internal/briefing.md | 9 +- docs/internal/facts.md | 72 ++++++++ docs/internal/forecast-derivation.md | 4 +- docs/policy/development.md | 1 + internal/app/app.go | 62 +++---- internal/briefing/storm.go | 17 +- internal/briefing/storm_test.go | 22 ++- internal/facts/facts.go | 139 +++++++++++++++ internal/facts/facts_test.go | 241 +++++++++++++++++++++++++++ 10 files changed, 519 insertions(+), 65 deletions(-) create mode 100644 docs/internal/facts.md create mode 100644 internal/facts/facts.go create mode 100644 internal/facts/facts_test.go diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index c5e9cab..4448b4a 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -6,10 +6,10 @@ This document describes the implemented workflow coordinator in `internal/app`. `internal/app` coordinates the top-level use cases after CLI parsing and config loading are complete. It resolves report definitions, fetches weather data, -builds briefing and prompt-input artifacts, invokes Scriptorium through the -adapter boundary, optionally notifies distributor through an app-owned notifier -boundary, persists managed state, runs batches, and reads existing artifacts for -inspection. +builds collected and derived facts, builds briefing and prompt-input artifacts, +invokes Scriptorium through the adapter boundary, optionally notifies +distributor through an app-owned notifier boundary, persists managed state, runs +batches, and reads existing artifacts for inspection. ## Inputs And Outputs @@ -21,7 +21,7 @@ Inputs: - `BriefingRequest` and `ReportRequest` for package-level orchestration tests and internal composition - resolved report definitions from `internal/report` -- forecast bundles from `internal/adapters/weatherapi` +- weather data bundles from `internal/adapters/weatherapi` - prior snapshots loaded from `internal/state` - optional renderer, notifier, and state-store fakes for tests @@ -39,11 +39,12 @@ Outputs: ## Boundaries `internal/app` owns workflow order and request composition. It does not parse -CLI flags, load YAML files directly, implement HTTP transport, derive forecast -facts, define report periods, compare rendered Markdown, or construct -Scriptorium argv. +CLI flags, load YAML files directly, implement HTTP transport, own fact +derivation algorithms, define report periods, compare rendered Markdown, or +construct Scriptorium argv. Report selection and report identity policy come from `internal/report`. +Collected and derived fact contracts come from `internal/facts`. Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium subprocess behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior stays in `internal/adapters/distributor`. Filesystem layout and diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index d5dbf61..6681e47 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -5,9 +5,8 @@ This document describes the implemented briefing package boundary. ## Purpose `internal/briefing` builds structured report-specific briefing packages from -resolved report metadata, forecast bundles, and derived forecast summaries. -Briefings are curated inputs for prompt data packages, not rendered report -prose. +resolved report metadata, collected weather data, and derived forecast facts. +Briefings are curated inputs for prompt data packages, not rendered report prose. ## Inputs And Outputs @@ -15,7 +14,7 @@ Inputs: - resolved report definition, generation time, timezone, and valid period - `weatherdata.Bundle` with source provenance and warnings -- derived daily or period summaries where required +- derived daily, period, or storm-window facts where required - configured units, timezone, and descriptive location context Outputs: @@ -37,7 +36,7 @@ Outputs: ## Config Fields Used The package receives configured units and timezone from the app layer. Daypart -configuration is consumed by `internal/forecast` before briefing builders run. +configuration is consumed by `internal/facts` before briefing builders run. Configured `location` values are prompt context only; Weather API `sourceLocationId` and `sourceLocation` remain source provenance. Current conditions are copied from the normalized `/conditions/current` bundle diff --git a/docs/internal/facts.md b/docs/internal/facts.md new file mode 100644 index 0000000..877ff5f --- /dev/null +++ b/docs/internal/facts.md @@ -0,0 +1,72 @@ +# Fact Contracts Internals + +This document describes the implemented fact contract boundary. + +## Purpose + +`internal/facts` separates normalized upstream facts collected for a report run +from conservative report-scoped facts derived from them. The package gives app +orchestration one place to build reusable facts before briefing construction. + +## Inputs And Outputs + +Inputs: + +- `weatherdata.Bundle` from the Weather API adapter +- resolved report definition and valid period +- report timezone +- configured daypart definitions + +Outputs: + +- `facts.CollectedFacts` with normalized source facts plus separate source + provenance and warnings +- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps, + daily summaries, daypart summaries, and Storm Report window summary + +## Boundaries + +- This package owns fact assembly and reusable deterministic derivation for a + report run. +- It does not fetch upstream data, build prompt wording, compare prior + snapshots, write workflow state, invoke Scriptorium, or define modules. + +## Config Fields Used + +- `dayparts[].name` +- `dayparts[].start` +- `dayparts[].end` +- `weather_api.timezone` + +## External Adapters Used + +None directly. Collected facts are built from `weatherdata.Bundle`. + +## State Or Manifest Behavior + +None. Source provenance and warnings remain data fields for downstream metadata +and inspection. + +## Failure Behavior + +- Invalid or missing report valid periods return an error. +- Invalid timezone names return an error. +- Missing required hourly forecast data returns the underlying forecast + derivation error for reports that require daily summaries. +- Missing optional narrative, alert, discussion, daily, or weather story data + produces empty or nil derived fields. + +## Tests + +Inspect: + +- `internal/facts/facts_test.go` +- `internal/app/app_test.go` + +## Invariants + +- Collected facts are built once from a fetched bundle. +- Derived facts are scoped to one resolved report. +- Source provenance and warnings stay separate from ordinary fact fields. +- Prompt-specific wording and one-off presentation decisions stay outside this + package. diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md index cf32190..eafa8e5 100644 --- a/docs/internal/forecast-derivation.md +++ b/docs/internal/forecast-derivation.md @@ -5,8 +5,8 @@ This document describes deterministic forecast summarization in ## Purpose -`internal/forecast` converts normalized `weatherdata` bundle data into daily -and period summaries used by briefing builders. +`internal/forecast` converts normalized weather data into daily and period +summaries used by fact builders and briefing builders. ## Inputs And Outputs diff --git a/docs/policy/development.md b/docs/policy/development.md index 07156ed..c875eb1 100644 --- a/docs/policy/development.md +++ b/docs/policy/development.md @@ -19,6 +19,7 @@ Developers and LLM coding agents should use it with - `internal/weatherdata`: normalized weather source facts, source metadata, and source warnings. - `internal/forecast`: deterministic forecast derivation. +- `internal/facts`: collected and derived report fact contracts. - `internal/report`: report definitions, valid periods, batches, output names, and comparison declarations. - `internal/briefing`: report-specific briefing package builders. diff --git a/internal/app/app.go b/internal/app/app.go index 3f1a79a..4ef45d7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go index 971ff7f..1aea99c 100644 --- a/internal/briefing/storm.go +++ b/internal/briefing/storm.go @@ -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, diff --git a/internal/briefing/storm_test.go b/internal/briefing/storm_test.go index 0d86fd5..a6a0d74 100644 --- a/internal/briefing/storm_test.go +++ b/internal/briefing/storm_test.go @@ -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 +} diff --git a/internal/facts/facts.go b/internal/facts/facts.go new file mode 100644 index 0000000..6c42751 --- /dev/null +++ b/internal/facts/facts.go @@ -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 +} diff --git a/internal/facts/facts_test.go b/internal/facts/facts_test.go new file mode 100644 index 0000000..bc489f5 --- /dev/null +++ b/internal/facts/facts_test.go @@ -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 +}