2005 lines
80 KiB
Go
2005 lines
80 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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/module"
|
|
"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/timeutil"
|
|
)
|
|
|
|
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"}}`))
|
|
case "/outlooks/convective":
|
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
|
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 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")
|
|
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
store := &recordingStore{Store: filesystemStore}
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: outputPath,
|
|
Renderer: renderer,
|
|
Store: store,
|
|
})
|
|
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)
|
|
}
|
|
if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) {
|
|
t.Fatalf("store calls = %v, want prefix %s", store.calls, want)
|
|
}
|
|
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
|
snapshotData, err := os.ReadFile(result.ModuleSnapshotPath)
|
|
if err != nil {
|
|
t.Fatalf("read module snapshot: %v", err)
|
|
}
|
|
if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) {
|
|
t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData))
|
|
}
|
|
data, err := os.ReadFile(result.DataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
if !strings.HasSuffix(result.DataPackagePath, ".data_package.yaml") {
|
|
t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath)
|
|
}
|
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v2") ||
|
|
!strings.Contains(string(data), "recent_changes:") ||
|
|
!strings.Contains(string(data), "applicable_risk_products:") ||
|
|
!strings.Contains(string(data), "derived_summaries:") ||
|
|
!strings.Contains(string(data), "narrative_products:") ||
|
|
!strings.Contains(string(data), "raw_data:") ||
|
|
!strings.Contains(string(data), "current_conditions:") ||
|
|
!strings.Contains(string(data), "narrative_forecast:") ||
|
|
!strings.Contains(string(data), "hourly_forecast:") ||
|
|
!strings.Contains(string(data), "area_forecast_discussion:") ||
|
|
!strings.Contains(string(data), "spc_convective_outlooks:") {
|
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
|
}
|
|
if strings.Contains(string(data), "spc_convective_discussion:") {
|
|
t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data))
|
|
}
|
|
if strings.Contains(string(data), "source_warnings:") {
|
|
t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data))
|
|
}
|
|
riskIndex := strings.Index(string(data), " applicable_risk_products:")
|
|
derivedIndex := strings.Index(string(data), " derived_summaries:")
|
|
narrativeIndex := strings.Index(string(data), " narrative_products:")
|
|
rawIndex := strings.Index(string(data), " raw_data:")
|
|
alertIndex := strings.Index(string(data), " alert_digest:")
|
|
summaryIndex := strings.Index(string(data), " derived_daily_summary:")
|
|
storyIndex := strings.Index(string(data), " weather_story:")
|
|
currentIndex := strings.Index(string(data), " current_conditions:")
|
|
hourlyIndex := strings.Index(string(data), " hourly_forecast:")
|
|
outlookIndex := strings.Index(string(data), " spc_convective_outlooks:")
|
|
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
|
!(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) ||
|
|
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
|
t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data))
|
|
}
|
|
savedDataPackage, err := promptinput.LoadYAML(data)
|
|
if 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)
|
|
}
|
|
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
|
t.Fatal("data package metadata stanza missing")
|
|
}
|
|
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
|
|
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
|
|
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
|
|
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
|
|
}
|
|
current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any)
|
|
if !ok || current["condition_text"] != "Clear" {
|
|
t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"])
|
|
}
|
|
narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any)
|
|
if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") {
|
|
t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"])
|
|
}
|
|
hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any)
|
|
if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") {
|
|
t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"])
|
|
}
|
|
story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any)
|
|
if !ok || story["title"] != "Several Chances for Rain Through Monday" {
|
|
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"])
|
|
}
|
|
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.ModuleSnapshotPath != result.ModuleSnapshotPath || 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 TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
|
server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse)
|
|
cfg := dailyTestConfig(t, server)
|
|
|
|
result := generateDailyReportForTest(t, cfg)
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
|
|
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
|
|
}
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok {
|
|
t.Fatal("module snapshot missing spc_convective_discussion stanza")
|
|
}
|
|
data := readDataPackageForTest(t, result)
|
|
text := string(data)
|
|
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
|
|
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
|
|
}
|
|
for _, want := range []string{
|
|
" spc_convective_outlooks:",
|
|
" spc_convective_discussion:",
|
|
" included_because: categorical severity_rank >= 3",
|
|
" label_text: Slight Risk",
|
|
" period_begins:",
|
|
" period_ends:",
|
|
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("data package missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
|
if strings.Contains(text, omitted) {
|
|
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
|
|
}
|
|
}
|
|
|
|
riskIndex := strings.Index(text, " applicable_risk_products:")
|
|
alertIndex := strings.Index(text, " alert_digest:")
|
|
outlookIndex := strings.Index(text, " spc_convective_outlooks:")
|
|
derivedIndex := strings.Index(text, " derived_summaries:")
|
|
narrativeIndex := strings.Index(text, " narrative_products:")
|
|
forecastIndex := strings.Index(text, " narrative_forecast:")
|
|
afdIndex := strings.Index(text, " area_forecast_discussion:")
|
|
discussionIndex := strings.Index(text, " spc_convective_discussion:")
|
|
storyIndex := strings.Index(text, " weather_story:")
|
|
rawIndex := strings.Index(text, " raw_data:")
|
|
if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 ||
|
|
!(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) ||
|
|
!(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) {
|
|
t.Fatalf("data package category order is wrong:\n%s", text)
|
|
}
|
|
|
|
loaded, err := promptinput.LoadYAML(data)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAML() error = %v", err)
|
|
}
|
|
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
|
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
|
|
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
|
}
|
|
if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok {
|
|
t.Fatal("loaded package missing spc_convective_discussion stanza")
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) {
|
|
server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse)
|
|
cfg := dailyTestConfig(t, server)
|
|
|
|
result := generateDailyReportForTest(t, cfg)
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok {
|
|
t.Fatal("module snapshot missing spc_convective_outlooks stanza")
|
|
}
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok {
|
|
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
|
|
}
|
|
text := string(readDataPackageForTest(t, result))
|
|
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") {
|
|
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
|
|
}
|
|
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
|
if strings.Contains(text, omitted) {
|
|
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
|
|
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
|
|
}
|
|
if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") {
|
|
t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
|
|
server := nearTermBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: ReportNearTerm,
|
|
}, mustParse("2026-05-29T08:30:00-05:00"))
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
outputPath := filepath.Join(t.TempDir(), "near-term.md")
|
|
renderer := successfulRenderer("# Near-Term Report\n")
|
|
|
|
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.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
|
|
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
|
|
}
|
|
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
|
|
t.Fatalf("valid period start = %s, want rolling window start", got)
|
|
}
|
|
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
|
|
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
|
|
}
|
|
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
|
|
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
|
|
}
|
|
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
|
|
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
|
|
}
|
|
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
|
|
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
|
|
}
|
|
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
|
if result.OutputPath != outputPath {
|
|
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
|
|
}
|
|
copiedReport, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("read output copy: %v", err)
|
|
}
|
|
if !strings.Contains(string(copiedReport), "# Near-Term Report") {
|
|
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
|
|
}
|
|
|
|
wantModules := []module.ID{
|
|
module.Metadata,
|
|
module.CurrentConditions,
|
|
module.HourlyForecast,
|
|
module.PrecipTiming,
|
|
module.AlertDigest,
|
|
module.SPCConvectiveOutlooks,
|
|
module.AreaForecastDiscussion,
|
|
module.SPCConvectiveDiscussion,
|
|
module.WeatherStory,
|
|
}
|
|
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
|
|
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
|
|
}
|
|
|
|
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast")
|
|
if err != nil {
|
|
t.Fatalf("decode hourly forecast: %v", err)
|
|
}
|
|
if !ok || len(hourly.Periods) != 5 {
|
|
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly)
|
|
}
|
|
hourlyJSON := mustMarshalString(t, hourly)
|
|
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") {
|
|
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON)
|
|
}
|
|
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
|
|
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
|
|
}
|
|
|
|
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing")
|
|
if err != nil {
|
|
t.Fatalf("decode precip timing: %v", err)
|
|
}
|
|
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
|
|
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip)
|
|
}
|
|
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" {
|
|
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0])
|
|
}
|
|
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
|
|
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
|
|
}
|
|
|
|
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
|
|
if err != nil {
|
|
t.Fatalf("decode alert digest: %v", err)
|
|
}
|
|
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
|
|
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
|
|
}
|
|
|
|
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
|
|
if err != nil {
|
|
t.Fatalf("decode SPC outlooks: %v", err)
|
|
}
|
|
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" {
|
|
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks)
|
|
}
|
|
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
|
|
if err != nil {
|
|
t.Fatalf("decode SPC discussion: %v", err)
|
|
}
|
|
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
|
|
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
|
|
}
|
|
|
|
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion")
|
|
if err != nil {
|
|
t.Fatalf("decode AFD: %v", err)
|
|
}
|
|
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." {
|
|
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
|
}
|
|
if afd.Product != "" || afd.LongTerm != "" {
|
|
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
|
|
}
|
|
|
|
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
|
|
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items)
|
|
}
|
|
data := readDataPackageForTest(t, result)
|
|
text := string(data)
|
|
for _, want := range []string{
|
|
"id: near_term",
|
|
"prompt_id: weather.near_term_report",
|
|
"valid_period:",
|
|
"recent_changes:",
|
|
" items: []",
|
|
" alert_digest:",
|
|
" spc_convective_outlooks:",
|
|
" precip_timing:",
|
|
" area_forecast_discussion:",
|
|
" spc_convective_discussion:",
|
|
" weather_story:",
|
|
" current_conditions:",
|
|
" hourly_forecast:",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("data package missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
|
|
if strings.Contains(text, omitted) {
|
|
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text)
|
|
}
|
|
}
|
|
riskIndex := strings.Index(text, " applicable_risk_products:")
|
|
derivedIndex := strings.Index(text, " derived_summaries:")
|
|
narrativeIndex := strings.Index(text, " narrative_products:")
|
|
rawIndex := strings.Index(text, " raw_data:")
|
|
alertIndex := strings.Index(text, " alert_digest:")
|
|
precipIndex := strings.Index(text, " precip_timing:")
|
|
afdIndex := strings.Index(text, " area_forecast_discussion:")
|
|
currentIndex := strings.Index(text, " current_conditions:")
|
|
hourlyIndex := strings.Index(text, " hourly_forecast:")
|
|
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
|
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
|
t.Fatalf("data package category order is wrong:\n%s", text)
|
|
}
|
|
loaded, err := promptinput.LoadYAML(data)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAML() error = %v", err)
|
|
}
|
|
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
|
}
|
|
|
|
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(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)
|
|
}
|
|
notifier := &recordingNotifier{}
|
|
|
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: successfulRenderer("# Daily Report\n"),
|
|
Notifier: notifier,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
if len(notifier.requests) != 0 {
|
|
t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
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)
|
|
}
|
|
notifier := &recordingNotifier{
|
|
result: &NotificationResult{
|
|
RunID: "distributor-run",
|
|
Status: "succeeded",
|
|
UploadStatus: "accepted",
|
|
PipelineID: "reports",
|
|
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
|
|
},
|
|
}
|
|
outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
|
|
|
|
result, err := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
OutputPath: outputPath,
|
|
Renderer: successfulRenderer("# Daily Report\n"),
|
|
Notifier: notifier,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
if result.Notification == nil {
|
|
t.Fatal("Notification = nil, want notification result")
|
|
}
|
|
if result.Notification.RunID != "distributor-run" || result.Notification.Status != "succeeded" {
|
|
t.Fatalf("Notification = %#v, want succeeded distributor run", result.Notification)
|
|
}
|
|
if result.NotificationPath == "" || result.Metadata.NotificationPath != result.NotificationPath {
|
|
t.Fatalf("NotificationPath result=%q metadata=%q, want linked artifact", result.NotificationPath, result.Metadata.NotificationPath)
|
|
}
|
|
notificationData, err := os.ReadFile(result.NotificationPath)
|
|
if err != nil {
|
|
t.Fatalf("read notification artifact: %v", err)
|
|
}
|
|
var notificationArtifact state.DistributorNotificationArtifact
|
|
if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil {
|
|
t.Fatalf("decode notification artifact: %v", err)
|
|
}
|
|
wantBundlePaths := []string{
|
|
"2026-05-29/daily/2026-05-29-daily-" + result.Metadata.RunID + ".md",
|
|
}
|
|
if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") {
|
|
t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact)
|
|
}
|
|
if len(notifier.requests) != 1 {
|
|
t.Fatalf("notification requests = %d, want 1", len(notifier.requests))
|
|
}
|
|
req := notifier.requests[0]
|
|
if req.ReportPath != result.ReportPath {
|
|
t.Fatalf("notification ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath)
|
|
}
|
|
if req.ReportPath == outputPath {
|
|
t.Fatalf("notification used output copy %q, want managed report path", outputPath)
|
|
}
|
|
if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") {
|
|
t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths)
|
|
}
|
|
if req.PipelineID != "weatherreporter.daily" {
|
|
t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID)
|
|
}
|
|
if req.BundleID != "weatherreporter.home.daily_today" {
|
|
t.Fatalf("notification BundleID = %q, want default template", req.BundleID)
|
|
}
|
|
if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID {
|
|
t.Fatalf("IdempotencyKey = %q, want per-run key", req.IdempotencyKey)
|
|
}
|
|
if req.RunID != result.Metadata.RunID {
|
|
t.Fatalf("notification RunID = %q, want report run id %q", req.RunID, result.Metadata.RunID)
|
|
}
|
|
if !req.CreatedAt.Equal(result.Metadata.GeneratedAt) {
|
|
t.Fatalf("notification CreatedAt = %s, want generated at %s", req.CreatedAt, result.Metadata.GeneratedAt)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
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)
|
|
}
|
|
notifier := &recordingNotifier{err: errors.New("upload rejected")}
|
|
|
|
store, err := state.NewFilesystemStore(cfg.Workspace)
|
|
if err != nil {
|
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
|
}
|
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: successfulRenderer("# Daily Report\n"),
|
|
Store: store,
|
|
Notifier: notifier,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("GenerateReport() error = nil, want notification error")
|
|
}
|
|
if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") {
|
|
t.Fatalf("error = %q, want notification context", err.Error())
|
|
}
|
|
if len(notifier.requests) != 1 {
|
|
t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests))
|
|
}
|
|
paths, pathErr := store.Paths(resolved)
|
|
if pathErr != nil {
|
|
t.Fatalf("Paths() error = %v", pathErr)
|
|
}
|
|
notificationData, readErr := os.ReadFile(paths.Notification)
|
|
if readErr != nil {
|
|
t.Fatalf("read notification artifact after failure: %v", readErr)
|
|
}
|
|
var notification state.DistributorNotificationArtifact
|
|
if err := json.Unmarshal(notificationData, ¬ification); err != nil {
|
|
t.Fatalf("decode notification artifact: %v", err)
|
|
}
|
|
if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") {
|
|
t.Fatalf("notification failure artifact = %+v, want failed upload context", notification)
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
tests := []struct {
|
|
name string
|
|
renderer Renderer
|
|
}{
|
|
{
|
|
name: "Render",
|
|
renderer: &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"},
|
|
err: errors.New("render failed"),
|
|
},
|
|
},
|
|
{
|
|
name: "Run",
|
|
renderer: &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 2, Stderr: "run failed"},
|
|
runErr: errors.New("run failed"),
|
|
runBody: "# Daily Report\n",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := dailyTestConfig(t, server)
|
|
cfg.Workspace.Root = t.TempDir()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
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)
|
|
}
|
|
notifier := &recordingNotifier{}
|
|
|
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: tt.renderer,
|
|
Notifier: notifier,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("GenerateReport() error = nil, want generation error")
|
|
}
|
|
if len(notifier.requests) != 0 {
|
|
t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = ""
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
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)
|
|
}
|
|
notifier := &recordingNotifier{}
|
|
|
|
_, err = GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: successfulRenderer("# Daily Report\n"),
|
|
Notifier: notifier,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("GenerateReport() error = nil, want fetch setup error")
|
|
}
|
|
if len(notifier.requests) != 0 {
|
|
t.Fatalf("notification requests = %#v, want none after fetch failure", notifier.requests)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
|
|
|
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.Metadata.ReportID != report.DailyTomorrow || result.Metadata.Variant != "tomorrow" {
|
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant)
|
|
}
|
|
dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary")
|
|
if err != nil {
|
|
t.Fatalf("decode daily summary: %v", err)
|
|
}
|
|
if !ok || dailySummary["date"] != "Saturday, May 30, 2026" {
|
|
t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary)
|
|
}
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok {
|
|
t.Fatal("tomorrow_planning stanza missing")
|
|
}
|
|
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)
|
|
}
|
|
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
|
|
|
|
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)
|
|
}
|
|
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
|
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)
|
|
}
|
|
|
|
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
|
if err != nil {
|
|
t.Fatalf("decode daypart summaries: %v", err)
|
|
}
|
|
if !ok || len(dayparts) == 0 {
|
|
t.Fatalf("daypart summaries = %#v, want 3-day module content", dayparts)
|
|
}
|
|
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)
|
|
}
|
|
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
|
|
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)
|
|
}
|
|
|
|
dayparts, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daypart_summaries")
|
|
if err != nil {
|
|
t.Fatalf("decode daypart summaries: %v", err)
|
|
}
|
|
if !ok || len(dayparts) == 0 {
|
|
t.Fatalf("daypart summaries = %#v, want weekend module content", dayparts)
|
|
}
|
|
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 renderer.renderRequest.PromptID != "weather.storm_report" {
|
|
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
|
|
}
|
|
if _, ok := result.ModuleSnapshot.LookupStanza("precip_timing"); !ok {
|
|
t.Fatal("precip_timing stanza missing")
|
|
}
|
|
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), "id: storm") || !strings.Contains(string(data), "prompt_id: weather.storm_report") || !strings.Contains(string(data), "precip_timing:") {
|
|
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.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath {
|
|
t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata)
|
|
}
|
|
moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID})
|
|
if err != nil {
|
|
t.Fatalf("InspectModules() error = %v", err)
|
|
}
|
|
if moduleSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(moduleSnapshot.Outputs) == 0 {
|
|
t.Fatalf("module snapshot = %#v, want persisted outputs", moduleSnapshot)
|
|
}
|
|
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 {
|
|
t.Fatalf("sources = %#v, want provenance", sources)
|
|
}
|
|
if len(sources.Warnings) != 0 {
|
|
t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings)
|
|
}
|
|
}
|
|
|
|
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-29T08:00:00-05:00")
|
|
|
|
tests := []struct {
|
|
name string
|
|
kind ReportKind
|
|
wantID report.ID
|
|
wantPrompt string
|
|
wantStart string
|
|
wantEnd string
|
|
requestDate time.Time
|
|
}{
|
|
{
|
|
name: "tomorrow",
|
|
kind: ReportTomorrow,
|
|
wantID: report.DailyTomorrow,
|
|
wantPrompt: "weather.daily_report",
|
|
wantStart: "2026-05-30T00:00:00-05:00",
|
|
wantEnd: "2026-05-31T00:00:00-05:00",
|
|
requestDate: time.Time{},
|
|
},
|
|
{
|
|
name: "near-term",
|
|
kind: ReportNearTerm,
|
|
wantID: report.NearTerm,
|
|
wantPrompt: "weather.near_term_report",
|
|
wantStart: "2026-05-29T08:00:00-05:00",
|
|
wantEnd: "2026-05-29T14:00:00-05:00",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resolved, err := ResolveGenerate(GenerateRequest{
|
|
Config: cfg,
|
|
Report: tt.kind,
|
|
Date: tt.requestDate,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
|
}
|
|
if resolved.Definition.ID != tt.wantID {
|
|
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
|
|
}
|
|
if resolved.Definition.PromptID != tt.wantPrompt {
|
|
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
|
|
}
|
|
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
|
|
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
|
|
}
|
|
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
|
|
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
if err := os.WriteFile(path, []byte(`
|
|
reports:
|
|
tomorrow:
|
|
deterministic_modules:
|
|
- metadata
|
|
- alert_digest
|
|
- tomorrow_planning
|
|
`), 0o600); err != nil {
|
|
t.Fatalf("write config fixture: %v", err)
|
|
}
|
|
cfg, err := config.LoadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadFile() error = %v", err)
|
|
}
|
|
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)
|
|
}
|
|
want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning}
|
|
if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") {
|
|
t.Fatalf("ModuleIDs() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func moduleIDsForTest(ids []module.ID) []string {
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, string(id))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
|
|
ids := make([]module.ID, 0, len(snapshot.Outputs))
|
|
for _, output := range snapshot.Outputs {
|
|
ids = append(ids, output.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func mustMarshalString(t *testing.T, value any) string {
|
|
t.Helper()
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatalf("marshal value: %v", err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func dailyBundleServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
|
|
}
|
|
|
|
func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *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"}}`))
|
|
case "/outlooks/convective":
|
|
_, _ = w.Write([]byte(convectiveResponse))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func nearTermBundleServer(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-29T13:20:00Z","conditionCode":3}}`))
|
|
case "/conditions/current":
|
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
|
|
case "/forecast/hourly":
|
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
|
|
case "/forecast/narrative":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
|
|
case "/alerts/active":
|
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
|
|
case "/discussion":
|
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`))
|
|
case "/weatherstories/latest":
|
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`))
|
|
case "/outlooks/convective":
|
|
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
|
|
|
|
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
|
|
|
|
const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
|
|
|
|
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 TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
|
server := dailyBundleServer(t)
|
|
cfg := config.Defaults()
|
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
|
cfg.Workspace.Root = t.TempDir()
|
|
cfg.Notify.Distributor.Enabled = true
|
|
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
|
|
notifier := &recordingNotifier{
|
|
errByReport: map[report.ID]error{
|
|
report.ThreeDay: errors.New("distributor unavailable"),
|
|
},
|
|
}
|
|
|
|
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
|
Config: cfg,
|
|
Batch: BatchMorning,
|
|
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
|
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
|
Notifier: notifier,
|
|
})
|
|
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 len(notifier.requests) != 3 {
|
|
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
|
|
}
|
|
var failedThreeDay bool
|
|
for _, item := range result.Reports {
|
|
if item.ReportID == report.ThreeDay {
|
|
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
|
|
failedThreeDay = true
|
|
}
|
|
if item.NotificationStatus != "failed" {
|
|
t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus)
|
|
}
|
|
if item.NotificationPipelineID != "weatherreporter.three-day" {
|
|
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID)
|
|
}
|
|
if !strings.Contains(item.NotificationError, "distributor unavailable") {
|
|
t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError)
|
|
}
|
|
continue
|
|
}
|
|
if item.Status != "succeeded" {
|
|
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
|
}
|
|
if item.NotificationStatus != "accepted" {
|
|
t.Fatalf("report %s notification status = %q, want accepted", item.ReportID, item.NotificationStatus)
|
|
}
|
|
if item.NotificationPipelineID == "" {
|
|
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
|
|
}
|
|
}
|
|
if !failedThreeDay {
|
|
t.Fatalf("reports = %#v, want notification failure on 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 generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
|
t.Helper()
|
|
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 := GenerateReport(context.Background(), ReportRequest{
|
|
Config: cfg,
|
|
Resolved: resolved,
|
|
Renderer: successfulRenderer("# Daily Report\n"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("GenerateReport() error = %v", err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(result.DataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read data package: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
|
|
t.Helper()
|
|
for name, value := range values {
|
|
if name == "metadata" {
|
|
continue
|
|
}
|
|
assertNoStaleIntervalKeys(t, "briefing."+name, value)
|
|
}
|
|
}
|
|
|
|
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
|
|
t.Helper()
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range typed {
|
|
switch key {
|
|
case "start_time", "end_time", "period", "start", "end":
|
|
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
|
|
}
|
|
assertNoStaleIntervalKeys(t, path+"."+key, child)
|
|
}
|
|
case []any:
|
|
for i, child := range typed {
|
|
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) {
|
|
t.Helper()
|
|
moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot)
|
|
if err != nil {
|
|
t.Fatalf("SaveModuleSnapshot() error = %v", err)
|
|
}
|
|
paths, err := store.Paths(resolved)
|
|
if err != nil {
|
|
t.Fatalf("Paths() error = %v", err)
|
|
}
|
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{
|
|
ModuleSnapshot: moduleSnapshotPath,
|
|
Metadata: paths.Metadata,
|
|
DataPackage: paths.DataPackage,
|
|
Preflight: paths.Preflight,
|
|
RenderedReport: paths.RenderedReport,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("SaveMetadata() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot {
|
|
t.Helper()
|
|
low := 50
|
|
high := 58
|
|
precip := 10
|
|
snapshot, err := module.NewSnapshot([]module.Output{
|
|
{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{
|
|
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
|
"low_temp_f": low,
|
|
"high_temp_f": high,
|
|
"daily_precipitation_probability": precip,
|
|
}},
|
|
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
|
"morning": map[string]any{
|
|
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
|
"period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
|
"period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
|
"temp_range_f": "50-58",
|
|
},
|
|
}},
|
|
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
|
|
"max_pop_percent": precip,
|
|
"max_pop_time": "6 AM",
|
|
}},
|
|
{ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSnapshot() error = %v", err)
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
|
t.Helper()
|
|
precip := 10
|
|
snapshot, err := module.NewSnapshot([]module.Output{
|
|
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
|
date + "_morning": map[string]any{
|
|
"date": date,
|
|
"period_begins": date + " at 6:00 AM",
|
|
"period_ends": date + " at 10:00 AM",
|
|
"temp_range_f": "50-58",
|
|
"max_pop_percent": precip,
|
|
"max_pop_time": "6 AM",
|
|
},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSnapshot() error = %v", err)
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func appBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
|
return briefing.Metadata{
|
|
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,
|
|
}
|
|
}
|
|
|
|
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 recordingStore struct {
|
|
state.Store
|
|
calls []string
|
|
}
|
|
|
|
func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
|
|
s.calls = append(s.calls, "module_snapshot")
|
|
return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot)
|
|
}
|
|
|
|
func (s *recordingStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
|
|
s.calls = append(s.calls, "data_package")
|
|
return s.Store.SaveDataPackage(ctx, resolved, pkg)
|
|
}
|
|
|
|
func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Resolved, artifact state.PreflightArtifact) (string, error) {
|
|
s.calls = append(s.calls, "preflight")
|
|
return s.Store.SavePreflight(ctx, resolved, artifact)
|
|
}
|
|
|
|
func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) {
|
|
s.calls = append(s.calls, "prepare_report")
|
|
return s.Store.PrepareRenderedReport(ctx, resolved)
|
|
}
|
|
|
|
func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) {
|
|
s.calls = append(s.calls, "metadata")
|
|
return s.Store.SaveMetadata(ctx, metadata)
|
|
}
|
|
|
|
func successfulRenderer(body string) *recordingRenderer {
|
|
return &recordingRenderer{
|
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
|
runBody: body,
|
|
}
|
|
}
|
|
|
|
type selectiveRenderer struct {
|
|
renderCalls int
|
|
runCalls int
|
|
failRenderPrompt string
|
|
runBody string
|
|
}
|
|
|
|
type recordingNotifier struct {
|
|
requests []NotificationRequest
|
|
result *NotificationResult
|
|
err error
|
|
errByReport map[report.ID]error
|
|
}
|
|
|
|
func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) {
|
|
n.requests = append(n.requests, req)
|
|
if err := n.errByReport[req.ReportID]; err != nil {
|
|
return nil, err
|
|
}
|
|
if n.err != nil {
|
|
return nil, n.err
|
|
}
|
|
if n.result != nil {
|
|
result := *n.result
|
|
if result.BundleID == "" {
|
|
result.BundleID = req.BundleID
|
|
}
|
|
if result.IdempotencyKey == "" {
|
|
result.IdempotencyKey = req.IdempotencyKey
|
|
}
|
|
if result.PipelineID == "" {
|
|
result.PipelineID = req.PipelineID
|
|
}
|
|
return &result, nil
|
|
}
|
|
return &NotificationResult{
|
|
PipelineID: req.PipelineID,
|
|
BundleID: req.BundleID,
|
|
IdempotencyKey: req.IdempotencyKey,
|
|
Status: "accepted",
|
|
UploadStatus: "accepted",
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|