Reuse collected batch data

This commit is contained in:
2026-06-17 16:04:14 +00:00
parent 6f9255105d
commit a9d87bdbaa
2 changed files with 98 additions and 63 deletions

View File

@@ -295,22 +295,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
} }
for _, planned := range plannedReports { for _, planned := range plannedReports {
resolved := planned.Resolved resolved := planned.Resolved
item := batchReportResult(resolved) item := batchReportResult(planned)
if paths, err := store.Paths(resolved); err == nil { if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata item.MetadataPath = paths.Metadata
} }
collection, err := collectWeather(ctx, req.Config, req.Collector) outputPath := plannedBatchOutputPath(req.OutputDir, planned)
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)
reportResult, err := GenerateReport(ctx, ReportRequest{ reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
@@ -357,7 +349,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
return nil, fmt.Errorf("run is not implemented") 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() metadata := resolved.Metadata()
return BatchReportResult{ return BatchReportResult{
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
@@ -369,11 +362,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
if outputDir == "" || definition.BatchOutputName == "" { if outputDir == "" {
return "" 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) { func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {

View File

@@ -2236,6 +2236,9 @@ func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
func TestRunBatchContinuesAfterReportFailure(t *testing.T) { func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
renderer := &selectiveRenderer{ renderer := &selectiveRenderer{
failRenderPrompt: "weather.tomorrow_generated_text", failRenderPrompt: "weather.tomorrow_generated_text",
runBody: "# Batch Report\n", runBody: "# Batch Report\n",
@@ -2245,23 +2248,31 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: renderer, Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 { if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed) 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) t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
} }
var failedTomorrow bool var failedTomorrow bool
var succeededDaily bool
for _, item := range result.Reports { for _, item := range result.Reports {
if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") { if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedTomorrow = true failedTomorrow = true
} }
if item.ReportID == report.Daily && item.Status == "succeeded" {
succeededDaily = true
}
if item.ReportID != report.Tomorrow && item.Status != "succeeded" { if item.ReportID != report.Tomorrow && item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
} }
@@ -2269,11 +2280,17 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
if !failedTomorrow { if !failedTomorrow {
t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports) 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) { func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server) cfg := dailyNotificationConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
notifier := &recordingNotifier{ notifier := &recordingNotifier{
errByReport: map[report.ID]error{ errByReport: map[report.ID]error{
report.Tomorrow: errors.New("distributor unavailable"), report.Tomorrow: errors.New("distributor unavailable"),
@@ -2284,6 +2301,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
Notifier: notifier, Notifier: notifier,
}) })
@@ -2291,10 +2309,13 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 { if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", result.Total, result.Succeeded, result.Failed) 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)) t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
} }
var failedTomorrow bool var failedTomorrow bool
@@ -2329,10 +2350,12 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
} }
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
collector = &recordingCollector{result: &collection}
err = RunBatch(context.Background(), BatchRequest{ err = RunBatch(context.Background(), BatchRequest{
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: &selectiveRenderer{ Renderer: &selectiveRenderer{
runBody: "# Batch Report\n", runBody: "# Batch Report\n",
}, },
@@ -2346,7 +2369,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
if !errors.As(err, &batchErr) { if !errors.As(err, &batchErr) {
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err) 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) 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) { func TestRunBatchUsesOutputDirectory(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
outputDir := filepath.Join(t.TempDir(), "reports") outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{ result, err := RunBatchDetailed(context.Background(), BatchRequest{
@@ -2361,22 +2387,34 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
Batch: BatchEvening, Batch: BatchEvening,
Now: mustParse("2026-05-29T18:00:00-05:00"), Now: mustParse("2026-05-29T18:00:00-05:00"),
OutputDir: outputDir, OutputDir: outputDir,
Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"}, Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
}) })
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
if result.Failed != 0 || len(result.Reports) != 1 { if result.Failed != 0 || len(result.Reports) != 2 {
t.Fatalf("summary = %#v, want one successful report", result) t.Fatalf("summary = %#v, want two successful reports", result)
} }
want := filepath.Join(outputDir, "tomorrow.md") if len(collector.requests) != 1 {
if result.Reports[0].OutputPath != want { t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests))
t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want) }
wantByReport := map[report.ID]string{
report.Tomorrow: filepath.Join(outputDir, "tomorrow.md"),
report.Daily: filepath.Join(outputDir, "daily-2026-05-31.md"),
}
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 { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected output copy %q: %v", want, err) t.Fatalf("expected output copy %q: %v", want, err)
} }
reportData, err := os.ReadFile(result.Reports[0].ReportPath) reportData, err := os.ReadFile(item.ReportPath)
if err != nil { if err != nil {
t.Fatalf("read managed report: %v", err) t.Fatalf("read managed report: %v", err)
} }
@@ -2385,7 +2423,8 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
t.Fatalf("read output copy: %v", err) t.Fatalf("read output copy: %v", err)
} }
if string(copyData) != string(reportData) { if string(copyData) != string(reportData) {
t.Fatalf("batch output copy differs from managed report") 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) { func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = "" cfg.WeatherAPI.BaseURL = ""
@@ -2506,6 +2533,14 @@ func collectionForTest(t *testing.T, cfg config.Config) collect.Result {
return collect.Result{Bundle: bundle} 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 { func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved {
t.Helper() t.Helper()
req.Config = cfg req.Config = cfg