diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 573ec57..6759f2d 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -646,6 +646,62 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) { } } +func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + cfg.Notify.Distributor.Enabled = true + cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" + cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" + cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" + cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"} + resolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportTomorrow, + }, mustParse("2026-05-29T18:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate() error = %v", err) + } + notifier := &recordingNotifier{} + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validTomorrowGeneratedTextJSON(), + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + Renderer: renderer, + Notifier: notifier, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) + } + req := notifier.requests[0] + if req.ReportID != report.Tomorrow { + t.Fatalf("notification ReportID = %q, want tomorrow", req.ReportID) + } + if req.PipelineID != "weatherreporter.tomorrow.tomorrow" { + t.Fatalf("PipelineID = %q, want report/artifact group values", req.PipelineID) + } + if req.BundleID != "tomorrow.tomorrow.md.tomorrow" { + t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID) + } + wantBundlePaths := []string{"2026-05-30/tomorrow/tomorrow.md"} + if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { + t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) + } + if req.ReportPath != result.ReportPath { + t.Fatalf("ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath) + } +} + func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) renderer := &recordingRenderer{ @@ -1398,6 +1454,50 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) { } } +func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + store, err := state.NewFilesystemStore(cfg.Workspace) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + priorResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportTomorrow, + }, mustParse("2026-05-28T18:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(prior) error = %v", err) + } + savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) + + currentResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(current) error = %v", err) + } + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: successfulRenderer("# Daily Report\n"), + Store: store, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if result.PriorSnapshot != nil { + t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot) + } + if len(result.RecentChanges) != 0 { + t.Fatalf("RecentChanges = %#v, want none from incompatible prior tomorrow snapshot", result.RecentChanges) + } +} + func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { server := dailyBundleServer(t) cfg := config.Defaults() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9028618..d3a6404 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -262,6 +262,16 @@ reports: `, wantErr: `unknown module "forecast_delta"`, }, + { + name: "RetiredTomorrowReportID", + yaml: ` +reports: + daily_tomorrow: + deterministic_modules: + - metadata +`, + wantErr: "reports.daily_tomorrow is not a known report", + }, { name: "InvalidOptions", yaml: ` diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 0f60641..b2832c9 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -42,30 +42,57 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { store := newTestStore(t) - resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") - - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) + tests := []struct { + name string + resolved report.Resolved + group string + validDate string + runID string + }{ + { + name: "hourly", + resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"), + group: "hourly", + validDate: "2026-05-29", + runID: "20260529T100000.000000000Z_hourly", + }, + { + name: "tomorrow", + resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"), + group: "tomorrow", + validDate: "2026-05-30", + runID: "20260529T230000.000000000Z_tomorrow", + }, } - runID := "20260529T100000.000000000Z_hourly" - wants := map[string]string{ - "GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"), - "GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"), - "GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"), - "RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"), - } - got := map[string]string{ - "GeneratedTextRaw": paths.GeneratedTextRaw, - "GeneratedTextResult": paths.GeneratedTextResult, - "GeneratedText": paths.GeneratedText, - "RenderContext": paths.RenderContext, - } - for name, want := range wants { - if !strings.Contains(got[name], want) { - t.Fatalf("%s path = %q, want component %q", name, got[name], want) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + paths, err := store.Paths(tt.resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + wants := map[string]string{ + "DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, tt.runID+".data_package.yaml"), + "RenderedReport": filepath.Join("reports", tt.group, tt.runID+".md"), + "GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.raw.json"), + "GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.run.json"), + "GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".generated_text.json"), + "RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, tt.runID+".render_context.json"), + } + got := map[string]string{ + "DataPackage": paths.DataPackage, + "RenderedReport": paths.RenderedReport, + "GeneratedTextRaw": paths.GeneratedTextRaw, + "GeneratedTextResult": paths.GeneratedTextResult, + "GeneratedText": paths.GeneratedText, + "RenderContext": paths.RenderContext, + } + for name, want := range wants { + if !strings.Contains(got[name], want) { + t.Fatalf("%s path = %q, want component %q", name, got[name], want) + } + } + }) } } @@ -565,6 +592,26 @@ func resolveHourlyAt(t *testing.T, value string) report.Resolved { return resolved } +func resolveTomorrowAt(t *testing.T, value string) report.Resolved { + t.Helper() + location, err := timeutil.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + now, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{ + Now: now, + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { return briefing.Metadata{ RunID: resolved.Metadata().RunID,