diff --git a/internal/app/app.go b/internal/app/app.go index e53961b..9553d3a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -265,7 +265,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro if now.IsZero() { now = time.Now() } - resolvedReports, err := ResolveBatch(req, now) + if _, err := report.BatchForCommandName(string(req.Batch)); err != nil { + return nil, err + } + collection, err := collectWeather(ctx, req.Config, req.Collector) + if err != nil { + return nil, err + } + plannedReports, err := planBatchReports(req, now, *collection) if err != nil { return nil, err } @@ -280,12 +287,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro } startedAt := now result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} - for _, resolved := range resolvedReports { + for _, planned := range plannedReports { + resolved := planned.Resolved if !resolved.Definition.Generated { return nil, fmt.Errorf("run is not implemented") } } - for _, resolved := range resolvedReports { + for _, planned := range plannedReports { + resolved := planned.Resolved item := batchReportResult(resolved) if paths, err := store.Paths(resolved); err == nil { item.DataPackagePath = paths.DataPackage diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7029127..2e594e7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2237,7 +2237,7 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) renderer := &selectiveRenderer{ - failRenderPrompt: "weather.three_day_outlook", + failRenderPrompt: "weather.tomorrow_generated_text", runBody: "# Batch Report\n", } @@ -2251,23 +2251,23 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) { t.Fatalf("RunBatchDetailed() error = %v", err) } - if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) + if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 { + t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed) } - if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 { + if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 { t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls) } - var failedThreeDay bool + var failedTomorrow bool for _, item := range result.Reports { - if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") { - failedThreeDay = true + if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") { + failedTomorrow = true } - if item.ReportID != report.ThreeDay && item.Status != "succeeded" { + if item.ReportID != report.Tomorrow && item.Status != "succeeded" { t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) } } - if !failedThreeDay { - t.Fatalf("reports = %#v, want failed 3-day item", result.Reports) + if !failedTomorrow { + t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports) } } @@ -2276,7 +2276,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { cfg := dailyNotificationConfig(t, server) notifier := &recordingNotifier{ errByReport: map[report.ID]error{ - report.ThreeDay: errors.New("distributor unavailable"), + report.Tomorrow: errors.New("distributor unavailable"), }, } @@ -2291,26 +2291,26 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { t.Fatalf("RunBatchDetailed() error = %v", err) } - if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) + if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 { + t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed) } - if len(notifier.requests) != 3 { + if len(notifier.requests) != 2 { t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests)) } - var failedThreeDay bool + var failedTomorrow bool for _, item := range result.Reports { - if item.ReportID == report.ThreeDay { + if item.ReportID == report.Tomorrow { if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") { - failedThreeDay = true + failedTomorrow = true } if item.NotificationStatus != "failed" { - t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus) + t.Fatalf("Tomorrow notification status = %q, want failed", item.NotificationStatus) } - if item.NotificationPipelineID != "weatherreporter.three-day" { - t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID) + if item.NotificationPipelineID != "weatherreporter.tomorrow" { + t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID) } if !strings.Contains(item.NotificationError, "distributor unavailable") { - t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError) + t.Fatalf("Tomorrow notification error = %q, want distributor unavailable", item.NotificationError) } continue } @@ -2324,8 +2324,8 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { t.Fatalf("report %s notification pipeline is empty", item.ReportID) } } - if !failedThreeDay { - t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports) + if !failedTomorrow { + t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports) } cfg.Workspace.Root = t.TempDir() @@ -2338,7 +2338,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { }, Notifier: &recordingNotifier{ errByReport: map[report.ID]error{ - report.ThreeDay: errors.New("distributor unavailable"), + report.Tomorrow: errors.New("distributor unavailable"), }, }, }) @@ -2346,7 +2346,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { if !errors.As(err, &batchErr) { t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err) } - if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 { + if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 1 { t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result) } } @@ -2404,7 +2404,7 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) { if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } - if result.Failed != 0 || len(result.Reports) != 3 { + if result.Failed != 0 || len(result.Reports) != 2 { t.Fatalf("summary = %#v, want successful morning batch", result) } var todayItem *BatchReportResult @@ -2448,26 +2448,18 @@ func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) { collector := &recordingCollector{err: errors.New("batch collector failed")} renderer := &recordingRenderer{} - result, err := RunBatchDetailed(context.Background(), BatchRequest{ + _, err := RunBatchDetailed(context.Background(), BatchRequest{ Config: cfg, Batch: BatchMorning, Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: collector, Renderer: renderer, }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) + if err == nil || !strings.Contains(err.Error(), "batch collector failed") { + t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err) } - if result.Total == 0 || result.Failed != result.Total { - t.Fatalf("batch result = %#v, want every report failed by collector", result) - } - if len(collector.requests) != result.Total { - t.Fatalf("collector requests = %d, want %d", len(collector.requests), result.Total) - } - for _, reportResult := range result.Reports { - if !strings.Contains(reportResult.Error, "batch collector failed") { - t.Fatalf("report error = %q, want fake collector error", reportResult.Error) - } + if len(collector.requests) != 1 { + t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests)) } if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 { t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls) diff --git a/internal/app/batch_plan.go b/internal/app/batch_plan.go index 87e0e7a..09c785a 100644 --- a/internal/app/batch_plan.go +++ b/internal/app/batch_plan.go @@ -1,12 +1,85 @@ package app import ( + "fmt" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) +type plannedBatchReport struct { + Resolved report.Resolved + OutputCopyName string +} + +func planBatchReports(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) { + location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) + if err != nil { + return nil, err + } + batch, err := report.BatchForCommandName(string(req.Batch)) + if err != nil { + return nil, err + } + registry, err := reportRegistry(req.Config) + if err != nil { + return nil, err + } + + resolveReq := report.ResolveRequest{ + Now: now, + Location: location, + } + var planned []plannedBatchReport + switch batch { + case report.Morning: + planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "") + if err != nil { + return nil, err + } + planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") + if err != nil { + return nil, err + } + case report.Evening: + planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "") + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown batch %q", batch) + } + + var hourly *weatherdata.ForecastRun + if collection.Bundle != nil { + hourly = collection.Bundle.Hourly + } + for _, date := range eligibleDailyDates(hourly, now, location) { + dailyReq := resolveReq + dailyReq.Date = date + outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md" + planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName) + if err != nil { + return nil, err + } + } + return planned, nil +} + +func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) { + resolved, err := registry.Resolve(id, req) + if err != nil { + return nil, err + } + return append(planned, plannedBatchReport{ + Resolved: resolved, + OutputCopyName: outputCopyName, + }), nil +} + func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time { if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 { return nil diff --git a/internal/app/batch_plan_test.go b/internal/app/batch_plan_test.go index 3816393..2c25d16 100644 --- a/internal/app/batch_plan_test.go +++ b/internal/app/batch_plan_test.go @@ -5,10 +5,97 @@ import ( "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 TestPlanBatchReportsMorningOrder(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) + + planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly)) + if err != nil { + t.Fatalf("planBatchReports() error = %v", err) + } + + assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily) +} + +func TestPlanBatchReportsEveningOrder(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) + + planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly)) + if err != nil { + t.Fatalf("planBatchReports() error = %v", err) + } + + assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily) +} + +func TestPlanBatchReportsDynamicDailyDatesStartAfterTomorrow(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 := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...))) + if err != nil { + t.Fatalf("planBatchReports() 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 TestPlanBatchReportsMorningExcludesLegacyStaticReports(t *testing.T) { + planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}}) + if err != nil { + t.Fatalf("planBatchReports() error = %v", err) + } + + for _, item := range planned { + if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend { + t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID) + } + } +} + +func TestPlanBatchReportsDynamicDailyOutputCopyNames(t *testing.T) { + location := mustLoadTestLocation(t, "America/Chicago") + hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...) + + planned, err := planBatchReports(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly)) + if err != nil { + t.Fatalf("planBatchReports() error = %v", err) + } + + daily := plannedDailyReports(planned) + if len(daily) != 1 { + t.Fatalf("daily reports = %#v, want one Daily report", daily) + } + if daily[0].OutputCopyName != "daily-2026-05-31.md" { + t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName) + } + if planned[0].OutputCopyName != "" { + t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName) + } +} + +func TestPlanBatchReportsRejectsUnknownBatch(t *testing.T) { + _, err := planBatchReports(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("planBatchReports() 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)...) @@ -142,6 +229,41 @@ func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weather } } +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) @@ -198,6 +320,19 @@ func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, wa } } +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) diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 0af77ba..a2cf6e0 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -164,7 +164,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") } -func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { +func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) runner := Runner{Clock: fixedClock()} @@ -176,7 +176,9 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { t.Fatalf("Run() error = %v", err) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml") } @@ -191,7 +193,7 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { if err == nil { t.Fatal("Run() error = nil, want aggregate failure") } - if !strings.Contains(err.Error(), "1 of 3 reports failed") { + if !strings.Contains(err.Error(), "1 of 2 reports failed") { t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) } @@ -199,14 +201,14 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil { t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) } - if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed) + if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 { + t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed) } if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") { t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") } func TestBatchOutputIncludesNotificationDetails(t *testing.T) { @@ -413,7 +415,7 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) { } } -func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { +func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) { fixture := newCLIFixture(t, writeFakeScriptorium) var stdout bytes.Buffer var stderr bytes.Buffer @@ -427,7 +429,9 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { t.Fatalf("Run() error = %v", err) } _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml") + _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml") + noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml") } @@ -1377,7 +1381,7 @@ if [ "$1" = "render" ]; then fi shift done - if [ "$prompt" = "weather.three_day_outlook" ]; then + if [ "$prompt" = "weather.tomorrow_generated_text" ]; then printf 'render failed\n' >&2 exit 1 fi