1211 lines
42 KiB
Go
1211 lines
42 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
|
|
)
|
|
|
|
func TestFetchAndSaveBundle(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/observations":
|
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
|
case "/conditions/current":
|
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
|
case "/forecast/hourly":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
|
case "/forecast/narrative":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
|
case "/alerts/active":
|
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
|
case "/discussion":
|
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
|
case "/weatherstories/latest":
|
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
path := filepath.Join(t.TempDir(), "bundle.json")
|
|
|
|
bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path})
|
|
if err != nil {
|
|
t.Fatalf("FetchAndSaveBundle() error = %v", err)
|
|
}
|
|
if bundle.Hourly == nil {
|
|
t.Fatal("Hourly = nil, want fetched bundle")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read saved bundle: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"product": "hourly"`) {
|
|
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
|
}
|
|
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
|
|
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|
_, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()})
|
|
if err == nil {
|
|
t.Fatal("FetchAndSaveBundle() error = nil, want output path error")
|
|
}
|
|
if !strings.Contains(err.Error(), "output path") {
|
|
t.Fatalf("error = %q, want output path context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestGenerateBriefingWritesArtifact(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "daily.briefing.json")
|
|
|
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: path,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
|
}
|
|
if result.OutputPath != path {
|
|
t.Fatalf("OutputPath = %q, want %q", result.OutputPath, path)
|
|
}
|
|
if result.Package.Daily == nil {
|
|
t.Fatal("Daily = nil")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read briefing artifact: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"schemaVersion"`) || !strings.Contains(string(data), `"daily"`) {
|
|
t.Fatalf("briefing artifact missing expected fields:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestGenerateBriefingDefaultPath(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
|
|
result, err := GenerateBriefing(context.Background(), BriefingRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateBriefing() error = %v", err)
|
|
}
|
|
if !strings.HasSuffix(result.OutputPath, filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json")) {
|
|
t.Fatalf("OutputPath = %q, want deterministic daily briefing path", result.OutputPath)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{
|
|
Command: []string{"scriptorium", "render"},
|
|
Stdout: `{"prepared":true}`,
|
|
ExitCode: 0,
|
|
},
|
|
runResult: &scriptorium.RunResult{
|
|
Command: []string{"scriptorium", "run"},
|
|
Stderr: "wrote report",
|
|
ExitCode: 0,
|
|
OutputPath: "",
|
|
},
|
|
runBody: "# Daily Report\n\nRain this morning.\n",
|
|
}
|
|
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: outputPath,
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
if renderer.renderCalls != 1 {
|
|
t.Fatalf("render calls = %d, want 1", renderer.renderCalls)
|
|
}
|
|
if renderer.runCalls != 1 {
|
|
t.Fatalf("run calls = %d, want 1", renderer.runCalls)
|
|
}
|
|
if renderer.renderRequest.PromptID != "weather.daily_report" {
|
|
t.Fatalf("render PromptID = %q, want weather.daily_report", renderer.renderRequest.PromptID)
|
|
}
|
|
if renderer.renderRequest.DataPackagePath != result.DataPackagePath {
|
|
t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath)
|
|
}
|
|
if renderer.runRequest.DataPackagePath != result.DataPackagePath {
|
|
t.Fatalf("run DataPackagePath = %q, want managed path %q", renderer.runRequest.DataPackagePath, result.DataPackagePath)
|
|
}
|
|
if renderer.runRequest.OutputPath != result.ReportPath {
|
|
t.Fatalf("run OutputPath = %q, want managed report path %q", renderer.runRequest.OutputPath, result.ReportPath)
|
|
}
|
|
assertPathsExist(t, result.BriefingPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
|
data, err := os.ReadFile(result.DataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
|
}
|
|
var savedDataPackage struct {
|
|
Report struct {
|
|
CurrentLocalDate string `json:"currentLocalDate"`
|
|
} `json:"report"`
|
|
Briefing briefing.Package `json:"briefing"`
|
|
}
|
|
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
|
|
t.Fatalf("decode data package: %v", err)
|
|
}
|
|
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
|
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
|
}
|
|
location := savedDataPackage.Briefing.Metadata.Location
|
|
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
|
|
t.Fatalf("data package location = %#v, want configured prompt location", location)
|
|
}
|
|
current := savedDataPackage.Briefing.CurrentConditions
|
|
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
|
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
|
}
|
|
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
|
}
|
|
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
|
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
|
}
|
|
preflight, err := os.ReadFile(result.PreflightPath)
|
|
if err != nil {
|
|
t.Fatalf("read preflight: %v", err)
|
|
}
|
|
if !strings.Contains(string(preflight), `prepared`) {
|
|
t.Fatalf("preflight output missing render stdout:\n%s", string(preflight))
|
|
}
|
|
if result.Metadata.RunID != resolved.Metadata().RunID {
|
|
t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID)
|
|
}
|
|
if result.Metadata.BriefingPath != result.BriefingPath || result.Metadata.DataPackagePath != result.DataPackagePath {
|
|
t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata)
|
|
}
|
|
if result.Metadata.RenderedReportPath != result.ReportPath {
|
|
t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath)
|
|
}
|
|
if len(result.RecentChanges) != 0 {
|
|
t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges)
|
|
}
|
|
report, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("read report output: %v", err)
|
|
}
|
|
if !strings.Contains(string(report), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
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,
|
|
Renderer: renderer,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("GenerateReport() error = nil, want render error")
|
|
}
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
preflightPath := paths.Preflight
|
|
preflight, readErr := os.ReadFile(preflightPath)
|
|
if readErr != nil {
|
|
t.Fatalf("read failed preflight: %v", readErr)
|
|
}
|
|
if !strings.Contains(string(preflight), `"exitCode": 1`) {
|
|
t.Fatalf("failed preflight was not persisted:\n%s", string(preflight))
|
|
}
|
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
|
t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err)
|
|
}
|
|
if renderer.runCalls != 0 {
|
|
t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{
|
|
Stderr: "validation failed",
|
|
ExitCode: 2,
|
|
},
|
|
runErr: errors.New("scriptorium run exited with code 2: validation failed"),
|
|
runBody: "# Daily Report\n",
|
|
}
|
|
|
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: renderer,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("GenerateReport() error = nil, want run error")
|
|
}
|
|
if renderer.renderCalls != 1 || renderer.runCalls != 1 {
|
|
t.Fatalf("calls render=%d run=%d, want one of each", renderer.renderCalls, renderer.runCalls)
|
|
}
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
|
t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err)
|
|
}
|
|
if _, err := os.Stat(paths.RenderedReport); err != nil {
|
|
t.Fatalf("expected report from validation exit %q: %v", paths.RenderedReport, err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T04:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
|
}
|
|
priorBriefing := priorDailyBriefing(priorResolved)
|
|
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
|
if err != nil {
|
|
t.Fatalf("SaveBriefing() error = %v", err)
|
|
}
|
|
priorPaths, err := store.Paths(priorResolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
|
Briefing: priorBriefingPath,
|
|
Metadata: priorPaths.Metadata,
|
|
DataPackage: priorPaths.DataPackage,
|
|
Preflight: priorPaths.Preflight,
|
|
RenderedReport: priorPaths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
|
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Daily Report\n",
|
|
}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: currentResolved,
|
|
Renderer: renderer,
|
|
Store: store,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
if len(result.RecentChanges) == 0 {
|
|
t.Fatal("RecentChanges length = 0, want changes from prior snapshot")
|
|
}
|
|
data, err := os.ReadFile(result.DataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") {
|
|
t.Fatalf("data package missing recent changes:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportTomorrow,
|
|
}, mustParse("2026-05-29T18:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Tomorrow Planning Brief\n",
|
|
}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant)
|
|
}
|
|
if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" {
|
|
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate)
|
|
}
|
|
if result.Briefing.Daily.Planning == nil {
|
|
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
|
}
|
|
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
|
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
|
}
|
|
}
|
|
|
|
func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-30T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T17:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
|
}
|
|
priorBriefing := priorDailyBriefing(priorResolved)
|
|
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
|
if err != nil {
|
|
t.Fatalf("SaveBriefing() error = %v", err)
|
|
}
|
|
priorPaths, err := store.Paths(priorResolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
|
Briefing: priorBriefingPath,
|
|
Metadata: priorPaths.Metadata,
|
|
DataPackage: priorPaths.DataPackage,
|
|
Preflight: priorPaths.Preflight,
|
|
RenderedReport: priorPaths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
|
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportTomorrow,
|
|
}, mustParse("2026-05-29T18:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Tomorrow Planning Brief\n",
|
|
}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: currentResolved,
|
|
Renderer: renderer,
|
|
Store: store,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
if result.PriorSnapshot == nil {
|
|
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
|
}
|
|
if len(result.RecentChanges) == 0 {
|
|
t.Fatal("RecentChanges length = 0, want changes from compatible prior daily snapshot")
|
|
}
|
|
}
|
|
|
|
func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportThreeDay,
|
|
}, mustParse("2026-05-29T04:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
|
}
|
|
priorBriefing := priorThreeDayBriefing(priorResolved)
|
|
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
|
if err != nil {
|
|
t.Fatalf("SaveBriefing() error = %v", err)
|
|
}
|
|
priorPaths, err := store.Paths(priorResolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
|
Briefing: priorBriefingPath,
|
|
Metadata: priorPaths.Metadata,
|
|
DataPackage: priorPaths.DataPackage,
|
|
Preflight: priorPaths.Preflight,
|
|
RenderedReport: priorPaths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportThreeDay,
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# 3-Day Outlook\n",
|
|
}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: currentResolved,
|
|
Renderer: renderer,
|
|
Store: store,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
if result.Briefing.ThreeDay == nil {
|
|
t.Fatal("ThreeDay = nil")
|
|
}
|
|
if len(result.Briefing.ThreeDay.Days) != 3 {
|
|
t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days))
|
|
}
|
|
if renderer.renderRequest.PromptID != "weather.three_day_outlook" {
|
|
t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID)
|
|
}
|
|
if result.PriorSnapshot == nil {
|
|
t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot")
|
|
}
|
|
if len(result.RecentChanges) == 0 {
|
|
t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot")
|
|
}
|
|
}
|
|
|
|
func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportWeekend,
|
|
}, mustParse("2026-05-29T04:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
|
}
|
|
priorBriefing := priorWeekendBriefing(priorResolved)
|
|
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
|
if err != nil {
|
|
t.Fatalf("SaveBriefing() error = %v", err)
|
|
}
|
|
priorPaths, err := store.Paths(priorResolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
|
Briefing: priorBriefingPath,
|
|
Metadata: priorPaths.Metadata,
|
|
DataPackage: priorPaths.DataPackage,
|
|
Preflight: priorPaths.Preflight,
|
|
RenderedReport: priorPaths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportWeekend,
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Weekend Outlook\n",
|
|
}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: currentResolved,
|
|
Renderer: renderer,
|
|
Store: store,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
if result.Briefing.Weekend == nil {
|
|
t.Fatal("Weekend = nil")
|
|
}
|
|
if len(result.Briefing.Weekend.Days) != 3 {
|
|
t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days))
|
|
}
|
|
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
|
|
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
|
|
}
|
|
if result.PriorSnapshot == nil {
|
|
t.Fatal("PriorSnapshot = nil, want prior weekend snapshot")
|
|
}
|
|
if len(result.RecentChanges) == 0 {
|
|
t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot")
|
|
}
|
|
}
|
|
|
|
func TestGenerateStormReportWritesReport(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportStorm,
|
|
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
|
|
StormEnd: mustParse("2026-05-29T10:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Storm Report\n",
|
|
}
|
|
outputPath := filepath.Join(t.TempDir(), "storm.md")
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: outputPath,
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
if result.Briefing.Storm == nil {
|
|
t.Fatal("Storm = nil")
|
|
}
|
|
if renderer.renderRequest.PromptID != "weather.storm_report" {
|
|
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
|
|
}
|
|
if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 {
|
|
t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods))
|
|
}
|
|
if _, err := os.Stat(outputPath); err != nil {
|
|
t.Fatalf("expected requested report output %q: %v", outputPath, err)
|
|
}
|
|
data, err := os.ReadFile(result.DataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
|
|
t.Fatalf("data package missing storm content:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Daily Report\n",
|
|
}
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
|
|
records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1})
|
|
if err != nil {
|
|
t.Fatalf("InspectReports() error = %v", err)
|
|
}
|
|
if len(records) != 1 || records[0].RunID != result.Metadata.RunID {
|
|
t.Fatalf("records = %#v, want generated run", records)
|
|
}
|
|
metadata, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectMetadata() error = %v", err)
|
|
}
|
|
if metadata.BriefingPath != result.BriefingPath || metadata.DataPackagePath != result.DataPackagePath {
|
|
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
|
|
}
|
|
briefingPackage, err := InspectBriefing(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectBriefing() error = %v", err)
|
|
}
|
|
if briefingPackage.Metadata.RunID != result.Metadata.RunID {
|
|
t.Fatalf("briefing RunID = %q, want %q", briefingPackage.Metadata.RunID, result.Metadata.RunID)
|
|
}
|
|
dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectDataPackage() error = %v", err)
|
|
}
|
|
if dataPackage.RunID != result.Metadata.RunID {
|
|
t.Fatalf("data package RunID = %q, want %q", dataPackage.RunID, result.Metadata.RunID)
|
|
}
|
|
sources, err := InspectSources(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectSources() error = %v", err)
|
|
}
|
|
if len(sources.Sources) == 0 || len(sources.Warnings) == 0 {
|
|
t.Fatalf("sources = %#v, want provenance and warnings", sources)
|
|
}
|
|
}
|
|
|
|
func TestInspectPriorSnapshot(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
priorResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T04:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
|
}
|
|
currentResolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportDaily,
|
|
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate(current) error = %v", err)
|
|
}
|
|
renderer := &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: "# Daily Report\n",
|
|
}
|
|
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
|
|
t.Fatalf("GenerateReport(prior) error = %v", err)
|
|
}
|
|
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport(current) error = %v", err)
|
|
}
|
|
|
|
prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectPriorSnapshot() error = %v", err)
|
|
}
|
|
if prior == nil || prior.Metadata.RunID != priorResolved.Metadata().RunID {
|
|
t.Fatalf("prior = %#v, want previous generated run", prior)
|
|
}
|
|
}
|
|
|
|
func TestInspectMissingMetadata(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.Workspace.Root = t.TempDir()
|
|
|
|
_, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: "missing"})
|
|
if err == nil {
|
|
t.Fatal("InspectMetadata() error = nil, want missing metadata error")
|
|
}
|
|
if !strings.Contains(err.Error(), "metadata for run id") {
|
|
t.Fatalf("error = %q, want missing run id context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-29T18:00:00-05:00")
|
|
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportTomorrow,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
if resolved.Definition.ID != report.DailyTomorrow {
|
|
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
|
|
}
|
|
if resolved.Definition.PromptID != "weather.daily_report" {
|
|
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
|
}
|
|
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
|
|
t.Fatalf("valid start date = %s, want 2026-05-30", got)
|
|
}
|
|
}
|
|
|
|
func dailyBundleServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/observations":
|
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
|
case "/conditions/current":
|
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
|
case "/forecast/hourly":
|
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
|
case "/forecast/narrative":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`))
|
|
case "/alerts/active":
|
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
|
case "/discussion":
|
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
|
case "/weatherstories/latest":
|
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func TestResolveGenerateStorm(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-29T12:00:00-05:00")
|
|
start := mustParse("2026-05-29T18:00:00-05:00")
|
|
end := mustParse("2026-05-30T06:00:00-05:00")
|
|
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportStorm,
|
|
StormStart: start,
|
|
StormEnd: end,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
if resolved.Definition.ID != report.Storm {
|
|
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
|
|
}
|
|
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
|
|
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
|
|
}
|
|
}
|
|
|
|
func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
now := mustParse("2026-05-31T06:00:00-05:00")
|
|
|
|
resolved, err := ResolveBatch(BatchRequest{Config: cfg, Batch: BatchMorning}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveBatch() error = %v", err)
|
|
}
|
|
if len(resolved) != 2 {
|
|
t.Fatalf("resolved length = %d, want 2", len(resolved))
|
|
}
|
|
for _, item := range resolved {
|
|
if item.Definition.ID == report.Weekend {
|
|
t.Fatal("morning batch included weekend on Sunday")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
renderer := &selectiveRenderer{
|
|
failRenderPrompt: "weather.three_day_outlook",
|
|
runBody: "# Batch Report\n",
|
|
}
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg,
|
|
Batch: BatchMorning,
|
|
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
Renderer: renderer,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
|
}
|
|
|
|
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
|
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
|
}
|
|
if renderer.runCalls != 2 {
|
|
t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls)
|
|
}
|
|
var failedThreeDay bool
|
|
for _, item := range result.Reports {
|
|
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
|
|
failedThreeDay = true
|
|
}
|
|
if item.ReportID != report.ThreeDay && item.Status != "succeeded" {
|
|
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
|
}
|
|
}
|
|
if !failedThreeDay {
|
|
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports)
|
|
}
|
|
}
|
|
|
|
func TestRunBatchUsesOutputDirectory(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
outputDir := filepath.Join(t.TempDir(), "reports")
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg,
|
|
Batch: BatchEvening,
|
|
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
|
OutputDir: outputDir,
|
|
Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunBatchDetailed() error = %v", err)
|
|
}
|
|
if result.Failed != 0 || len(result.Reports) != 1 {
|
|
t.Fatalf("summary = %#v, want one successful report", result)
|
|
}
|
|
want := filepath.Join(outputDir, "tomorrow.md")
|
|
if result.Reports[0].OutputPath != want {
|
|
t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want)
|
|
}
|
|
if _, err := os.Stat(want); err != nil {
|
|
t.Fatalf("expected output copy %q: %v", want, err)
|
|
}
|
|
}
|
|
|
|
func mustParse(value string) time.Time {
|
|
parsed, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
|
|
t.Helper()
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
return cfg
|
|
}
|
|
|
|
func assertPathsExist(t *testing.T, paths ...string) {
|
|
t.Helper()
|
|
for _, path := range paths {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected artifact %q: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func priorDailyBriefing(resolved report.Resolved) briefing.Package {
|
|
low := 50.0
|
|
high := 58.0
|
|
precip := 10.0
|
|
return briefing.Package{
|
|
Metadata: briefing.Metadata{
|
|
SchemaVersion: briefing.SchemaVersion,
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
Variant: "today",
|
|
PromptID: resolved.Definition.PromptID,
|
|
GeneratedAt: resolved.GeneratedAt,
|
|
Units: "us",
|
|
Timezone: resolved.Timezone,
|
|
ValidPeriod: resolved.ValidPeriod,
|
|
},
|
|
Daily: &briefing.Daily{
|
|
BottomLine: briefing.BottomLine{
|
|
Temperature: forecast.Range{Min: &low, Max: &high},
|
|
MaxPrecipProbability: &forecast.TimedValue{
|
|
Value: precip,
|
|
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
|
},
|
|
},
|
|
Dayparts: []forecast.DaypartSummary{
|
|
{Name: "morning"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
|
|
high := 58.0
|
|
precip := 10.0
|
|
return briefing.Package{
|
|
Metadata: briefing.Metadata{
|
|
SchemaVersion: briefing.SchemaVersion,
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
PromptID: resolved.Definition.PromptID,
|
|
GeneratedAt: resolved.GeneratedAt,
|
|
Units: "us",
|
|
Timezone: resolved.Timezone,
|
|
ValidPeriod: resolved.ValidPeriod,
|
|
},
|
|
ThreeDay: &briefing.ThreeDay{
|
|
Days: []briefing.OutlookDay{
|
|
{
|
|
Date: "2026-05-29",
|
|
Temperature: forecast.Range{Max: &high},
|
|
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
Value: precip,
|
|
Time: resolved.ValidPeriod.Start.Add(6 * time.Hour),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func priorWeekendBriefing(resolved report.Resolved) briefing.Package {
|
|
high := 58.0
|
|
precip := 10.0
|
|
return briefing.Package{
|
|
Metadata: briefing.Metadata{
|
|
SchemaVersion: briefing.SchemaVersion,
|
|
RunID: resolved.Metadata().RunID,
|
|
ReportID: resolved.Definition.ID,
|
|
PromptID: resolved.Definition.PromptID,
|
|
GeneratedAt: resolved.GeneratedAt,
|
|
Units: "us",
|
|
Timezone: resolved.Timezone,
|
|
ValidPeriod: resolved.ValidPeriod,
|
|
},
|
|
Weekend: &briefing.Weekend{
|
|
Days: []briefing.OutlookDay{
|
|
{
|
|
Date: "2026-05-30",
|
|
Temperature: forecast.Range{Max: &high},
|
|
MaxPrecipitationProbability: &forecast.TimedValue{
|
|
Value: precip,
|
|
Time: resolved.ValidPeriod.Start.Add(12 * time.Hour),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type recordingRenderer struct {
|
|
renderCalls int
|
|
runCalls int
|
|
renderRequest scriptorium.RenderRequest
|
|
runRequest scriptorium.RunRequest
|
|
renderResult *scriptorium.RenderResult
|
|
runResult *scriptorium.RunResult
|
|
err error
|
|
runErr error
|
|
runBody string
|
|
}
|
|
|
|
type selectiveRenderer struct {
|
|
renderCalls int
|
|
runCalls int
|
|
failRenderPrompt string
|
|
runBody string
|
|
}
|
|
|
|
func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
|
r.renderCalls++
|
|
if req.PromptID == r.failRenderPrompt {
|
|
return &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, errors.New("render failed")
|
|
}
|
|
return &scriptorium.RenderResult{ExitCode: 0}, nil
|
|
}
|
|
|
|
func (r *selectiveRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
|
|
r.runCalls++
|
|
if r.runBody != "" {
|
|
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
|
|
}
|
|
|
|
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
|
|
r.renderCalls++
|
|
r.renderRequest = req
|
|
return r.renderResult, r.err
|
|
}
|
|
|
|
func (r *recordingRenderer) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
|
|
r.runCalls++
|
|
r.runRequest = req
|
|
if r.runBody != "" {
|
|
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if r.runResult != nil {
|
|
r.runResult.OutputPath = req.OutputPath
|
|
}
|
|
return r.runResult, r.runErr
|
|
}
|