248 lines
9.9 KiB
Go
248 lines
9.9 KiB
Go
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))
|
|
}
|
|
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
|
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want valid-period rain start", derived.PrecipTiming.FirstPrecipitation)
|
|
}
|
|
if !derived.PrecipTiming.ThunderMentioned {
|
|
t.Fatal("PrecipTiming.ThunderMentioned = false, want true")
|
|
}
|
|
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
|
|
}
|