218 lines
7.1 KiB
Go
218 lines
7.1 KiB
Go
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
|
|
}
|