Add data-aware batch planning
This commit is contained in:
@@ -265,7 +265,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
if now.IsZero() {
|
if now.IsZero() {
|
||||||
now = time.Now()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -280,12 +287,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
|||||||
}
|
}
|
||||||
startedAt := now
|
startedAt := now
|
||||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||||
for _, resolved := range resolvedReports {
|
for _, planned := range plannedReports {
|
||||||
|
resolved := planned.Resolved
|
||||||
if !resolved.Definition.Generated {
|
if !resolved.Definition.Generated {
|
||||||
return nil, fmt.Errorf("run is not implemented")
|
return nil, fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, resolved := range resolvedReports {
|
for _, planned := range plannedReports {
|
||||||
|
resolved := planned.Resolved
|
||||||
item := batchReportResult(resolved)
|
item := batchReportResult(resolved)
|
||||||
if paths, err := store.Paths(resolved); err == nil {
|
if paths, err := store.Paths(resolved); err == nil {
|
||||||
item.DataPackagePath = paths.DataPackage
|
item.DataPackagePath = paths.DataPackage
|
||||||
|
|||||||
@@ -2237,7 +2237,7 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
|||||||
server := dailyBundleServer(t)
|
server := dailyBundleServer(t)
|
||||||
cfg := dailyWorkspaceConfig(t, server)
|
cfg := dailyWorkspaceConfig(t, server)
|
||||||
renderer := &selectiveRenderer{
|
renderer := &selectiveRenderer{
|
||||||
failRenderPrompt: "weather.three_day_outlook",
|
failRenderPrompt: "weather.tomorrow_generated_text",
|
||||||
runBody: "# Batch Report\n",
|
runBody: "# Batch Report\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,23 +2251,23 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
|||||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 {
|
||||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
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)
|
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 {
|
for _, item := range result.Reports {
|
||||||
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
|
if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
|
||||||
failedThreeDay = true
|
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)
|
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !failedThreeDay {
|
if !failedTomorrow {
|
||||||
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports)
|
t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2276,7 +2276,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
|||||||
cfg := dailyNotificationConfig(t, server)
|
cfg := dailyNotificationConfig(t, server)
|
||||||
notifier := &recordingNotifier{
|
notifier := &recordingNotifier{
|
||||||
errByReport: map[report.ID]error{
|
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)
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
if result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 {
|
||||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
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))
|
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
|
||||||
}
|
}
|
||||||
var failedThreeDay bool
|
var failedTomorrow bool
|
||||||
for _, item := range result.Reports {
|
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") {
|
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
|
||||||
failedThreeDay = true
|
failedTomorrow = true
|
||||||
}
|
}
|
||||||
if item.NotificationStatus != "failed" {
|
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" {
|
if item.NotificationPipelineID != "weatherreporter.tomorrow" {
|
||||||
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID)
|
t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(item.NotificationError, "distributor unavailable") {
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -2324,8 +2324,8 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
|||||||
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
|
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !failedThreeDay {
|
if !failedTomorrow {
|
||||||
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
|
t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Workspace.Root = t.TempDir()
|
cfg.Workspace.Root = t.TempDir()
|
||||||
@@ -2338,7 +2338,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Notifier: &recordingNotifier{
|
Notifier: &recordingNotifier{
|
||||||
errByReport: map[report.ID]error{
|
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) {
|
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 != 2 {
|
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 1 {
|
||||||
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
|
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2404,7 +2404,7 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
|
|||||||
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) != 3 {
|
if result.Failed != 0 || len(result.Reports) != 2 {
|
||||||
t.Fatalf("summary = %#v, want successful morning batch", result)
|
t.Fatalf("summary = %#v, want successful morning batch", result)
|
||||||
}
|
}
|
||||||
var todayItem *BatchReportResult
|
var todayItem *BatchReportResult
|
||||||
@@ -2448,26 +2448,18 @@ func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
|
|||||||
collector := &recordingCollector{err: errors.New("batch collector failed")}
|
collector := &recordingCollector{err: errors.New("batch collector failed")}
|
||||||
renderer := &recordingRenderer{}
|
renderer := &recordingRenderer{}
|
||||||
|
|
||||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
_, err := RunBatchDetailed(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,
|
Collector: collector,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil || !strings.Contains(err.Error(), "batch collector failed") {
|
||||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err)
|
||||||
}
|
}
|
||||||
if result.Total == 0 || result.Failed != result.Total {
|
if len(collector.requests) != 1 {
|
||||||
t.Fatalf("batch result = %#v, want every report failed by collector", result)
|
t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests))
|
||||||
}
|
|
||||||
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 renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 {
|
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)
|
t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls)
|
||||||
|
|||||||
@@ -1,12 +1,85 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"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/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"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 {
|
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 {
|
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -5,10 +5,97 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/timeutil"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
"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) {
|
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
|
||||||
location := mustLoadTestLocation(t, "America/Chicago")
|
location := mustLoadTestLocation(t, "America/Chicago")
|
||||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
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 {
|
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
|
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 {
|
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
location, err := time.LoadLocation(name)
|
location, err := time.LoadLocation(name)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|||||||
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||||
runner := Runner{Clock: fixedClock()}
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
@@ -176,7 +176,9 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
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", "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")
|
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 {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want aggregate failure")
|
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())
|
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 {
|
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
||||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
||||||
}
|
}
|
||||||
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
|
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
|
||||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
|
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") {
|
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
||||||
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
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", "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) {
|
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)
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -427,7 +429,9 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
|
|||||||
t.Fatalf("Run() error = %v", err)
|
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", "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")
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1377,7 +1381,7 @@ if [ "$1" = "render" ]; then
|
|||||||
fi
|
fi
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
if [ "$prompt" = "weather.three_day_outlook" ]; then
|
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||||
printf 'render failed\n' >&2
|
printf 'render failed\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user