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 }