329 lines
12 KiB
Go
329 lines
12 KiB
Go
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"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|
bundle := loadBundleFixture(t)
|
|
currentIsDay := true
|
|
currentTemp := 75.9
|
|
currentFeelsLike := 76.1
|
|
currentHumidity := 56.0
|
|
currentWind := 10.7
|
|
bundle.Current = &weatherdata.Current{
|
|
ConditionText: "Partly cloudy",
|
|
IsDay: ¤tIsDay,
|
|
TemperatureF: ¤tTemp,
|
|
ApparentTemperatureF: ¤tFeelsLike,
|
|
RelativeHumidityPercent: ¤tHumidity,
|
|
WindSpeedMph: ¤tWind,
|
|
}
|
|
bundle.Sources[0].DataSHA256 = "abc123"
|
|
bundle.Warnings = []weatherdata.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 := &weatherdata.Bundle{
|
|
Hourly: &weatherdata.ForecastRun{Periods: []weatherdata.ForecastPeriod{
|
|
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
|
}},
|
|
Alerts: &weatherdata.AlertRun{},
|
|
Sources: []weatherdata.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: []weatherdata.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 = &weatherdata.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) *weatherdata.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 weatherdata.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) weatherdata.ForecastPeriod {
|
|
return weatherdata.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
|
|
}
|