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 }