package app import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "sync" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/testutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type generationCollector struct { bundle *weatherdata.Bundle err error called bool calls int beforeRun func() } type publicationGateContext struct { context.Context err error checks int afterChecks int } func (c *publicationGateContext) Err() error { c.checks++ afterChecks := c.afterChecks if afterChecks == 0 { afterChecks = 2 } if c.checks >= afterChecks { return c.err } return nil } func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) { if c.beforeRun != nil { c.beforeRun() } c.called = true c.calls++ return &collect.Result{Bundle: c.bundle}, c.err } type generationExecutor struct { called bool executeCalls int promptInspections int profileInspections int inspectErr error profileInspectErrors map[string]error executeErr error executeErrors map[string]error beforeExecute func(promptexec.ExecuteRequest) cancelBeforeReturn context.CancelFunc validation promptexec.ValidationStatus validations map[string]promptexec.ValidationStatus rawOutput []byte waitForCancellation map[string]bool failedPrompt string skipPreparation bool preparationCalls int prepare func(*promptexec.Preparation) complete func(*promptexec.Execution) } var generationExecutorMu sync.Mutex func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) { generationExecutorMu.Lock() defer generationExecutorMu.Unlock() e.promptInspections++ if e.inspectErr != nil { return promptexec.PromptInspection{}, e.inspectErr } definition := generationDefinitionForPrompt(id) return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: generationPromptHash, DefaultProfileID: "fixture", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"}}, nil } func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { generationExecutorMu.Lock() defer generationExecutorMu.Unlock() e.profileInspections++ if err := e.profileInspectErrors[id]; err != nil { return promptexec.ProfileInspection{}, err } return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil } func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) generationExecutorMu.Lock() skipPreparation := e.skipPreparation prepare := e.prepare preparationCalls := e.preparationCalls generationExecutorMu.Unlock() if !skipPreparation { calls := preparationCalls if calls == 0 { calls = 1 } for range calls { preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID + ".generated_text.schema.json"}, StartedAt: stamp, EndedAt: stamp} if prepare != nil { prepare(&preparation) } if err := callback(preparation, nil); err != nil { return nil, err } } } generationExecutorMu.Lock() e.called = true e.executeCalls++ beforeExecute := e.beforeExecute profileErr := e.executeErrors[req.ProfileID] executeErr := e.executeErr status := e.validation if profileStatus, ok := e.validations[req.ProfileID]; ok { status = profileStatus } rawOutput := append([]byte(nil), e.rawOutput...) waitForCancellation := e.waitForCancellation[req.ProfileID] failedPrompt := e.failedPrompt cancelBeforeReturn := e.cancelBeforeReturn complete := e.complete generationExecutorMu.Unlock() if beforeExecute != nil { beforeExecute(req) } if waitForCancellation { <-ctx.Done() return nil, ctx.Err() } if profileErr != nil { return nil, profileErr } if executeErr != nil { return nil, executeErr } if status == "" { status = promptexec.ValidationPassed } if failedPrompt == req.PromptID { status = promptexec.ValidationFailed } if rawOutput == nil { rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`) } if cancelBeforeReturn != nil { cancelBeforeReturn() } execution := &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)} if complete != nil { complete(execution) } return execution, nil } const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" func generationDefinitionForPrompt(promptID string) report.Definition { for _, definition := range report.DefaultRegistry().All() { if definition.PromptID == promptID { return definition } } panic("unknown fixture prompt " + promptID) } func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home" bundle := generationBundle(t) executor := &generationExecutor{} collector := &generationCollector{bundle: &bundle} workingDir := t.TempDir() result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: collector, Executor: executor}) if err != nil { t.Fatalf("GenerateDetailed() error = %v", err) } if !executor.called || executor.executeCalls != 1 || collector.calls != 1 || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" { t.Fatalf("result = %#v", result) } if result.LLMDebugPath != "" { t.Fatalf("unexpected debug output = %q", result.LLMDebugPath) } if _, err := os.Stat(filepath.Join(workingDir, "workspace")); !os.IsNotExist(err) { t.Fatalf("unexpected default state directory: %v", err) } data, err := os.ReadFile(result.OutputPath) if err != nil || len(data) == 0 { t.Fatalf("output = %q, error = %v", data, err) } } func TestGenerateDetailedUsesConfiguredOutputDirectory(t *testing.T) { tests := []struct { name string directory func(t *testing.T, workingDir string) string wantDir func(t *testing.T, workingDir string, configuredDir string) string }{ { name: "absolute directory", directory: func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "reports") }, wantDir: func(_ *testing.T, _ string, configuredDir string) string { return configuredDir }, }, { name: "relative directory", directory: func(_ *testing.T, _ string) string { return "configured/../reports" }, wantDir: func(_ *testing.T, workingDir string, _ string) string { return filepath.Join(workingDir, "reports") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { workingDir := t.TempDir() configuredDir := tt.directory(t, workingDir) cfg := generationDistributorConfig() cfg.Output.Directory = configuredDir bundle := generationBundle(t) notifier := &generationNotifier{} result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier, }) wantPath := filepath.Join(tt.wantDir(t, workingDir, configuredDir), "daily-2026-05-29.md") if err != nil || result == nil || result.OutputPath != wantPath || notifier.request.ReportPath != wantPath { t.Fatalf("GenerateDetailed() result/error/notification = %#v/%v/%#v", result, err, notifier.request) } if info, statErr := os.Stat(filepath.Dir(wantPath)); statErr != nil || !info.IsDir() { t.Fatalf("configured output directory info/error = %#v/%v", info, statErr) } if _, statErr := os.Stat(wantPath); statErr != nil { t.Fatalf("output %q: %v", wantPath, statErr) } }) } } func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory(t *testing.T) { configuredPath := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil { t.Fatal(err) } explicitPath := filepath.Join(t.TempDir(), "explicit.md") cfg := generationConfig() cfg.Output.Directory = configuredPath bundle := generationBundle(t) result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: explicitPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, }) if err != nil || result == nil || result.OutputPath != explicitPath { t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err) } if _, statErr := os.Stat(explicitPath); statErr != nil { t.Fatalf("explicit output %q: %v", explicitPath, statErr) } } func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) { configuredPath := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil { t.Fatal(err) } cfg := generationDistributorConfig() cfg.Output.Directory = configuredPath bundle := generationBundle(t) collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} notifier := &generationNotifier{} result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: executor, Notifier: notifier, }) if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 { t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier) } if data, readErr := os.ReadFile(configuredPath); readErr != nil || string(data) != "not a directory" { t.Fatalf("configured path = %q, error = %v", data, readErr) } } func TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) { missingDirectory := filepath.Join(t.TempDir(), "missing") outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md") bundle := generationBundle(t) collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, }) if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called { t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor) } if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) { t.Fatalf("missing output directory exists after preflight failure: %v", statErr) } } func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) { outputPath := filepath.Join(t.TempDir(), "daily.md") cfg := generationDistributorConfig() cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test" bundle := generationBundle(t) collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} notifier := &generationNotifier{} result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier, }) if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 { t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier) } if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) { t.Fatalf("output exists after endpoint preflight failure: %v", statErr) } } func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home" collectionErr := errors.New("weather source unavailable") result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: &generationCollector{err: collectionErr}, Executor: &generationExecutor{}, }) if !errors.Is(err, collectionErr) { t.Fatalf("GenerateDetailed() error = %v, want %v", err, collectionErr) } if result == nil || result.ReportID != report.Daily || result.RunID == "" || result.ProfileID != "fixture" || result.BackendID != "fixture" || result.ModelName != "fixture-model" || result.OutputPath != "" { t.Fatalf("result = %#v", result) } } func TestGenerateDetailedInspectsPromptBeforeCollectingWeather(t *testing.T) { cfg := generationConfig() inspectionErr := errors.New("profile is invalid") collector := &generationCollector{bundle: generationBundlePointer(t)} result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: &generationExecutor{inspectErr: inspectionErr}}) if !errors.Is(err, inspectionErr) || collector.called || result == nil { t.Fatalf("GenerateDetailed() result/error/collector-called = %#v/%v/%t", result, err, collector.called) } } func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) { for _, scenario := range []struct { name string executor generationExecutor }{ {name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}}, {name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}}, } { t.Run(scenario.name, func(t *testing.T) { outputPath := filepath.Join(t.TempDir(), "daily.md") if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil { t.Fatal(err) } bundle := generationBundle(t) result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor}) data, readErr := os.ReadFile(outputPath) if err == nil || result == nil || readErr != nil || string(data) != "previous report" { t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr) } }) } } func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) { outputPath := filepath.Join(t.TempDir(), "daily.md") const previousReport = "previous report" if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) bundle := generationBundle(t) result, err := GenerateDetailed(ctx, GenerateRequest{ Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, }) data, readErr := os.ReadFile(outputPath) if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport { t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr) } } func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) { outputPath := filepath.Join(t.TempDir(), "daily.md") const previousReport = "previous report" if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil { t.Fatal(err) } ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0)) defer cancel() bundle := generationBundle(t) result, err := GenerateDetailed(ctx, GenerateRequest{ Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, }) data, readErr := os.ReadFile(outputPath) if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport { t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr) } } func TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication(t *testing.T) { for _, tt := range []struct { name string err error category promptexec.ErrorCategory }{ {name: "canceled", err: context.Canceled, category: promptexec.Canceled}, {name: "deadline", err: context.DeadlineExceeded, category: promptexec.DeadlineExceeded}, } { t.Run(tt.name, func(t *testing.T) { outputPath := filepath.Join(t.TempDir(), "daily.md") const previousReport = "previous report" if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil { t.Fatal(err) } ctx := &publicationGateContext{Context: context.Background(), err: tt.err} cfg := generationConfig() cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.PipelineIDTemplate = "weather" bundle := generationBundle(t) notifier := &generationNotifier{} result, err := GenerateDetailed(ctx, GenerateRequest{ Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier, }) data, readErr := os.ReadFile(outputPath) matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(outputPath), ".weatherreporter-*.tmp")) if !errors.Is(err, tt.err) || promptexec.CategoryOf(err) != tt.category || result == nil || result.OutputPath != "" || notifier.calls != 0 || readErr != nil || string(data) != previousReport || globErr != nil || len(matches) != 0 { t.Fatalf("GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v", result, err, data, notifier, matches, globErr) } }) } } func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) { cfg := generationConfig() cfg.Notify.Distributor.Enabled = true cfg.Notify.Distributor.PipelineIDTemplate = "weather" bundle := generationBundle(t) outputPath := filepath.Join(t.TempDir(), "daily.md") notifier := &generationNotifier{err: errors.New("distributor unavailable")} result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier}) if err == nil || result == nil || result.OutputPath != outputPath || notifier.request.ReportPath != outputPath || len(notifier.request.BundlePaths) == 0 { t.Fatalf("GenerateDetailed() result/error/request = %#v/%v/%#v", result, err, notifier.request) } if data, readErr := os.ReadFile(outputPath); readErr != nil || len(data) == 0 { t.Fatalf("published output = %q, error = %v", data, readErr) } } func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) { bundle := generationBundle(t) outputPath := filepath.Join(t.TempDir(), "daily.md") if err := os.Mkdir(outputPath, 0o700); err != nil { t.Fatal(err) } collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} notifier := &generationNotifier{} result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier}) info, statErr := os.Stat(outputPath) if err == nil || result == nil || statErr != nil || !info.IsDir() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 { t.Fatalf("GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier) } } func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) { dir := t.TempDir() backing := filepath.Join(dir, "backing.md") if err := os.WriteFile(backing, []byte("previous report"), 0o600); err != nil { t.Fatal(err) } outputPath := filepath.Join(dir, "daily.md") testutil.RequireSymlink(t, backing, outputPath) bundle := generationBundle(t) collector := &generationCollector{bundle: &bundle} executor := &generationExecutor{} notifier := &generationNotifier{} result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier}) info, statErr := os.Lstat(outputPath) data, readErr := os.ReadFile(backing) if err == nil || result == nil || statErr != nil || info.Mode()&os.ModeSymlink == 0 || readErr != nil || string(data) != "previous report" || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 { t.Fatalf("GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v", result, err, info, statErr, data, readErr, collector.called, executor, notifier) } } func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) { bundle := generationBundle(t) debugRoot := t.TempDir() result, err := GenerateDetailed(context.Background(), GenerateRequest{ Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, }) if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) { t.Skipf("secure prompt debug capture is unavailable: %v", err) } if err != nil || result == nil || result.LLMDebugPath == "" { t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err) } for _, name := range []string{"preparation.json", "execution.json"} { if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil { t.Fatalf("debug artifact %q: %v", name, statErr) } } } func generationConfig() config.Config { cfg := config.Defaults() cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home" return cfg } func generationBundlePointer(t *testing.T) *weatherdata.Bundle { bundle := generationBundle(t) return &bundle } type generationNotifier struct { err error batchErr error calls int request NotificationRequest batchRequest batchNotificationRequest batchCalls int } func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) { n.calls++ n.request = request if n.err != nil { return nil, n.err } return &NotificationResult{Status: "succeeded"}, nil } func (n *generationNotifier) NotifyBatch(_ context.Context, request batchNotificationRequest) (*NotificationResult, error) { n.batchCalls++ n.batchRequest = request for _, file := range request.Files { if _, err := os.Stat(file.SourcePath); err != nil { return nil, err } } return &NotificationResult{Status: "succeeded", PipelineID: request.PipelineID, BundleID: request.BundleID}, n.batchErr } func generationBundle(t *testing.T) weatherdata.Bundle { t.Helper() data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json")) if err != nil { t.Fatalf("read bundle fixture: %v", err) } var bundle weatherdata.Bundle if err := json.Unmarshal(data, &bundle); err != nil { t.Fatalf("decode bundle fixture: %v", err) } return bundle } func generationTime(value string) time.Time { parsed, _ := time.Parse(time.RFC3339, value) return parsed } var _ promptexec.Executor = (*generationExecutor)(nil) var _ Collector = (*generationCollector)(nil) var _ Notifier = (*generationNotifier)(nil) var _ = report.Daily