package app import ( "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-05-31") } func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-05-31") } func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") periods := fullDayPeriods(t, "2026-05-31", location) periods = append(periods[:12], periods[13:]...) hourly := hourlyRun(periods...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location) } func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") periods := fullDayPeriods(t, "2026-05-31", location) periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...) hourly := hourlyRun(periods...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-05-31") } func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") periods := fullDayPeriods(t, "2026-05-29", location) periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...) periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...) hourly := hourlyRun(periods...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-05-31") } func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") periods := fullDayPeriods(t, "2026-05-31", location) periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...) hourly := hourlyRun(periods...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-05-31", "2026-06-01") } func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location) periods := []weatherdata.ForecastPeriod{ {StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)}, {StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)}, {StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)}, } periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...) hourly := hourlyRun(periods...) got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location, "2026-06-01") } func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) { location := mustLoadTestLocation(t, "America/New_York") tests := []struct { name string now string date string }{ { name: "spring forward", now: "2026-03-06T08:00:00-05:00", date: "2026-03-08", }, { name: "fall back", now: "2026-10-30T08:00:00-04:00", date: "2026-11-01", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...) got := eligibleDailyDates(hourly, mustParse(tt.now), location) assertLocalDates(t, got, location, tt.date) }) } } func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) { location := mustLoadTestLocation(t, "America/Chicago") fullDay := fullDayPeriods(t, "2026-05-31", location) tests := []struct { name string hourly *weatherdata.ForecastRun }{ {name: "nil run"}, {name: "empty periods", hourly: hourlyRun()}, {name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location) assertLocalDates(t, got, location) }) } } func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun { return forecastRun("hourly", periods...) } func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun { return &weatherdata.ForecastRun{ Product: product, Periods: periods, } } func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod { t.Helper() day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location) var periods []weatherdata.ForecastPeriod for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) { periods = append(periods, weatherdata.ForecastPeriod{ StartTime: start, EndTime: start.Add(time.Hour), }) } return periods } func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod { t.Helper() periods := fullDayPeriods(t, date, location) if count > len(periods) { count = len(periods) } return periods[:count] } func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod { t.Helper() out := make([]weatherdata.ForecastPeriod, 0, len(periods)) for _, period := range periods { start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339)) if err != nil { t.Fatalf("parse fixed-offset start: %v", err) } end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339)) if err != nil { t.Fatalf("parse fixed-offset end: %v", err) } out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end}) } return out } func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) { t.Helper() gotDates := make([]string, 0, len(got)) for _, date := range got { gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout)) } if strings.Join(gotDates, ",") != strings.Join(want, ",") { t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ",")) } for _, date := range got { day := timeutil.CivilDay(date, location) if !date.Equal(day.Start) { t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start) } } } func mustLoadTestLocation(t *testing.T, name string) *time.Location { t.Helper() location, err := time.LoadLocation(name) if err != nil { t.Fatalf("LoadLocation(%q) error = %v", name, err) } return location } func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time { t.Helper() parsed, err := timeutil.ParseLocalDate(value, location) if err != nil { t.Fatalf("ParseLocalDate(%q) error = %v", value, err) } return parsed }