348 lines
12 KiB
Go
348 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
func TestPlanBatchRunMorningOrder(t *testing.T) {
|
|
location := mustLoadTestLocation(t, "America/Chicago")
|
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
|
|
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
|
|
if err != nil {
|
|
t.Fatalf("planBatchRun() error = %v", err)
|
|
}
|
|
|
|
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
|
|
}
|
|
|
|
func TestPlanBatchRunEveningOrder(t *testing.T) {
|
|
location := mustLoadTestLocation(t, "America/Chicago")
|
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
|
|
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
|
if err != nil {
|
|
t.Fatalf("planBatchRun() error = %v", err)
|
|
}
|
|
|
|
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
|
|
}
|
|
|
|
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
|
location := mustLoadTestLocation(t, "America/Chicago")
|
|
periods := fullDayPeriods(t, "2026-05-30", location)
|
|
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
|
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
|
|
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
|
|
if err != nil {
|
|
t.Fatalf("planBatchRun() error = %v", err)
|
|
}
|
|
|
|
daily := plannedDailyReports(planned)
|
|
if len(daily) != 2 {
|
|
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
|
|
}
|
|
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
|
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
|
}
|
|
|
|
func TestPlanBatchRunUsesResolvedOutputNames(t *testing.T) {
|
|
location := mustLoadTestLocation(t, "America/Chicago")
|
|
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
|
|
|
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
|
if err != nil {
|
|
t.Fatalf("planBatchRun() error = %v", err)
|
|
}
|
|
|
|
daily := plannedDailyReports(planned)
|
|
if len(daily) != 1 {
|
|
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
|
}
|
|
outputName, err := daily[0].Resolved.OutputName()
|
|
if err != nil {
|
|
t.Fatalf("OutputName() error = %v", err)
|
|
}
|
|
if outputName != "daily-2026-05-31.md" {
|
|
t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
|
|
}
|
|
outputName, err = planned[0].Resolved.OutputName()
|
|
if err != nil {
|
|
t.Fatalf("OutputName() error = %v", err)
|
|
}
|
|
if outputName != "tomorrow.md" {
|
|
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
|
|
}
|
|
}
|
|
|
|
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
|
|
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
|
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
|
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
|
|
}
|
|
}
|
|
|
|
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 collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
|
|
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
|
|
}
|
|
|
|
func planningConfig() config.Config {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
return cfg
|
|
}
|
|
|
|
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
|
|
t.Helper()
|
|
gotIDs := make([]string, 0, len(got))
|
|
for _, item := range got {
|
|
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
|
|
}
|
|
wantIDs := make([]string, 0, len(want))
|
|
for _, id := range want {
|
|
wantIDs = append(wantIDs, string(id))
|
|
}
|
|
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
|
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
|
|
}
|
|
}
|
|
|
|
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
|
|
var daily []plannedBatchReport
|
|
for _, item := range planned {
|
|
if item.Resolved.Definition.ID == report.Daily {
|
|
daily = append(daily, item)
|
|
}
|
|
}
|
|
return daily
|
|
}
|
|
|
|
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 assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
|
t.Helper()
|
|
if !period.IsValid() {
|
|
t.Fatalf("period = %#v, want valid", period)
|
|
}
|
|
if got := period.Start.Format(time.RFC3339); got != wantStart {
|
|
t.Fatalf("Start = %s, want %s", got, wantStart)
|
|
}
|
|
if got := period.End.Format(time.RFC3339); got != wantEnd {
|
|
t.Fatalf("End = %s, want %s", got, wantEnd)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|