610 lines
27 KiB
Go
610 lines
27 KiB
Go
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
|