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) 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", }, 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 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) != 4 { t.Fatalf("Dayparts length = %d, want 4", 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.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), }}, Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}}, } 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 len(pkg.Daily.RelevantAlerts) != 0 { t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts)) } } 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{Thunder: 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: "12:00"}, {Name: "afternoon", Start: "12:00", End: "18:00"}, {Name: "evening", Start: "18: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 }