package briefing import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestDailyBriefingFromRepresentativeFixture(t *testing.T) { bundle := loadBundleFixture(t) currentIsDay := true currentTemp := 75.9 currentFeelsLike := 76.1 currentHumidity := 56.0 currentWind := 10.7 bundle.Current = &forecast.Current{ ConditionText: "Partly cloudy", IsDay: ¤tIsDay, TemperatureF: ¤tTemp, ApparentTemperatureF: ¤tFeelsLike, RelativeHumidityPercent: ¤tHumidity, WindSpeedMph: ¤tWind, } bundle.Sources[0].DataSHA256 = "abc123" bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}} location := mustLocation(t) resolved := mustResolveDaily(t, location) summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { t.Fatalf("BuildDailySummary() error = %v", err) } pkg, err := BuildDaily(BuildContext{ Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago", Location: &LocationContext{ ID: "home", Name: "Brentwood", Region: "St. Louis Metro", Timezone: "America/Chicago", }, }, summary) if err != nil { t.Fatalf("BuildDaily() error = %v", err) } if pkg.Metadata.SchemaVersion != SchemaVersion { t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion) } if !strings.Contains(pkg.Metadata.RunID, "daily_today") { t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID) } if pkg.Metadata.ReportID != report.DailyToday { t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID) } if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" { t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone) } if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" { t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location) } if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity { t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions) } if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" { t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources) } if len(pkg.Metadata.SourceWarnings) != 1 { t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings)) } if pkg.Daily == nil { t.Fatal("Daily = nil") } if len(pkg.Daily.Dayparts) != 5 { t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts)) } if len(pkg.Daily.RelevantAlerts) != 1 { t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts)) } if len(pkg.Daily.NarrativePeriods) != 1 { t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods)) } if len(pkg.Daily.Discussion.KeyMessages) != 1 { t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages)) } if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." { t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm) } if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." { t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm) } if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil { t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows) } if pkg.Daily.BottomLine.Summary == "" { t.Fatal("BottomLine summary is empty") } if _, err := json.Marshal(pkg); err != nil { t.Fatalf("briefing package is not JSON inspectable: %v", err) } } func TestDailyBriefingQuietWeather(t *testing.T) { location := mustLocation(t) resolved := mustResolveDaily(t, location) bundle := &forecast.Bundle{ Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{ quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72), }}, Alerts: &forecast.AlertRun{}, Sources: []forecast.Source{ {Name: "hourly", FetchedAt: time.Now()}, {Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()}, {Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true}, }, Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}}, } summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { t.Fatalf("BuildDailySummary() error = %v", err) } pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) if err != nil { t.Fatalf("BuildDaily() error = %v", err) } if pkg.Daily.BottomLine.Summary != "Conditions: Clear." { t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary) } if pkg.CurrentConditions != nil { t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions) } if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" { t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings) } if len(pkg.Daily.RelevantAlerts) != 0 { t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) } if pkg.Metadata.Alerts == nil { t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status") } if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing { t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts) } data, err := json.Marshal(pkg.Metadata.Alerts) if err != nil { t.Fatalf("marshal alert metadata: %v", err) } if strings.Contains(string(data), `"missing"`) { t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data)) } } func TestDailyBriefingAlertExclusion(t *testing.T) { location := mustLocation(t) resolved := mustResolveDaily(t, location) bundle := loadBundleFixture(t) bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{ json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`), }} summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts()) if err != nil { t.Fatalf("BuildDailySummary() error = %v", err) } pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary) if err != nil { t.Fatalf("BuildDaily() error = %v", err) } if len(pkg.Daily.RelevantAlerts) != 0 { t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) } } func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) { location := mustLocation(t) resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{ Now: mustParse("2026-05-29T18:00:00-05:00"), Location: location, }) if err != nil { t.Fatalf("resolve tomorrow: %v", err) } precip := 70.0 wind := 34.0 summary := &forecast.DailySummary{ Date: "2026-05-30", Period: resolved.ValidPeriod, Dayparts: []forecast.DaypartSummary{ { Name: "overnight", Period: timeutil.Period{ Start: mustParse("2026-05-30T00:00:00-05:00"), End: mustParse("2026-05-30T06:00:00-05:00"), }, MaxPrecipitationProbability: &forecast.TimedValue{ Value: 40, Time: mustParse("2026-05-30T03:00:00-05:00"), }, }, { Name: "morning", Period: timeutil.Period{ Start: mustParse("2026-05-30T06:00:00-05:00"), End: mustParse("2026-05-30T12:00:00-05:00"), }, MaxPrecipitationProbability: &forecast.TimedValue{ Value: precip, Time: mustParse("2026-05-30T08:00:00-05:00"), }, PeakWindGust: &forecast.TimedValue{ Value: wind, Time: mustParse("2026-05-30T09:00:00-05:00"), }, Indicators: forecast.Indicators{Snow: true}, }, }, } pkg, err := BuildDaily(BuildContext{ Resolved: resolved, Units: "us", Timezone: "America/Chicago", }, summary) if err != nil { t.Fatalf("BuildDaily() error = %v", err) } if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" { t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant) } if pkg.Daily.ForecastSummaryDate != "2026-05-30" { t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate) } if pkg.Daily.Planning == nil { t.Fatal("Planning = nil, want tomorrow planning inputs") } if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 { t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning) } if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") { t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness) } } func TestSaveBriefingPackage(t *testing.T) { pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}} path := filepath.Join(t.TempDir(), "nested", "briefing.json") if err := Save(path, pkg); err != nil { t.Fatalf("Save() error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("read briefing: %v", err) } if !strings.Contains(string(data), SchemaVersion) { t.Fatalf("saved briefing missing schema version:\n%s", string(data)) } } func loadBundleFixture(t *testing.T) *forecast.Bundle { t.Helper() data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) if err != nil { t.Fatalf("read bundle fixture: %v", err) } var bundle forecast.Bundle if err := json.Unmarshal(data, &bundle); err != nil { t.Fatalf("decode bundle fixture: %v", err) } return &bundle } func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved { t.Helper() resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{ Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location, }) if err != nil { t.Fatalf("resolve daily: %v", err) } return resolved } func defaultDayparts() []forecast.DaypartDefinition { return []forecast.DaypartDefinition{ {Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "morning", Start: "06:00", End: "10:00"}, {Name: "midday", Start: "10:00", End: "15:00"}, {Name: "afternoon", Start: "15:00", End: "17:00"}, {Name: "evening", Start: "17:00", End: "24:00"}, } } func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod { return forecast.ForecastPeriod{ StartTime: mustParse(start), EndTime: mustParse(end), TextDescription: "Clear", TemperatureF: &temperature, } } func mustLocation(t *testing.T) *time.Location { t.Helper() location, err := time.LoadLocation("America/Chicago") if err != nil { t.Fatalf("load location: %v", err) } return location } func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil { panic(err) } return parsed }