Restore batch workflow coverage
This commit is contained in:
@@ -13,31 +13,44 @@ import (
|
||||
)
|
||||
|
||||
func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||
req := BatchRequest{Config: cfg, Batch: BatchMorning, Now: now}
|
||||
candidates, err := batchInspectionCandidates(req, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||
tests := []struct {
|
||||
name string
|
||||
batch BatchKind
|
||||
now string
|
||||
wantPrompts int
|
||||
}{
|
||||
{name: "morning", batch: BatchMorning, now: "2026-05-29T08:00:00-05:00", wantPrompts: 3},
|
||||
{name: "evening", batch: BatchEvening, now: "2026-05-29T18:00:00-05:00", wantPrompts: 2},
|
||||
}
|
||||
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||
}, prompts: map[string]promptexec.PromptInspection{}}
|
||||
for _, candidate := range candidates {
|
||||
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
|
||||
}
|
||||
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
|
||||
return nil, errors.New("collection reached")
|
||||
})
|
||||
req.Executor = executor
|
||||
req.Collector = collector
|
||||
_, err = RunBatchDetailed(context.Background(), req)
|
||||
if err == nil || err.Error() != "collection reached" {
|
||||
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
|
||||
}
|
||||
if len(executor.promptRequests) != 3 || len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
now := mustParse(test.now)
|
||||
req := BatchRequest{Config: cfg, Batch: test.batch, Now: now}
|
||||
candidates, err := batchInspectionCandidates(req, now)
|
||||
if err != nil {
|
||||
t.Fatalf("batchInspectionCandidates() error = %v", err)
|
||||
}
|
||||
executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
|
||||
}, prompts: map[string]promptexec.PromptInspection{}}
|
||||
for _, candidate := range candidates {
|
||||
executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition)
|
||||
}
|
||||
collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) {
|
||||
return nil, errors.New("collection reached")
|
||||
})
|
||||
req.Executor = executor
|
||||
req.Collector = collector
|
||||
_, err = RunBatchDetailed(context.Background(), req)
|
||||
if err == nil || err.Error() != "collection reached" {
|
||||
t.Fatalf("RunBatchDetailed() error = %v, want collection error", err)
|
||||
}
|
||||
if len(executor.promptRequests) != test.wantPrompts || len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
500
internal/app/batch_workflow_test.go
Normal file
500
internal/app/batch_workflow_test.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type assembledBatchExecutor struct {
|
||||
definitions map[string]report.Definition
|
||||
promptRequests []string
|
||||
profileRequests []string
|
||||
executeRequests []promptexec.ExecuteRequest
|
||||
failures map[int]error
|
||||
active int
|
||||
maxActive int
|
||||
}
|
||||
|
||||
func newAssembledBatchExecutor() *assembledBatchExecutor {
|
||||
definitions := make(map[string]report.Definition)
|
||||
for _, definition := range report.DefaultRegistry().All() {
|
||||
definitions[definition.PromptID] = definition
|
||||
}
|
||||
return &assembledBatchExecutor{definitions: definitions, failures: make(map[int]error)}
|
||||
}
|
||||
|
||||
func (e *assembledBatchExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||
e.promptRequests = append(e.promptRequests, id+"@"+version)
|
||||
definition, ok := e.definitions[id]
|
||||
if !ok || definition.PromptVersion != version {
|
||||
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
|
||||
}
|
||||
return validPromptInspection(definition), nil
|
||||
}
|
||||
|
||||
func (e *assembledBatchExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
e.profileRequests = append(e.profileRequests, id)
|
||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||
}
|
||||
|
||||
func (e *assembledBatchExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
call := len(e.executeRequests)
|
||||
e.executeRequests = append(e.executeRequests, req)
|
||||
e.active++
|
||||
if e.active > e.maxActive {
|
||||
e.maxActive = e.active
|
||||
}
|
||||
defer func() { e.active-- }()
|
||||
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
preparation := promptexec.Preparation{
|
||||
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash",
|
||||
RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture",
|
||||
ModelName: "fixture-model", DataPackagePath: req.DataPackagePath, StartedAt: stamp, EndedAt: stamp,
|
||||
}
|
||||
if err := callback(preparation, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.failures[call]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition := e.definitions[req.PromptID]
|
||||
return &promptexec.Execution{
|
||||
RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion,
|
||||
PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID,
|
||||
BackendID: "fixture", ModelName: "fixture-model", GeneratedHash: "generated-hash",
|
||||
StartedAt: stamp, EndedAt: stamp, DataPackagePath: req.DataPackagePath,
|
||||
RawOutput: []byte(generatedTextForPrompt(req.PromptID)),
|
||||
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", definition.GeneratedTextSchemaID+".generated_text.schema.json", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type assembledBatchNotifier struct {
|
||||
reportRequests []NotificationRequest
|
||||
batchRequests []batchNotificationRequest
|
||||
batchResult *NotificationResult
|
||||
batchErr error
|
||||
}
|
||||
|
||||
func (n *assembledBatchNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
||||
n.reportRequests = append(n.reportRequests, req)
|
||||
return nil, errors.New("per-report notification must be suppressed")
|
||||
}
|
||||
|
||||
func (n *assembledBatchNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) {
|
||||
n.batchRequests = append(n.batchRequests, req)
|
||||
if n.batchErr != nil {
|
||||
return nil, n.batchErr
|
||||
}
|
||||
if n.batchResult != nil {
|
||||
result := *n.batchResult
|
||||
if result.PipelineID == "" {
|
||||
result.PipelineID = req.PipelineID
|
||||
}
|
||||
if result.BundleID == "" {
|
||||
result.BundleID = req.BundleID
|
||||
}
|
||||
if result.IdempotencyKey == "" {
|
||||
result.IdempotencyKey = req.IdempotencyKey
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
return &NotificationResult{
|
||||
RunID: "batch-notification-run", PipelineID: req.PipelineID, BundleID: req.BundleID,
|
||||
IdempotencyKey: req.IdempotencyKey, Status: "succeeded", UploadStatus: "accepted",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedExecutesRetainedReportsSequentially(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
batch BatchKind
|
||||
now time.Time
|
||||
wantIDs []report.ID
|
||||
wantCopies []string
|
||||
}{
|
||||
{name: "morning", batch: BatchMorning, now: workflowTime("2026-05-29T08:00:00-05:00"), wantIDs: []report.ID{report.Today, report.Tomorrow, report.Daily}, wantCopies: []string{"today.md", "tomorrow.md", "daily-2026-05-31.md"}},
|
||||
{name: "evening", batch: BatchEvening, now: workflowTime("2026-05-29T18:00:00-05:00"), wantIDs: []report.ID{report.Tomorrow, report.Daily}, wantCopies: []string{"tomorrow.md", "daily-2026-05-31.md"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, false)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||
executor := newAssembledBatchExecutor()
|
||||
outputDir := filepath.Join(t.TempDir(), "output")
|
||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: test.batch, Now: test.now, OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||
Collector: collector, Executor: executor,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if collector.calls != 1 || result.Total != len(test.wantIDs) || result.Succeeded != len(test.wantIDs) || result.Failed != 0 {
|
||||
t.Fatalf("collection/summary = %d/%d/%d/%d", collector.calls, result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if len(executor.executeRequests) != len(test.wantIDs) || executor.maxActive != 1 {
|
||||
t.Fatalf("executor calls/max active = %d/%d, want %d/1", len(executor.executeRequests), executor.maxActive, len(test.wantIDs))
|
||||
}
|
||||
if len(executor.profileRequests) != 1 {
|
||||
t.Fatalf("profile inspections = %#v, want one shared profile inspection", executor.profileRequests)
|
||||
}
|
||||
for index, item := range result.Reports {
|
||||
if item.ReportID != test.wantIDs[index] || item.Status != "succeeded" {
|
||||
t.Fatalf("report %d = %s/%s, want %s/succeeded", index, item.ReportID, item.Status, test.wantIDs[index])
|
||||
}
|
||||
if executor.executeRequests[index].PromptID != item.PromptID {
|
||||
t.Fatalf("execution %d prompt = %q, want item prompt %q", index, executor.executeRequests[index].PromptID, item.PromptID)
|
||||
}
|
||||
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.ReportPath == "" || item.OutputPath == "" || item.MetadataPath == "" {
|
||||
t.Fatalf("successful report paths = %#v", item)
|
||||
}
|
||||
assertBatchItemMatchesMetadata(t, item)
|
||||
if filepath.Base(item.OutputPath) != test.wantCopies[index] {
|
||||
t.Fatalf("output copy = %q, want %q", item.OutputPath, test.wantCopies[index])
|
||||
}
|
||||
assertBatchPathsExist(t, item.DataPackagePath, item.PreparationPath, item.ExecutionPath, item.LLMDebugPath, item.ReportPath, item.OutputPath, item.MetadataPath)
|
||||
managed, readErr := os.ReadFile(item.ReportPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read managed report: %v", readErr)
|
||||
}
|
||||
copied, readErr := os.ReadFile(item.OutputPath)
|
||||
if readErr != nil || !bytes.Equal(managed, copied) {
|
||||
t.Fatalf("output copy mismatch/error = %v", readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedContinuesAfterCapacityRejection(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, true)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
collector := &workflowCollector{result: &collect.Result{Bundle: &bundle}}
|
||||
executor := newAssembledBatchExecutor()
|
||||
executor.failures[1] = promptexec.NewError(promptexec.Capacity, "capacity rejected", nil)
|
||||
notifier := &assembledBatchNotifier{}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||
OutputDir: filepath.Join(t.TempDir(), "output"), LLMDebugDir: filepath.Join(t.TempDir(), "debug"),
|
||||
Collector: collector, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if collector.calls != 1 || len(executor.executeRequests) != 3 || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("collection/execution/summary = %d/%d/%d/%d/%d", collector.calls, len(executor.executeRequests), result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 || result.Notification == nil || result.Notification.Status != "skipped" {
|
||||
t.Fatalf("notification state = reports %d batches %d result %#v", len(notifier.reportRequests), len(notifier.batchRequests), result.Notification)
|
||||
}
|
||||
for index, item := range result.Reports {
|
||||
if index == 1 {
|
||||
if item.ReportID != report.Tomorrow || item.Status != "failed" || !strings.Contains(item.Error, string(promptexec.Capacity)) {
|
||||
t.Fatalf("failed item = %#v", item)
|
||||
}
|
||||
if item.DataPackagePath == "" || item.PreparationPath == "" || item.ExecutionPath == "" || item.LLMDebugPath == "" || item.MetadataPath == "" || item.ReportPath != "" || item.OutputPath != "" {
|
||||
t.Fatalf("failed item reached paths = %#v", item)
|
||||
}
|
||||
assertBatchItemMatchesMetadata(t, item)
|
||||
continue
|
||||
}
|
||||
if item.Status != "succeeded" || item.ReportPath == "" || item.OutputPath == "" {
|
||||
t.Fatalf("continued item %d = %#v", index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedNotificationLifecycle(t *testing.T) {
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, false)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
notifier := &assembledBatchNotifier{}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||
})
|
||||
if err != nil || result.Notification != nil || result.Failed != 0 || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch disabled", func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, true)
|
||||
cfg.Notify.Distributor.Batch.Enabled = false
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
notifier := &assembledBatchNotifier{}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||
})
|
||||
if err != nil || result.Notification != nil || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 0 {
|
||||
t.Fatalf("result/error/requests = %#v/%v/%d/%d", result, err, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all success", func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, true)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||
RunID: "batch-notification-run", Status: "succeeded", UploadStatus: "accepted",
|
||||
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
}}
|
||||
outputDir := filepath.Join(t.TempDir(), "output")
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"), OutputDir: outputDir,
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 0 || result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.Path == "" || len(notifier.reportRequests) != 0 || len(notifier.batchRequests) != 1 {
|
||||
t.Fatalf("notification result/requests = %#v/%d/%d", result.Notification, len(notifier.reportRequests), len(notifier.batchRequests))
|
||||
}
|
||||
managedPaths := make(map[string]struct{}, len(result.Reports))
|
||||
for _, item := range result.Reports {
|
||||
managedPaths[item.ReportPath] = struct{}{}
|
||||
if item.NotificationPath != "" {
|
||||
t.Fatalf("report item contains per-report notification path: %#v", item)
|
||||
}
|
||||
}
|
||||
request := notifier.batchRequests[0]
|
||||
if len(request.IncludedReports) != len(result.Reports) {
|
||||
t.Fatalf("included reports = %d, want %d", len(request.IncludedReports), len(result.Reports))
|
||||
}
|
||||
for _, file := range request.Files {
|
||||
if _, ok := managedPaths[file.SourcePath]; !ok || strings.HasPrefix(file.SourcePath, outputDir+string(filepath.Separator)) || file.BundlePath == "" {
|
||||
t.Fatalf("notification file = %#v, want managed Markdown source", file)
|
||||
}
|
||||
}
|
||||
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||
if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-notification-run" || artifact.RunStatus == nil || len(artifact.Reports) != len(result.Reports) {
|
||||
t.Fatalf("notification artifact = %#v", artifact)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload failure", func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, true)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
notifier := &assembledBatchNotifier{batchErr: errors.New("batch upload rejected")}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Succeeded != 2 || result.Failed != 1 || result.Notification == nil || result.Notification.Status != "failed" || result.Notification.Path == "" {
|
||||
t.Fatalf("result = %#v, want successful reports and failed notification", result)
|
||||
}
|
||||
for _, item := range result.Reports {
|
||||
if item.Status != "succeeded" {
|
||||
t.Fatalf("report item = %#v, want success despite notification failure", item)
|
||||
}
|
||||
}
|
||||
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||
if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") {
|
||||
t.Fatalf("notification artifact = %#v", artifact)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status report", func(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, true)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31")
|
||||
notifier := &assembledBatchNotifier{batchResult: &NotificationResult{
|
||||
RunID: "batch-notification-run", Status: "accepted", UploadStatus: "accepted",
|
||||
StatusError: "status lookup unavailable", Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
||||
}}
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(), Notifier: notifier,
|
||||
})
|
||||
if err != nil || result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" {
|
||||
t.Fatalf("result/error = %#v/%v", result, err)
|
||||
}
|
||||
artifact := readBatchNotificationArtifact(t, result.Notification.Path)
|
||||
if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !bytes.Contains(artifact.RunStatus.Report, []byte("replace_older")) {
|
||||
t.Fatalf("notification artifact = %#v", artifact)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedKeepsDynamicDailyArtifactsDistinct(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, false)
|
||||
bundle := assembledBatchBundle(t, "2026-05-31", "2026-06-01")
|
||||
outputDir := filepath.Join(t.TempDir(), "output")
|
||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchEvening, Now: workflowTime("2026-05-29T18:00:00-05:00"), OutputDir: outputDir, LLMDebugDir: debugRoot,
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &bundle}}, Executor: newAssembledBatchExecutor(),
|
||||
})
|
||||
if err != nil || result.Failed != 0 || len(result.Reports) != 3 {
|
||||
t.Fatalf("result/error = %#v/%v", result, err)
|
||||
}
|
||||
seenRuns := make(map[string]struct{})
|
||||
seenDebug := make(map[string]struct{})
|
||||
dailyDates := make(map[string]BatchReportResult)
|
||||
for _, item := range result.Reports {
|
||||
if _, exists := seenRuns[item.RunID]; exists {
|
||||
t.Fatalf("duplicate run ID %q", item.RunID)
|
||||
}
|
||||
seenRuns[item.RunID] = struct{}{}
|
||||
if _, exists := seenDebug[item.LLMDebugPath]; exists {
|
||||
t.Fatalf("duplicate debug path %q", item.LLMDebugPath)
|
||||
}
|
||||
seenDebug[item.LLMDebugPath] = struct{}{}
|
||||
if item.ReportID == report.Daily {
|
||||
date := item.ValidPeriod.Start.In(mustLoadTestLocation(t, "America/Chicago")).Format("2006-01-02")
|
||||
dailyDates[date] = item
|
||||
}
|
||||
}
|
||||
for _, date := range []string{"2026-05-31", "2026-06-01"} {
|
||||
item, ok := dailyDates[date]
|
||||
if !ok {
|
||||
t.Fatalf("daily items = %#v, want %s", dailyDates, date)
|
||||
}
|
||||
if !strings.HasSuffix(item.RunID, "_daily_"+date) || item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") {
|
||||
t.Fatalf("daily identity/output = %q/%q", item.RunID, item.OutputPath)
|
||||
}
|
||||
wantDebugPrefix := filepath.Join(debugRoot, "daily", date, item.RunID)
|
||||
if item.LLMDebugPath != wantDebugPrefix {
|
||||
t.Fatalf("daily debug path = %q, want %q", item.LLMDebugPath, wantDebugPrefix)
|
||||
}
|
||||
assertBatchPathsExist(t, filepath.Join(item.LLMDebugPath, "preparation.json"), filepath.Join(item.LLMDebugPath, "execution.json"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedUsesPriorSnapshotsForPromptPackages(t *testing.T) {
|
||||
cfg := assembledBatchConfig(t, false)
|
||||
firstBundle := assembledBatchBundle(t, "2026-05-31")
|
||||
setWorkflowTemperatures(&firstBundle, 45)
|
||||
first, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:00:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &firstBundle}}, Executor: newAssembledBatchExecutor(),
|
||||
})
|
||||
if err != nil || first.Failed != 0 {
|
||||
t.Fatalf("first result/error = %#v/%v", first, err)
|
||||
}
|
||||
secondBundle := assembledBatchBundle(t, "2026-05-31")
|
||||
setWorkflowTemperatures(&secondBundle, 85)
|
||||
second, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg, Batch: BatchMorning, Now: workflowTime("2026-05-29T08:30:00-05:00"),
|
||||
Collector: &workflowCollector{result: &collect.Result{Bundle: &secondBundle}}, Executor: newAssembledBatchExecutor(),
|
||||
})
|
||||
if err != nil || second.Failed != 0 || len(second.Reports) != len(first.Reports) {
|
||||
t.Fatalf("second result/error = %#v/%v", second, err)
|
||||
}
|
||||
for _, item := range second.Reports {
|
||||
pkg := loadBatchDataPackage(t, item.DataPackagePath)
|
||||
if len(pkg.RecentChanges.Items) == 0 {
|
||||
t.Fatalf("report %s data package has no changes from prior snapshot", item.ReportID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assembledBatchConfig(t *testing.T, notify bool) config.Config {
|
||||
t.Helper()
|
||||
cfg := workflowConfig(t)
|
||||
cfg.Notify.Distributor.Enabled = notify
|
||||
cfg.Notify.Distributor.Batch.Enabled = notify
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assembledBatchBundle(t *testing.T, dates ...string) weatherdata.Bundle {
|
||||
t.Helper()
|
||||
bundle := workflowBundle(t)
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
for _, date := range dates {
|
||||
periods := fullDayPeriods(t, date, location)
|
||||
for index := range periods {
|
||||
temperature := float64(60 + index)
|
||||
periods[index].TemperatureF = &temperature
|
||||
periods[index].TextDescription = "Partly cloudy"
|
||||
}
|
||||
bundle.Hourly.Periods = append(bundle.Hourly.Periods, periods...)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
func generatedTextForPrompt(promptID string) string {
|
||||
switch promptID {
|
||||
case "weather.today_generated_text":
|
||||
return validTodayWorkflowJSON()
|
||||
case "weather.tomorrow_generated_text":
|
||||
return validTomorrowWorkflowJSON()
|
||||
case "weather.daily_generated_text":
|
||||
return validDailyWorkflowJSON()
|
||||
case "weather.hourly_generated_text":
|
||||
return validHourlyWorkflowJSON()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func assertBatchPathsExist(t *testing.T, paths ...string) {
|
||||
t.Helper()
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected path %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBatchItemMatchesMetadata(t *testing.T, item BatchReportResult) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(item.MetadataPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read metadata %q: %v", item.MetadataPath, err)
|
||||
}
|
||||
var metadata state.Metadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
t.Fatalf("decode metadata %q: %v", item.MetadataPath, err)
|
||||
}
|
||||
if item.ReportID != metadata.ReportID || item.RunID != metadata.RunID ||
|
||||
item.DataPackagePath != metadata.DataPackagePath || item.PreparationPath != metadata.PreparationPath ||
|
||||
item.ExecutionPath != metadata.ExecutionPath || item.ReportPath != metadata.RenderedReportPath ||
|
||||
item.NotificationPath != metadata.NotificationPath {
|
||||
t.Fatalf("batch item paths do not exactly match metadata: item=%#v metadata=%#v", item, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func readBatchNotificationArtifact(t *testing.T, path string) state.BatchDistributorNotificationArtifact {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read batch notification artifact: %v", err)
|
||||
}
|
||||
var artifact state.BatchDistributorNotificationArtifact
|
||||
if err := json.Unmarshal(data, &artifact); err != nil {
|
||||
t.Fatalf("decode batch notification artifact: %v", err)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
func loadBatchDataPackage(t *testing.T, path string) promptinput.Package {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read data package: %v", err)
|
||||
}
|
||||
pkg, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
@@ -18,20 +18,25 @@ func TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveRunActionConstructsOneExecutor(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
calls++
|
||||
return factoryExecutor{}, nil
|
||||
},
|
||||
}
|
||||
req, _, err := runner.resolveRunAction([]string{"morning", "--config", configPath, "--llm-debug-dir", "/tmp/debug"})
|
||||
if err != nil || calls != 1 || req.Executor == nil || req.LLMDebugDir != "/tmp/debug" {
|
||||
t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls)
|
||||
for _, command := range []string{"morning", "evening"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
executor := &factoryExecutor{}
|
||||
runner := Runner{
|
||||
Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)},
|
||||
ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
calls++
|
||||
return executor, nil
|
||||
},
|
||||
}
|
||||
req, _, err := runner.resolveRunAction([]string{command, "--config", configPath, "--llm-debug-dir", "/tmp/debug"})
|
||||
if err != nil || calls != 1 || req.Executor != executor || req.LLMDebugDir != "/tmp/debug" {
|
||||
t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user