diff --git a/internal/app/app.go b/internal/app/app.go index 9553d3a..0043c8a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -295,22 +295,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro } for _, planned := range plannedReports { resolved := planned.Resolved - item := batchReportResult(resolved) + item := batchReportResult(planned) if paths, err := store.Paths(resolved); err == nil { item.DataPackagePath = paths.DataPackage item.PreflightPath = paths.Preflight item.ReportPath = paths.RenderedReport item.MetadataPath = paths.Metadata } - collection, err := collectWeather(ctx, req.Config, req.Collector) - if err != nil { - item.Status = "failed" - item.Error = err.Error() - result.Failed++ - result.Reports = append(result.Reports, item) - continue - } - outputPath := batchOutputPath(req.OutputDir, resolved.Definition) + outputPath := plannedBatchOutputPath(req.OutputDir, planned) reportResult, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, @@ -357,7 +349,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro return nil, fmt.Errorf("run is not implemented") } -func batchReportResult(resolved report.Resolved) BatchReportResult { +func batchReportResult(planned plannedBatchReport) BatchReportResult { + resolved := planned.Resolved metadata := resolved.Metadata() return BatchReportResult{ ReportID: resolved.Definition.ID, @@ -369,11 +362,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult { } } -func batchOutputPath(outputDir string, definition report.Definition) string { - if outputDir == "" || definition.BatchOutputName == "" { +func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string { + if outputDir == "" { return "" } - return filepath.Join(outputDir, definition.BatchOutputName) + outputCopyName := planned.OutputCopyName + if outputCopyName == "" { + outputCopyName = planned.Resolved.Definition.BatchOutputName + } + if outputCopyName == "" { + return "" + } + return filepath.Join(outputDir, outputCopyName) } func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 2e594e7..c194582 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2236,32 +2236,43 @@ func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) { func TestRunBatchContinuesAfterReportFailure(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) + collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") + cfg.WeatherAPI.BaseURL = "" + collector := &recordingCollector{result: &collection} renderer := &selectiveRenderer{ failRenderPrompt: "weather.tomorrow_generated_text", runBody: "# Batch Report\n", } result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Renderer: renderer, + 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 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 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 renderer.runCalls != 0 || renderer.structuredRunCalls != 1 { + if len(collector.requests) != 1 { + t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests)) + } + if renderer.runCalls != 0 || renderer.structuredRunCalls != 2 { t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls) } var failedTomorrow bool + var succeededDaily bool for _, item := range result.Reports { if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") { failedTomorrow = true } + if item.ReportID == report.Daily && item.Status == "succeeded" { + succeededDaily = true + } if item.ReportID != report.Tomorrow && item.Status != "succeeded" { t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) } @@ -2269,11 +2280,17 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) { if !failedTomorrow { t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports) } + if !succeededDaily { + t.Fatalf("reports = %#v, want Daily report to continue after Tomorrow failure", result.Reports) + } } func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { server := dailyBundleServer(t) cfg := dailyNotificationConfig(t, server) + collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") + cfg.WeatherAPI.BaseURL = "" + collector := &recordingCollector{result: &collection} notifier := &recordingNotifier{ errByReport: map[report.ID]error{ report.Tomorrow: errors.New("distributor unavailable"), @@ -2281,20 +2298,24 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { } result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, + Config: cfg, + Batch: BatchMorning, + Now: mustParse("2026-05-29T05:00:00-05:00"), + Collector: collector, + Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, + Notifier: notifier, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } - 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 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 len(notifier.requests) != 2 { + if len(collector.requests) != 1 { + t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests)) + } + if len(notifier.requests) != 3 { t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests)) } var failedTomorrow bool @@ -2329,10 +2350,12 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { } cfg.Workspace.Root = t.TempDir() + collector = &recordingCollector{result: &collection} err = RunBatch(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), + Config: cfg, + Batch: BatchMorning, + Now: mustParse("2026-05-29T05:00:00-05:00"), + Collector: collector, Renderer: &selectiveRenderer{ runBody: "# Batch Report\n", }, @@ -2346,7 +2369,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 != 1 { + if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 { t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result) } } @@ -2354,6 +2377,9 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) { func TestRunBatchUsesOutputDirectory(t *testing.T) { server := dailyBundleServer(t) cfg := dailyWorkspaceConfig(t, server) + collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") + cfg.WeatherAPI.BaseURL = "" + collector := &recordingCollector{result: &collection} outputDir := filepath.Join(t.TempDir(), "reports") result, err := RunBatchDetailed(context.Background(), BatchRequest{ @@ -2361,31 +2387,44 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) { Batch: BatchEvening, Now: mustParse("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, - Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"}, + Collector: collector, + Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, }) if err != nil { t.Fatalf("RunBatchDetailed() error = %v", err) } - if result.Failed != 0 || len(result.Reports) != 1 { - t.Fatalf("summary = %#v, want one successful report", result) + if result.Failed != 0 || len(result.Reports) != 2 { + t.Fatalf("summary = %#v, want two successful reports", result) } - want := filepath.Join(outputDir, "tomorrow.md") - if result.Reports[0].OutputPath != want { - t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want) + if len(collector.requests) != 1 { + t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests)) } - if _, err := os.Stat(want); err != nil { - t.Fatalf("expected output copy %q: %v", want, err) + wantByReport := map[report.ID]string{ + report.Tomorrow: filepath.Join(outputDir, "tomorrow.md"), + report.Daily: filepath.Join(outputDir, "daily-2026-05-31.md"), } - reportData, err := os.ReadFile(result.Reports[0].ReportPath) - if err != nil { - t.Fatalf("read managed report: %v", err) - } - copyData, err := os.ReadFile(want) - if err != nil { - t.Fatalf("read output copy: %v", err) - } - if string(copyData) != string(reportData) { - t.Fatalf("batch output copy differs from managed report") + for _, item := range result.Reports { + want := wantByReport[item.ReportID] + if want == "" { + t.Fatalf("unexpected report item = %#v", item) + } + if item.OutputPath != want { + t.Fatalf("%s OutputPath = %q, want %q", item.ReportID, item.OutputPath, want) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected output copy %q: %v", want, err) + } + reportData, err := os.ReadFile(item.ReportPath) + if err != nil { + t.Fatalf("read managed report: %v", err) + } + copyData, err := os.ReadFile(want) + if err != nil { + t.Fatalf("read output copy: %v", err) + } + if string(copyData) != string(reportData) { + t.Fatalf("batch output copy differs from managed report for %s", item.ReportID) + } } } @@ -2429,18 +2468,6 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) { } } -func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { - definition, err := report.DefaultRegistry().Lookup(report.Hourly) - if err != nil { - t.Fatalf("Lookup(hourly) error = %v", err) - } - outputDir := filepath.Join(t.TempDir(), "reports") - want := filepath.Join(outputDir, "hourly.md") - if got := batchOutputPath(outputDir, definition); got != want { - t.Fatalf("batchOutputPath() = %q, want %q", got, want) - } -} - func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.BaseURL = "" @@ -2506,6 +2533,14 @@ func collectionForTest(t *testing.T, cfg config.Config) collect.Result { return collect.Result{Bundle: bundle} } +func collectionWithFutureDailyForTest(t *testing.T, cfg config.Config, date string) collect.Result { + t.Helper() + collection := collectionForTest(t, cfg) + location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone) + collection.Bundle.Hourly.Periods = append(collection.Bundle.Hourly.Periods, fullDayPeriods(t, date, location)...) + return collection +} + func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved { t.Helper() req.Config = cfg