|
|
|
|
@@ -417,7 +417,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
|
|
|
|
if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath {
|
|
|
|
|
t.Fatalf("structured run DataPackagePath = %q, want %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath)
|
|
|
|
|
}
|
|
|
|
|
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,render_context,prepare_report,metadata"; got != want {
|
|
|
|
|
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; got != want {
|
|
|
|
|
t.Fatalf("store calls = %v, want %s", store.calls, want)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -501,6 +501,212 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
|
|
|
|
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
|
|
|
|
renderer := &recordingRenderer{
|
|
|
|
|
renderResult: &scriptorium.RenderResult{
|
|
|
|
|
Command: []string{"scriptorium", "render"},
|
|
|
|
|
Stderr: "render failed",
|
|
|
|
|
ExitCode: 1,
|
|
|
|
|
},
|
|
|
|
|
err: errors.New("scriptorium render exited with code 1: render failed"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := GenerateReport(context.Background(), ReportRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Resolved: resolved,
|
|
|
|
|
OutputPath: outputPath,
|
|
|
|
|
Renderer: renderer,
|
|
|
|
|
Store: store,
|
|
|
|
|
Notifier: notifier,
|
|
|
|
|
})
|
|
|
|
|
assertGeneratedReportError(t, err, resolved, "render preflight")
|
|
|
|
|
if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 {
|
|
|
|
|
t.Fatalf("post-preflight calls structured=%d run=%d, want none", renderer.structuredRunCalls, renderer.runCalls)
|
|
|
|
|
}
|
|
|
|
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
|
|
|
|
|
|
|
|
|
paths := hourlyArtifactPaths(t, store, resolved)
|
|
|
|
|
assertPathsExist(t, paths.Preflight, paths.Metadata)
|
|
|
|
|
assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
|
|
|
|
|
preflight, readErr := os.ReadFile(paths.Preflight)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read failed preflight: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(preflight), `"exitCode": 1`) || !strings.Contains(string(preflight), "render failed") {
|
|
|
|
|
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
|
|
|
|
|
}
|
|
|
|
|
metadataData, readErr := os.ReadFile(paths.Metadata)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read metadata: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(metadataData), paths.Preflight) || !strings.Contains(string(metadataData), paths.GeneratedTextRaw) {
|
|
|
|
|
t.Fatalf("metadata missing failed-run artifact links:\n%s", string(metadataData))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
|
|
|
|
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
|
|
|
|
renderer := &recordingRenderer{
|
|
|
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
|
|
|
structuredRunResult: &scriptorium.StructuredRunResult{
|
|
|
|
|
Command: []string{"scriptorium", "run", "--json"},
|
|
|
|
|
Stderr: "generation failed",
|
|
|
|
|
ExitCode: 2,
|
|
|
|
|
},
|
|
|
|
|
structuredRunErr: errors.New("scriptorium structured run exited with code 2: generation failed"),
|
|
|
|
|
structuredRunBody: validHourlyGeneratedTextJSON(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := GenerateReport(context.Background(), ReportRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Resolved: resolved,
|
|
|
|
|
OutputPath: outputPath,
|
|
|
|
|
Renderer: renderer,
|
|
|
|
|
Store: store,
|
|
|
|
|
Notifier: notifier,
|
|
|
|
|
})
|
|
|
|
|
assertGeneratedReportError(t, err, resolved, "structured generated text")
|
|
|
|
|
if renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
|
|
|
|
|
t.Fatalf("calls structured=%d run=%d, want one structured run and no markdown run", renderer.structuredRunCalls, renderer.runCalls)
|
|
|
|
|
}
|
|
|
|
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
|
|
|
|
|
|
|
|
|
paths := hourlyArtifactPaths(t, store, resolved)
|
|
|
|
|
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
|
|
|
|
|
assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
|
|
|
|
|
metadataData, readErr := os.ReadFile(paths.Metadata)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read metadata: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) || !strings.Contains(string(metadataData), paths.GeneratedTextResult) {
|
|
|
|
|
t.Fatalf("metadata missing structured failure links:\n%s", string(metadataData))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
|
|
|
|
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
|
|
|
|
renderer := &recordingRenderer{
|
|
|
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
|
|
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
|
|
|
|
structuredRunBody: `{
|
|
|
|
|
"summary": "Storm chances increase through late morning.",
|
|
|
|
|
"timing": "The main window is 10 AM to noon.",
|
|
|
|
|
"impacts": "Brief downpours may slow travel.",
|
|
|
|
|
"details": "not allowed"
|
|
|
|
|
}`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := GenerateReport(context.Background(), ReportRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Resolved: resolved,
|
|
|
|
|
OutputPath: outputPath,
|
|
|
|
|
Renderer: renderer,
|
|
|
|
|
Store: store,
|
|
|
|
|
Notifier: notifier,
|
|
|
|
|
})
|
|
|
|
|
assertGeneratedReportError(t, err, resolved, "validate generated text")
|
|
|
|
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
|
|
|
|
|
|
|
|
|
paths := hourlyArtifactPaths(t, store, resolved)
|
|
|
|
|
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult)
|
|
|
|
|
assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport)
|
|
|
|
|
raw, readErr := os.ReadFile(paths.GeneratedTextRaw)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read raw generated text: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(raw), `"details": "not allowed"`) {
|
|
|
|
|
t.Fatalf("raw generated text was not preserved:\n%s", string(raw))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateHourlyReportPreservesValidatedTextOnRenderContextFailure(t *testing.T) {
|
|
|
|
|
server := hourlyBundleServer(t)
|
|
|
|
|
cfg := hourlyGeneratedTextConfigWithModules(t, server, []string{
|
|
|
|
|
"metadata",
|
|
|
|
|
"hourly_forecast",
|
|
|
|
|
"precip_timing",
|
|
|
|
|
"alert_digest",
|
|
|
|
|
"area_forecast_discussion",
|
|
|
|
|
"weather_story",
|
|
|
|
|
"spc_convective_outlooks",
|
|
|
|
|
})
|
|
|
|
|
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
|
|
|
|
|
renderer := &recordingRenderer{
|
|
|
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
|
|
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
|
|
|
|
structuredRunBody: validHourlyGeneratedTextJSON(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := GenerateReport(context.Background(), ReportRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Resolved: resolved,
|
|
|
|
|
OutputPath: outputPath,
|
|
|
|
|
Renderer: renderer,
|
|
|
|
|
Store: store,
|
|
|
|
|
Notifier: notifier,
|
|
|
|
|
})
|
|
|
|
|
assertGeneratedReportError(t, err, resolved, "build render context")
|
|
|
|
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
|
|
|
|
|
|
|
|
|
paths := hourlyArtifactPaths(t, store, resolved)
|
|
|
|
|
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
|
|
|
|
|
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
|
|
|
|
|
generatedText, readErr := os.ReadFile(paths.GeneratedText)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read validated generated text: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if string(generatedText) != validHourlyGeneratedTextJSON() {
|
|
|
|
|
t.Fatalf("validated generated text = %s, want normalized JSON", generatedText)
|
|
|
|
|
}
|
|
|
|
|
metadataData, readErr := os.ReadFile(paths.Metadata)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read metadata: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(metadataData), paths.GeneratedText) {
|
|
|
|
|
t.Fatalf("metadata missing validated generated text link:\n%s", string(metadataData))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateHourlyReportPreservesRenderContextOnTemplateFailure(t *testing.T) {
|
|
|
|
|
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
|
|
|
|
resolved.Definition.TemplateID = "missing-template"
|
|
|
|
|
renderer := &recordingRenderer{
|
|
|
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
|
|
|
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
|
|
|
|
structuredRunBody: validHourlyGeneratedTextJSON(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := GenerateReport(context.Background(), ReportRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Resolved: resolved,
|
|
|
|
|
OutputPath: outputPath,
|
|
|
|
|
Renderer: renderer,
|
|
|
|
|
Store: store,
|
|
|
|
|
Notifier: notifier,
|
|
|
|
|
})
|
|
|
|
|
assertGeneratedReportError(t, err, resolved, "render template")
|
|
|
|
|
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
|
|
|
|
|
|
|
|
|
paths := hourlyArtifactPaths(t, store, resolved)
|
|
|
|
|
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext)
|
|
|
|
|
assertPathsMissing(t, paths.RenderedReport)
|
|
|
|
|
renderContext, readErr := os.ReadFile(paths.RenderContext)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read render context: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) {
|
|
|
|
|
t.Fatalf("render context was not preserved:\n%s", string(renderContext))
|
|
|
|
|
}
|
|
|
|
|
metadataData, readErr := os.ReadFile(paths.Metadata)
|
|
|
|
|
if readErr != nil {
|
|
|
|
|
t.Fatalf("read metadata: %v", readErr)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(string(metadataData), paths.RenderContext) {
|
|
|
|
|
t.Fatalf("metadata missing render context link:\n%s", string(metadataData))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
|
|
|
|
server := dailyBundleServer(t)
|
|
|
|
|
cfg := dailyTestConfig(t, server)
|
|
|
|
|
@@ -1686,6 +1892,75 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
|
|
|
|
return cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hourlyGeneratedTextFixture(t *testing.T) (config.Config, report.Resolved, *recordingStore, *recordingNotifier, string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
server := hourlyBundleServer(t)
|
|
|
|
|
cfg := hourlyGeneratedTextConfig(t, server)
|
|
|
|
|
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
|
|
|
|
|
return cfg, resolved, store, notifier, outputPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hourlyGeneratedTextConfig(t *testing.T, server *httptest.Server) config.Config {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cfg := config.Defaults()
|
|
|
|
|
applyHourlyGeneratedTextSettings(&cfg, t, server)
|
|
|
|
|
return cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hourlyGeneratedTextConfigWithModules(t *testing.T, server *httptest.Server, modules []string) config.Config {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var data strings.Builder
|
|
|
|
|
data.WriteString("reports:\n hourly:\n deterministic_modules:\n")
|
|
|
|
|
for _, id := range modules {
|
|
|
|
|
_, _ = fmt.Fprintf(&data, " - %s\n", id)
|
|
|
|
|
}
|
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
|
|
|
if err := os.WriteFile(path, []byte(data.String()), 0o600); err != nil {
|
|
|
|
|
t.Fatalf("write config fixture: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cfg, err := config.LoadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("LoadFile() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
applyHourlyGeneratedTextSettings(&cfg, t, server)
|
|
|
|
|
return cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *httptest.Server) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
|
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
|
|
|
cfg.Workspace.Root = t.TempDir()
|
|
|
|
|
cfg.Location.ID = "home"
|
|
|
|
|
cfg.Location.Name = "Brentwood"
|
|
|
|
|
cfg.Location.Region = "MO"
|
|
|
|
|
cfg.Notify.Distributor.Enabled = true
|
|
|
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
|
|
|
cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
|
|
|
|
|
cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}"
|
|
|
|
|
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Report: ReportHourly,
|
|
|
|
|
}, mustParse("2026-05-29T08:30:00-05:00"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
return resolved, &recordingStore{Store: filesystemStore}, &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validHourlyGeneratedTextJSON() string {
|
|
|
|
|
return `{"summary":"Storm chances increase through late morning.","timing":"The main window is 10 AM to noon.","impacts":"Brief downpours may slow travel.","confidence":"Medium"}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
|
|
|
|
t.Helper()
|
|
|
|
|
cfg.Workspace.Root = t.TempDir()
|
|
|
|
|
@@ -1754,6 +2029,51 @@ func assertPathsExist(t *testing.T, paths ...string) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertPathsMissing(t *testing.T, paths ...string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
if _, err := os.Stat(path); err == nil {
|
|
|
|
|
t.Fatalf("artifact %q exists, want missing", path)
|
|
|
|
|
} else if !os.IsNotExist(err) {
|
|
|
|
|
t.Fatalf("stat artifact %q: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolved) state.ArtifactPaths {
|
|
|
|
|
t.Helper()
|
|
|
|
|
paths, err := store.Paths(resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Paths() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
return paths
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("GenerateReport() error = nil, want generated-text report error")
|
|
|
|
|
}
|
|
|
|
|
text := err.Error()
|
|
|
|
|
for _, want := range []string{
|
|
|
|
|
fmt.Sprintf("generate report %q", resolved.Definition.ID),
|
|
|
|
|
fmt.Sprintf("run %q", resolved.Metadata().RunID),
|
|
|
|
|
operation,
|
|
|
|
|
} {
|
|
|
|
|
if !strings.Contains(text, want) {
|
|
|
|
|
t.Fatalf("error = %q, want %q", text, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func assertNoGeneratedFailureSideEffects(t *testing.T, notifier *recordingNotifier, outputPath string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if len(notifier.requests) != 0 {
|
|
|
|
|
t.Fatalf("notification requests = %#v, want none after generated-text failure", notifier.requests)
|
|
|
|
|
}
|
|
|
|
|
assertPathsMissing(t, outputPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
|
|
|
|
|