2026-05-29 17:06:39 +00:00
package app
import (
"context"
2026-05-29 19:57:08 -05:00
"encoding/json"
2026-05-29 17:32:57 +00:00
"errors"
2026-06-12 12:00:40 -05:00
"fmt"
2026-05-29 17:06:39 +00:00
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
2026-05-29 17:17:50 +00:00
"time"
2026-05-29 17:06:39 +00:00
2026-05-29 17:32:57 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
2026-05-29 17:53:03 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
2026-06-17 15:46:01 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
2026-05-29 17:06:39 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
2026-06-09 20:51:22 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
2026-06-09 21:08:17 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
2026-05-29 17:17:50 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
2026-05-29 17:40:38 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
2026-06-09 21:20:53 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
2026-05-29 17:06:39 +00:00
)
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" :
2026-05-29 23:29:04 -05:00
_ , _ = 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."}}} ` ) )
2026-05-30 07:47:49 -05:00
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"}} ` ) )
2026-06-12 14:51:54 +00:00
case "/outlooks/convective" :
_ , _ = w . Write ( [ ] byte ( ` { "data": { "asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}} ` ) )
2026-05-29 17:06:39 +00:00
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 ) )
}
2026-05-30 07:47:49 -05:00
if ! strings . Contains ( string ( data ) , ` "title": "Several Chances for Rain Through Monday" ` ) {
t . Fatalf ( "saved bundle missing weather story title:\n%s" , string ( data ) )
}
2026-05-29 17:06:39 +00:00
}
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 ( ) )
}
}
2026-05-29 17:17:50 +00:00
2026-06-17 15:46:01 +00:00
func TestGenerateUsesProvidedCollector ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . BaseURL = ""
cfg . Workspace . Root = t . TempDir ( )
collector := & recordingCollector { err : errors . New ( "provided collector failed" ) }
err := Generate ( context . Background ( ) , GenerateRequest {
Config : cfg ,
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
} )
if err == nil {
t . Fatal ( "Generate() error = nil, want collector error" )
}
if ! strings . Contains ( err . Error ( ) , "provided collector failed" ) {
t . Fatalf ( "Generate() error = %q, want provided collector error" , err . Error ( ) )
}
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want 1" , len ( collector . requests ) )
}
if collector . requests [ 0 ] . Config . WeatherAPI . BaseURL != "" {
t . Fatalf ( "collector base URL = %q, want request config" , collector . requests [ 0 ] . Config . WeatherAPI . BaseURL )
}
}
2026-06-17 15:50:04 +00:00
func TestGenerateCollectsOnceForSingleReport ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyWorkspaceConfig ( t , server )
collection := collectionForTest ( t , cfg )
collector := & recordingCollector { result : & collection }
markerPath := filepath . Join ( t . TempDir ( ) , "scriptorium-called" )
binaryPath := filepath . Join ( t . TempDir ( ) , "scriptorium" )
script := fmt . Sprintf ( "#!/bin/sh\nprintf called > %q\nprintf render failed >&2\nexit 1\n" , markerPath )
if err := os . WriteFile ( binaryPath , [ ] byte ( script ) , 0 o755 ) ; err != nil {
t . Fatalf ( "write scriptorium marker script: %v" , err )
}
cfg . Scriptorium . Binary = binaryPath
err := Generate ( context . Background ( ) , GenerateRequest {
Config : cfg ,
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
} )
if err == nil {
t . Fatal ( "Generate() error = nil, want render error" )
}
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want 1" , len ( collector . requests ) )
}
if _ , statErr := os . Stat ( markerPath ) ; statErr != nil {
t . Fatalf ( "scriptorium marker stat error = %v, want report execution reached renderer" , statErr )
}
}
2026-06-17 15:46:01 +00:00
func TestGenerateCollectionFailureStopsBeforeReportExecution ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . BaseURL = ""
cfg . Workspace . Root = t . TempDir ( )
markerPath := filepath . Join ( t . TempDir ( ) , "scriptorium-called" )
binaryPath := filepath . Join ( t . TempDir ( ) , "scriptorium" )
script := fmt . Sprintf ( "#!/bin/sh\nprintf called > %q\nexit 0\n" , markerPath )
if err := os . WriteFile ( binaryPath , [ ] byte ( script ) , 0 o755 ) ; err != nil {
t . Fatalf ( "write scriptorium marker script: %v" , err )
}
cfg . Scriptorium . Binary = binaryPath
collector := & recordingCollector { err : errors . New ( "collection unavailable" ) }
err := Generate ( context . Background ( ) , GenerateRequest {
Config : cfg ,
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
} )
if err == nil {
t . Fatal ( "Generate() error = nil, want collector error" )
}
if _ , statErr := os . Stat ( markerPath ) ; ! errors . Is ( statErr , os . ErrNotExist ) {
t . Fatalf ( "scriptorium marker stat error = %v, want marker absent after collection failure" , statErr )
}
}
2026-05-29 20:44:25 +00:00
func TestGenerateReportWritesReportAndPreflight ( t * testing . T ) {
2026-05-29 17:32:57 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 17:32:57 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 17:32:57 +00:00
renderer := & recordingRenderer {
2026-05-29 17:47:42 +00:00
renderResult : & scriptorium . RenderResult {
2026-05-29 17:32:57 +00:00
Command : [ ] string { "scriptorium" , "render" } ,
Stdout : ` { "prepared":true} ` ,
ExitCode : 0 ,
} ,
2026-05-29 17:47:42 +00:00
runResult : & scriptorium . RunResult {
Command : [ ] string { "scriptorium" , "run" } ,
Stderr : "wrote report" ,
ExitCode : 0 ,
OutputPath : "" ,
} ,
2026-06-15 16:43:06 +00:00
structuredRunResult : & scriptorium . StructuredRunResult {
Command : [ ] string { "scriptorium" , "run" } ,
Stderr : "wrote generated text" ,
ExitCode : 0 ,
} ,
2026-05-29 17:47:42 +00:00
runBody : "# Daily Report\n\nRain this morning.\n" ,
2026-05-29 17:32:57 +00:00
}
2026-05-29 17:47:42 +00:00
outputPath := filepath . Join ( t . TempDir ( ) , "daily.md" )
2026-06-16 15:30:23 +00:00
store := recordingFilesystemStore ( t , cfg )
2026-05-29 17:32:57 +00:00
2026-05-29 20:44:25 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-05-29 17:47:42 +00:00
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-05-29 17:47:42 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
2026-06-09 21:12:13 +00:00
Store : store ,
2026-05-29 17:32:57 +00:00
} )
if err != nil {
2026-05-29 20:44:25 +00:00
t . Fatalf ( "GenerateReport() error = %v" , err )
2026-05-29 17:32:57 +00:00
}
2026-05-29 17:47:42 +00:00
if renderer . renderCalls != 1 {
t . Fatalf ( "render calls = %d, want 1" , renderer . renderCalls )
}
2026-06-15 16:43:06 +00:00
if renderer . structuredRunCalls != 1 {
t . Fatalf ( "structured run calls = %d, want 1" , renderer . structuredRunCalls )
2026-05-29 17:32:57 +00:00
}
2026-06-15 16:43:06 +00:00
if renderer . runCalls != 0 {
t . Fatalf ( "markdown run calls = %d, want none" , renderer . runCalls )
}
if renderer . renderRequest . PromptID != "weather.daily_generated_text" {
t . Fatalf ( "render PromptID = %q, want weather.daily_generated_text" , renderer . renderRequest . PromptID )
2026-05-29 17:32:57 +00:00
}
2026-05-29 17:47:42 +00:00
if renderer . renderRequest . DataPackagePath != result . DataPackagePath {
t . Fatalf ( "render DataPackagePath = %q, want managed path %q" , renderer . renderRequest . DataPackagePath , result . DataPackagePath )
2026-05-29 17:32:57 +00:00
}
2026-06-15 16:43:06 +00:00
if renderer . structuredRunRequest . DataPackagePath != result . DataPackagePath {
t . Fatalf ( "structured run DataPackagePath = %q, want managed path %q" , renderer . structuredRunRequest . DataPackagePath , result . DataPackagePath )
2026-05-29 17:47:42 +00:00
}
2026-06-15 16:43:06 +00:00
if renderer . structuredRunRequest . OutputPath != result . GeneratedTextRawPath {
t . Fatalf ( "structured run OutputPath = %q, want raw generated text path %q" , renderer . structuredRunRequest . OutputPath , result . GeneratedTextRawPath )
2026-05-29 17:47:42 +00:00
}
2026-06-15 16:43:06 +00:00
if got , want := strings . Join ( store . calls , "," ) , "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata" ; ! strings . HasPrefix ( got , want ) {
2026-06-09 21:12:13 +00:00
t . Fatalf ( "store calls = %v, want prefix %s" , store . calls , want )
}
2026-06-15 16:43:06 +00:00
assertPathsExist ( t , result . ModuleSnapshotPath , result . DataPackagePath , result . PreflightPath , result . GeneratedTextRawPath , result . GeneratedTextResultPath , result . GeneratedTextPath , result . RenderContextPath , result . ReportPath , result . MetadataPath , outputPath )
2026-06-09 20:58:35 +00:00
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 ) )
}
2026-06-15 20:32:12 +00:00
for _ , want := range [ ] string { ` "condition_text_lower" ` , ` "hour_label" ` , ` "text_description_lower" ` , ` "mention_precipitation" ` } {
if ! strings . Contains ( string ( snapshotData ) , want ) {
t . Fatalf ( "module snapshot missing rich helper field %q:\n%s" , want , string ( snapshotData ) )
}
}
2026-06-15 20:38:56 +00:00
for _ , want := range [ ] string { ` "temperature_phrase_f" ` , ` "dominant_condition_lower" ` , ` "dominant_condition_display" ` , ` "max_pop_time_label" ` } {
if ! strings . Contains ( string ( snapshotData ) , want ) {
t . Fatalf ( "module snapshot missing rich daypart helper field %q:\n%s" , want , string ( snapshotData ) )
}
}
2026-05-29 17:47:42 +00:00
data , err := os . ReadFile ( result . DataPackagePath )
2026-05-29 17:32:57 +00:00
if err != nil {
t . Fatalf ( "read data package: %v" , err )
}
2026-06-09 21:08:17 +00:00
if ! strings . HasSuffix ( result . DataPackagePath , ".data_package.yaml" ) {
t . Fatalf ( "DataPackagePath = %q, want YAML data package path" , result . DataPackagePath )
2026-05-29 17:32:57 +00:00
}
2026-06-15 20:27:28 +00:00
if ! strings . Contains ( string ( data ) , "schema_version: weatherreporter.data_package.v3" ) ||
2026-06-09 21:08:17 +00:00
! strings . Contains ( string ( data ) , "recent_changes:" ) ||
2026-06-10 11:50:22 -05:00
! 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:" ) ||
2026-06-09 21:08:17 +00:00
! strings . Contains ( string ( data ) , "current_conditions:" ) ||
2026-06-10 08:24:50 -05:00
! strings . Contains ( string ( data ) , "narrative_forecast:" ) ||
2026-06-10 10:00:36 -05:00
! strings . Contains ( string ( data ) , "hourly_forecast:" ) ||
2026-06-12 15:14:09 +00:00
! strings . Contains ( string ( data ) , "area_forecast_discussion:" ) ||
! strings . Contains ( string ( data ) , "spc_convective_outlooks:" ) {
2026-06-09 21:08:17 +00:00
t . Fatalf ( "data package missing expected content:\n%s" , string ( data ) )
2026-05-29 19:57:08 -05:00
}
2026-06-12 15:14:09 +00:00
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 ) )
}
2026-06-10 08:24:50 -05:00
if strings . Contains ( string ( data ) , "source_warnings:" ) {
t . Fatalf ( "data package has source warnings, want none for complete fetched sources:\n%s" , string ( data ) )
}
2026-06-10 11:50:22 -05:00
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:" )
2026-06-12 15:14:09 +00:00
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 ||
2026-06-10 11:50:22 -05:00
! ( riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex ) ||
2026-06-12 15:14:09 +00:00
! ( riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex ) {
2026-06-10 11:50:22 -05:00
t . Fatalf ( "data package grouping is wrong, want categorized prompt stanzas:\n%s" , string ( data ) )
2026-06-10 08:24:50 -05:00
}
2026-06-09 21:08:17 +00:00
savedDataPackage , err := promptinput . LoadYAML ( data )
if err != nil {
2026-05-29 19:57:08 -05:00
t . Fatalf ( "decode data package: %v" , err )
}
2026-05-29 23:29:04 -05:00
if savedDataPackage . Report . CurrentLocalDate != "2026-05-29" {
t . Fatalf ( "data package currentLocalDate = %q, want 2026-05-29" , savedDataPackage . Report . CurrentLocalDate )
}
2026-06-09 21:08:17 +00:00
if _ , ok := savedDataPackage . Briefing . Values [ "metadata" ] ; ! ok {
t . Fatal ( "data package metadata stanza missing" )
2026-05-29 19:57:08 -05:00
}
2026-06-12 12:00:40 -05:00
assertNoStaleModuleIntervalKeys ( t , savedDataPackage . Briefing . Values )
2026-06-12 15:14:09 +00:00
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" ] )
}
2026-06-09 21:08:17 +00:00
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" ] )
2026-05-29 20:04:27 -05:00
}
2026-06-15 20:32:12 +00:00
for _ , omitted := range [ ] string { "condition_text_lower" , "wind_direction_text" } {
if _ , ok := current [ omitted ] ; ok {
t . Fatalf ( "data package current conditions contains helper field %q: %#v" , omitted , current )
}
}
2026-06-10 08:24:50 -05:00
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" ] )
}
2026-06-10 10:00:36 -05:00
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" ] )
}
2026-06-15 20:32:12 +00:00
periods , ok := hourly [ "periods" ] . ( [ ] any )
if ! ok || len ( periods ) == 0 {
t . Fatalf ( "data package hourly periods = %#v, want prompt period rows" , hourly [ "periods" ] )
}
firstPeriod , ok := periods [ 0 ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "data package hourly first period = %#v, want mapping" , periods [ 0 ] )
}
for _ , omitted := range [ ] string { "hour_label" , "text_description_lower" , "mention_precipitation" } {
if _ , ok := firstPeriod [ omitted ] ; ok {
t . Fatalf ( "data package hourly period contains helper field %q: %#v" , omitted , firstPeriod )
}
}
2026-06-15 20:38:56 +00:00
dayparts , ok := savedDataPackage . Briefing . Values [ "derived_daypart_summaries" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "data package daypart summaries = %#v, want daypart map" , savedDataPackage . Briefing . Values [ "derived_daypart_summaries" ] )
}
morning , ok := dayparts [ "morning" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "data package morning daypart = %#v, want daypart map" , dayparts [ "morning" ] )
}
if morning [ "max_pop_time" ] != "6:00 AM" {
t . Fatalf ( "data package morning max_pop_time = %#v, want friendly label" , morning [ "max_pop_time" ] )
}
for _ , omitted := range [ ] string { "temperature_phrase_f" , "dominant_condition_lower" , "dominant_condition_display" , "max_pop_time_label" } {
if _ , ok := morning [ omitted ] ; ok {
t . Fatalf ( "data package morning daypart contains helper field %q: %#v" , omitted , morning )
}
}
2026-06-09 21:08:17 +00:00
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" ] )
2026-05-30 07:47:49 -05:00
}
2026-05-29 19:42:07 -05:00
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 ) )
}
2026-05-29 17:32:57 +00:00
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 ) )
}
2026-05-29 17:40:38 +00:00
if result . Metadata . RunID != resolved . Metadata ( ) . RunID {
t . Fatalf ( "metadata RunID = %q, want %q" , result . Metadata . RunID , resolved . Metadata ( ) . RunID )
}
2026-06-09 21:33:34 +00:00
if result . Metadata . ModuleSnapshotPath != result . ModuleSnapshotPath || result . Metadata . DataPackagePath != result . DataPackagePath {
2026-05-29 17:40:38 +00:00
t . Fatalf ( "metadata does not link artifact paths: %#v" , result . Metadata )
}
2026-05-29 17:47:42 +00:00
if result . Metadata . RenderedReportPath != result . ReportPath {
t . Fatalf ( "metadata rendered report path = %q, want %q" , result . Metadata . RenderedReportPath , result . ReportPath )
}
2026-05-29 17:53:03 +00:00
if len ( result . RecentChanges ) != 0 {
t . Fatalf ( "RecentChanges = %#v, want none without prior snapshot" , result . RecentChanges )
}
2026-05-29 17:47:42 +00:00
report , err := os . ReadFile ( outputPath )
if err != nil {
t . Fatalf ( "read report output: %v" , err )
}
2026-06-15 16:43:06 +00:00
if ! strings . Contains ( string ( report ) , "# Friday's Weather" ) {
2026-05-29 17:47:42 +00:00
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
2026-05-29 17:32:57 +00:00
}
2026-06-15 20:42:48 +00:00
func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages ( t * testing . T ) {
server := dailyBundleServer ( t )
tests := [ ] struct {
name string
kind ReportKind
date time . Time
now time . Time
prompt string
} {
{
name : "today" ,
kind : ReportToday ,
date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
prompt : "weather.today_generated_text" ,
} ,
{
name : "tomorrow" ,
kind : ReportTomorrow ,
now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
prompt : "weather.tomorrow_generated_text" ,
} ,
{
name : "daily" ,
kind : ReportDaily ,
date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
prompt : "weather.daily_generated_text" ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-15 20:42:48 +00:00
Report : tt . kind ,
Date : tt . date ,
2026-06-16 15:30:23 +00:00
} , tt . now . Format ( time . RFC3339 ) )
renderer := successfulGeneratedTextRenderer ( "" )
2026-06-15 20:42:48 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
2026-06-15 20:42:48 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if renderer . renderCalls != 1 || renderer . structuredRunCalls != 1 || renderer . runCalls != 0 {
t . Fatalf ( "renderer calls render=%d structured=%d run=%d, want generated-template workflow" , renderer . renderCalls , renderer . structuredRunCalls , renderer . runCalls )
}
if renderer . renderRequest . PromptID != tt . prompt || renderer . structuredRunRequest . PromptID != tt . prompt {
t . Fatalf ( "prompt IDs render=%q structured=%q, want %q" , renderer . renderRequest . PromptID , renderer . structuredRunRequest . PromptID , tt . prompt )
}
if renderer . renderRequest . DataPackagePath != result . DataPackagePath {
t . Fatalf ( "render DataPackagePath = %q, want managed path %q" , renderer . renderRequest . DataPackagePath , result . DataPackagePath )
}
if renderer . structuredRunRequest . DataPackagePath != result . DataPackagePath {
t . Fatalf ( "structured run DataPackagePath = %q, want managed path %q" , renderer . structuredRunRequest . DataPackagePath , result . DataPackagePath )
}
assertRichPromptHelperArtifacts ( t , result )
assertCuratedPromptDataPackage ( t , result )
} )
}
}
2026-06-12 15:14:09 +00:00
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:" ,
2026-06-12 12:00:40 -05:00
" included_because: categorical severity_rank >= 3" ,
2026-06-12 15:14:09 +00:00
" label_text: Slight Risk" ,
2026-06-12 12:00:40 -05:00
" period_begins:" ,
" period_ends:" ,
2026-06-12 15:14:09 +00:00
" 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 )
}
}
2026-06-12 12:00:40 -05:00
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 )
}
}
2026-06-12 15:14:09 +00:00
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 )
}
2026-06-12 12:00:40 -05:00
assertNoStaleModuleIntervalKeys ( t , loaded . Briefing . Values )
2026-06-12 15:14:09 +00:00
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 ) )
2026-06-12 12:00:40 -05:00
if ! strings . Contains ( text , " spc_convective_outlooks:" ) || ! strings . Contains ( text , " label_text: Marginal Risk" ) {
2026-06-12 15:14:09 +00:00
t . Fatalf ( "data package missing lower-risk SPC outlook:\n%s" , text )
}
2026-06-12 12:00:40 -05:00
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 )
}
}
2026-06-12 15:14:09 +00:00
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 )
}
}
2026-06-14 05:01:26 +00:00
func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow ( t * testing . T ) {
server := hourlyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := hourlyTestConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest { Report : ReportHourly } , "2026-05-29T08:30:00-05:00" )
2026-06-14 04:35:52 +00:00
if resolved . Definition . GenerationMode != report . GenerationModeGeneratedTextTemplate {
t . Fatalf ( "GenerationMode = %q, want generated text template" , resolved . Definition . GenerationMode )
2026-06-12 17:40:54 +00:00
}
2026-06-16 15:30:23 +00:00
store := recordingFilesystemStore ( t , cfg )
2026-06-14 05:01:26 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult {
Command : [ ] string { "scriptorium" , "render" } ,
Stdout : ` { "prepared":true} ` ,
ExitCode : 0 ,
} ,
structuredRunResult : & scriptorium . StructuredRunResult {
Command : [ ] string { "scriptorium" , "run" } ,
Stderr : "wrote generated text" ,
ExitCode : 0 ,
} ,
structuredRunBody : ` {
"summary" : " Storm chances increase through late morning. " ,
2026-06-14 12:54:32 -05:00
"forecast_discussion" : "A front will keep the region unsettled." ,
"precipitation_timing" : "A cold front is moving into the region." ,
2026-06-14 05:01:26 +00:00
"confidence" : "Medium"
} ` ,
}
2026-06-12 17:40:54 +00:00
2026-06-14 05:01:26 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
Store : store ,
2026-06-14 04:35:52 +00:00
} )
2026-06-14 05:01:26 +00:00
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if renderer . renderCalls != 1 {
t . Fatalf ( "render calls = %d, want 1" , renderer . renderCalls )
}
if renderer . structuredRunCalls != 1 {
t . Fatalf ( "structured run calls = %d, want 1" , renderer . structuredRunCalls )
}
if renderer . runCalls != 0 {
t . Fatalf ( "markdown run calls = %d, want none" , renderer . runCalls )
}
if renderer . structuredRunRequest . OutputPath != result . GeneratedTextRawPath {
t . Fatalf ( "structured run OutputPath = %q, want %q" , renderer . structuredRunRequest . OutputPath , result . GeneratedTextRawPath )
}
if renderer . structuredRunRequest . DataPackagePath != result . DataPackagePath {
t . Fatalf ( "structured run DataPackagePath = %q, want %q" , renderer . structuredRunRequest . DataPackagePath , result . DataPackagePath )
}
2026-06-14 05:08:26 +00:00
if got , want := strings . Join ( store . calls , "," ) , "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata" ; got != want {
2026-06-14 05:01:26 +00:00
t . Fatalf ( "store calls = %v, want %s" , store . calls , want )
}
assertPathsExist ( t ,
result . ModuleSnapshotPath ,
result . DataPackagePath ,
result . PreflightPath ,
result . GeneratedTextRawPath ,
result . GeneratedTextResultPath ,
result . GeneratedTextPath ,
result . RenderContextPath ,
result . ReportPath ,
result . MetadataPath ,
)
raw , err := os . ReadFile ( result . GeneratedTextRawPath )
if err != nil {
t . Fatalf ( "read raw generated text: %v" , err )
}
if ! strings . Contains ( string ( raw ) , ` "summary": " Storm chances increase through late morning. " ` ) {
t . Fatalf ( "raw generated text was not preserved:\n%s" , string ( raw ) )
}
normalized , err := os . ReadFile ( result . GeneratedTextPath )
if err != nil {
t . Fatalf ( "read validated generated text: %v" , err )
}
2026-06-14 12:54:32 -05:00
if string ( normalized ) != ` { "summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"} ` {
2026-06-14 05:01:26 +00:00
t . Fatalf ( "validated generated text = %s, want normalized JSON" , normalized )
}
renderContext , err := os . ReadFile ( result . RenderContextPath )
if err != nil {
t . Fatalf ( "read render context: %v" , err )
}
2026-06-14 08:57:53 -05:00
for _ , want := range [ ] string {
` "Report": { ` ,
` "Title": "Hourly Report" ` ,
` "LocationName": "Brentwood, MO" ` ,
` "GeneratedText": { ` ,
` "Modules": { ` ,
` "CurrentConditions": { ` ,
` "HourlyForecast": { ` ,
` "Collected": { ` ,
` "Derived": { ` ,
} {
if ! strings . Contains ( string ( renderContext ) , want ) {
t . Fatalf ( "render context missing %q:\n%s" , want , string ( renderContext ) )
}
2026-06-14 05:01:26 +00:00
}
reportData , err := os . ReadFile ( result . ReportPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
reportText := string ( reportData )
for _ , want := range [ ] string {
"# Hourly Report" ,
"Storm chances increase through late morning." ,
2026-06-16 21:42:20 -05:00
"## Alert Digest" ,
2026-06-16 21:09:17 -05:00
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings." ,
2026-06-14 12:54:32 -05:00
"## Precipitation Timing" ,
"A cold front is moving into the region." ,
"A front will keep the region unsettled." ,
2026-06-14 05:01:26 +00:00
} {
if ! strings . Contains ( reportText , want ) {
t . Fatalf ( "rendered hourly report missing %q:\n%s" , want , reportText )
}
}
if result . OutputPath != result . ReportPath {
t . Fatalf ( "OutputPath = %q, want managed report path %q" , result . OutputPath , result . ReportPath )
}
if result . StructuredRunResult == nil || result . StructuredRunResult . OutputPath != result . GeneratedTextRawPath {
t . Fatalf ( "StructuredRunResult = %#v, want captured structured run result" , result . StructuredRunResult )
}
if result . RunResult != nil {
t . Fatalf ( "RunResult = %#v, want nil for generated-text template workflow" , result . RunResult )
}
if len ( result . RecentChanges ) != 0 {
t . Fatalf ( "RecentChanges = %#v, want none for hourly report" , result . RecentChanges )
}
if result . Metadata . GeneratedTextSchemaID != "hourly" ||
result . Metadata . GeneratedTextRawPath != result . GeneratedTextRawPath ||
result . Metadata . GeneratedTextResultPath != result . GeneratedTextResultPath ||
result . Metadata . GeneratedTextPath != result . GeneratedTextPath ||
result . Metadata . RenderContextPath != result . RenderContextPath ||
result . Metadata . RenderedReportPath != result . ReportPath {
t . Fatalf ( "metadata generated-text links = %#v, want saved artifact links" , result . Metadata )
}
metadataData , err := os . ReadFile ( result . MetadataPath )
if err != nil {
t . Fatalf ( "read metadata: %v" , err )
}
if ! strings . Contains ( string ( metadataData ) , ` "generatedTextSchemaId": "hourly" ` ) ||
! strings . Contains ( string ( metadataData ) , result . GeneratedTextRawPath ) ||
! strings . Contains ( string ( metadataData ) , result . RenderContextPath ) {
t . Fatalf ( "metadata JSON missing generated-text links:\n%s" , string ( metadataData ) )
2026-06-12 17:40:54 +00:00
}
}
2026-06-14 05:13:40 +00:00
func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport ( t * testing . T ) {
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
2026-06-16 15:30:23 +00:00
notifier . result = successfulNotificationResult ( )
renderer := successfulGeneratedTextRenderer ( validHourlyGeneratedTextJSON ( ) )
2026-06-14 05:13:40 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:13:40 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if result . OutputPath != outputPath {
t . Fatalf ( "OutputPath = %q, want requested copy %q" , result . OutputPath , outputPath )
}
assertPathsExist ( t , result . ReportPath , outputPath , result . NotificationPath )
reportData , err := os . ReadFile ( result . ReportPath )
if err != nil {
t . Fatalf ( "read managed report: %v" , err )
}
copyData , err := os . ReadFile ( outputPath )
if err != nil {
t . Fatalf ( "read output copy: %v" , err )
}
if string ( copyData ) != string ( reportData ) {
t . Fatalf ( "output copy differs from managed report" )
}
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 report path %q" , req . ReportPath , result . ReportPath )
}
if req . ReportPath == outputPath {
t . Fatalf ( "notification used output copy %q, want managed report path" , outputPath )
}
2026-06-20 02:55:06 +00:00
wantBundlePaths := [ ] string { "hourly/index.md" }
2026-06-14 05:13:40 +00:00
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) {
t . Fatalf ( "notification BundlePaths = %#v, want %#v" , req . BundlePaths , wantBundlePaths )
}
if req . PipelineID != "weatherreporter.hourly" {
t . Fatalf ( "notification PipelineID = %q, want weatherreporter.hourly" , req . PipelineID )
}
if req . BundleID != "weatherreporter.home.hourly" {
t . Fatalf ( "notification BundleID = %q, want weatherreporter.home.hourly" , req . BundleID )
}
if req . IdempotencyKey != req . BundleID + "." + result . Metadata . RunID {
t . Fatalf ( "notification IdempotencyKey = %q, want per-run key" , req . IdempotencyKey )
}
if result . Notification == nil || result . Notification . RunID != "distributor-run" {
t . Fatalf ( "Notification = %#v, want distributor result" , result . Notification )
}
if result . Metadata . NotificationPath != result . NotificationPath {
t . Fatalf ( "metadata NotificationPath = %q, want %q" , result . Metadata . NotificationPath , result . 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 , & notificationArtifact ) ; err != nil {
t . Fatalf ( "decode notification artifact: %v" , err )
}
if notificationArtifact . SourcePath != result . ReportPath || strings . Join ( notificationArtifact . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) || notificationArtifact . RunStatus == nil {
t . Fatalf ( "notification artifact = %#v, want managed source and run status" , notificationArtifact )
}
}
2026-06-15 14:58:36 +00:00
func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
2026-06-15 14:58:36 +00:00
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg . Notify . Distributor . BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg . Notify . Distributor . IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
2026-06-16 15:30:23 +00:00
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-15 14:58:36 +00:00
Report : ReportToday ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-15 14:58:36 +00:00
outputPath := filepath . Join ( t . TempDir ( ) , "today-copy.md" )
notifier := & recordingNotifier {
2026-06-16 15:30:23 +00:00
result : successfulNotificationResult ( ) ,
2026-06-15 14:58:36 +00:00
}
2026-06-16 15:30:23 +00:00
renderer := successfulGeneratedTextRenderer ( validTodayGeneratedTextJSON ( ) )
2026-06-15 14:58:36 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-15 14:58:36 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if result . OutputPath != outputPath {
t . Fatalf ( "OutputPath = %q, want requested copy %q" , result . OutputPath , outputPath )
}
assertPathsExist ( t , result . ReportPath , outputPath , result . NotificationPath )
reportData , err := os . ReadFile ( result . ReportPath )
if err != nil {
t . Fatalf ( "read managed report: %v" , err )
}
copyData , err := os . ReadFile ( outputPath )
if err != nil {
t . Fatalf ( "read output copy: %v" , err )
}
if string ( copyData ) != string ( reportData ) {
t . Fatalf ( "output copy differs from managed report" )
}
if len ( notifier . requests ) != 1 {
t . Fatalf ( "notification requests = %d, want 1" , len ( notifier . requests ) )
}
req := notifier . requests [ 0 ]
if req . ReportID != report . Today {
t . Fatalf ( "notification ReportID = %q, want today" , req . ReportID )
}
if req . ReportPath != result . ReportPath {
t . Fatalf ( "notification ReportPath = %q, want managed report path %q" , req . ReportPath , result . ReportPath )
}
if req . ReportPath == outputPath {
t . Fatalf ( "notification used output copy %q, want managed report path" , outputPath )
}
if req . PipelineID != "weatherreporter.today.today" {
t . Fatalf ( "PipelineID = %q, want Today report and artifact values" , req . PipelineID )
}
if req . BundleID != "today.today.md.today" {
t . Fatalf ( "BundleID = %q, want Today artifact group, output name, and report id" , req . BundleID )
}
2026-06-20 02:55:06 +00:00
wantBundlePaths := [ ] string {
"daily/2026-05-29/" + result . Metadata . RunID + ".md" ,
"daily/2026-05-29/index.md" ,
"today/index.md" ,
}
2026-06-15 14:58:36 +00:00
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) {
t . Fatalf ( "BundlePaths = %#v, want %#v" , req . BundlePaths , wantBundlePaths )
}
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 , & notificationArtifact ) ; err != nil {
t . Fatalf ( "decode notification artifact: %v" , err )
}
if notificationArtifact . ReportID != report . Today ||
notificationArtifact . PipelineID != "weatherreporter.today.today" ||
notificationArtifact . BundleID != "today.today.md.today" ||
notificationArtifact . SourcePath != result . ReportPath ||
strings . Join ( notificationArtifact . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) ||
notificationArtifact . RunStatus == nil ||
! strings . Contains ( string ( notificationArtifact . RunStatus . Report ) , "replace_older" ) {
t . Fatalf ( "notification artifact = %#v, want Today managed-source notification" , notificationArtifact )
}
}
2026-06-14 05:13:40 +00:00
func TestGenerateHourlyReportNotificationFailureFailsReport ( t * testing . T ) {
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
notifier . err = errors . New ( "upload rejected" )
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult { ExitCode : 0 } ,
structuredRunBody : validHourlyGeneratedTextJSON ( ) ,
}
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:13:40 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
if err == nil {
t . Fatal ( "GenerateReport() error = nil, want notification error" )
}
var notificationErr * NotificationError
if ! errors . As ( err , & notificationErr ) {
t . Fatalf ( "GenerateReport() error = %T %v, want NotificationError" , err , err )
}
if ! strings . Contains ( err . Error ( ) , ` notify report "hourly" ` ) || ! strings . Contains ( err . Error ( ) , "upload rejected" ) {
t . Fatalf ( "error = %q, want hourly notification context" , err . Error ( ) )
}
if len ( notifier . requests ) != 1 {
t . Fatalf ( "notification requests = %d, want 1" , len ( notifier . requests ) )
}
paths := hourlyArtifactPaths ( t , store , resolved )
assertPathsExist ( t , paths . RenderedReport , outputPath , paths . Metadata , paths . Notification )
if notifier . requests [ 0 ] . ReportPath != paths . RenderedReport {
t . Fatalf ( "notification ReportPath = %q, want managed report path %q" , notifier . requests [ 0 ] . ReportPath , paths . RenderedReport )
}
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 , & notification ) ; err != nil {
t . Fatalf ( "decode notification artifact: %v" , err )
}
if notification . Status != "failed" || ! strings . Contains ( notification . Error , "upload rejected" ) || notification . SourcePath != paths . RenderedReport {
t . Fatalf ( "notification failure artifact = %+v, want failed managed-source context" , notification )
}
}
2026-06-15 12:28:54 +00:00
func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports ( t * testing . T ) {
t . Run ( "Markdown" , func ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyTestConfig ( t , server )
cfg . Workspace . Root = t . TempDir ( )
resolved , err := ResolveGenerate ( GenerateRequest {
Config : cfg ,
2026-06-15 16:43:06 +00:00
Report : ReportThreeDay ,
2026-06-15 12:28:54 +00:00
} , mustParse ( "2026-05-29T05:00:00-05:00" ) )
if err != nil {
t . Fatalf ( "ResolveGenerate() error = %v" , err )
}
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : successfulRenderer ( "# 3-Day Outlook\n" ) ,
2026-06-15 12:28:54 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
saved := readMetadataForTest ( t , result . MetadataPath )
if saved . RenderedReportPath != result . ReportPath || saved . NotificationPath != "" {
t . Fatalf ( "saved metadata = %#v, want final rendered path without notification" , saved )
}
if saved . GeneratedTextSchemaID != "" || saved . GeneratedTextPath != "" || saved . RenderContextPath != "" {
t . Fatalf ( "saved markdown metadata has generated-text fields: %#v" , saved )
}
} )
t . Run ( "GeneratedTextTemplate" , func ( t * testing . T ) {
server := hourlyBundleServer ( t )
cfg := hourlyGeneratedTextConfig ( t , server )
cfg . Notify . Distributor . Enabled = false
resolved , store , _ , _ := resolveHourlyGeneratedTextFixture ( t , cfg )
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult { ExitCode : 0 } ,
structuredRunBody : validHourlyGeneratedTextJSON ( ) ,
}
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
Store : store ,
2026-06-15 12:28:54 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
saved := readMetadataForTest ( t , result . MetadataPath )
if saved . RenderedReportPath != result . ReportPath || saved . NotificationPath != "" {
t . Fatalf ( "saved metadata = %#v, want final rendered path without notification" , saved )
}
if saved . GeneratedTextSchemaID != "hourly" ||
saved . GeneratedTextRawPath != result . GeneratedTextRawPath ||
saved . GeneratedTextResultPath != result . GeneratedTextResultPath ||
saved . GeneratedTextPath != result . GeneratedTextPath ||
saved . RenderContextPath != result . RenderContextPath {
t . Fatalf ( "saved generated-text metadata = %#v, want generated-text artifact links" , saved )
}
} )
}
2026-06-14 23:49:18 +00:00
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
2026-06-14 23:49:18 +00:00
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg . Notify . Distributor . BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg . Notify . Distributor . IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
2026-06-16 15:30:23 +00:00
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-14 23:49:18 +00:00
Report : ReportTomorrow ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T18:00:00-05:00" )
2026-06-14 23:49:18 +00:00
notifier := & recordingNotifier { }
2026-06-16 15:30:23 +00:00
renderer := successfulGeneratedTextRenderer ( validTomorrowGeneratedTextJSON ( ) )
2026-06-14 23:49:18 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
Notifier : notifier ,
2026-06-14 23:49:18 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if len ( notifier . requests ) != 1 {
t . Fatalf ( "notification requests = %d, want 1" , len ( notifier . requests ) )
}
req := notifier . requests [ 0 ]
if req . ReportID != report . Tomorrow {
t . Fatalf ( "notification ReportID = %q, want tomorrow" , req . ReportID )
}
if req . PipelineID != "weatherreporter.tomorrow.tomorrow" {
t . Fatalf ( "PipelineID = %q, want report/artifact group values" , req . PipelineID )
}
if req . BundleID != "tomorrow.tomorrow.md.tomorrow" {
t . Fatalf ( "BundleID = %q, want artifact group, batch output name, and report id" , req . BundleID )
}
2026-06-20 02:55:06 +00:00
wantBundlePaths := [ ] string {
"daily/2026-05-30/" + result . Metadata . RunID + ".md" ,
"daily/2026-05-30/index.md" ,
"tomorrow/index.md" ,
}
2026-06-14 23:49:18 +00:00
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) {
t . Fatalf ( "BundlePaths = %#v, want %#v" , req . BundlePaths , wantBundlePaths )
}
if req . ReportPath != result . ReportPath {
t . Fatalf ( "ReportPath = %q, want managed path %q" , req . ReportPath , result . ReportPath )
}
}
2026-06-14 05:08:26 +00:00
func TestGenerateHourlyReportPersistsPreflightFailure ( t * testing . T ) {
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
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 ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:08:26 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
assertGeneratedReportError ( t , err , resolved , "render preflight" )
if renderer . structuredRunCalls != 0 || renderer . runCalls != 0 {
t . Fatalf ( "post-preflight calls structured=%d run=%d, want none" , renderer . structuredRunCalls , renderer . runCalls )
}
assertNoGeneratedFailureSideEffects ( t , notifier , outputPath )
paths := hourlyArtifactPaths ( t , store , resolved )
assertPathsExist ( t , paths . Preflight , paths . Metadata )
assertPathsMissing ( t , paths . GeneratedTextRaw , paths . GeneratedTextResult , paths . GeneratedText , paths . RenderContext , paths . RenderedReport )
preflight , readErr := os . ReadFile ( paths . Preflight )
if readErr != nil {
t . Fatalf ( "read failed preflight: %v" , readErr )
}
if ! strings . Contains ( string ( preflight ) , ` "exitCode": 1 ` ) || ! strings . Contains ( string ( preflight ) , "render failed" ) {
t . Fatalf ( "failed preflight was not persisted:\n%s" , string ( preflight ) )
}
metadataData , readErr := os . ReadFile ( paths . Metadata )
if readErr != nil {
t . Fatalf ( "read metadata: %v" , readErr )
}
if ! strings . Contains ( string ( metadataData ) , paths . Preflight ) || ! strings . Contains ( string ( metadataData ) , paths . GeneratedTextRaw ) {
t . Fatalf ( "metadata missing failed-run artifact links:\n%s" , string ( metadataData ) )
}
}
func TestGenerateHourlyReportPersistsStructuredRunFailure ( t * testing . T ) {
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult {
Command : [ ] string { "scriptorium" , "run" , "--json" } ,
Stderr : "generation failed" ,
ExitCode : 2 ,
} ,
structuredRunErr : errors . New ( "scriptorium structured run exited with code 2: generation failed" ) ,
structuredRunBody : validHourlyGeneratedTextJSON ( ) ,
}
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:08:26 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
assertGeneratedReportError ( t , err , resolved , "structured generated text" )
if renderer . structuredRunCalls != 1 || renderer . runCalls != 0 {
t . Fatalf ( "calls structured=%d run=%d, want one structured run and no markdown run" , renderer . structuredRunCalls , renderer . runCalls )
}
assertNoGeneratedFailureSideEffects ( t , notifier , outputPath )
paths := hourlyArtifactPaths ( t , store , resolved )
assertPathsExist ( t , paths . Preflight , paths . Metadata , paths . GeneratedTextRaw , paths . GeneratedTextResult )
assertPathsMissing ( t , paths . GeneratedText , paths . RenderContext , paths . RenderedReport )
metadataData , readErr := os . ReadFile ( paths . Metadata )
if readErr != nil {
t . Fatalf ( "read metadata: %v" , readErr )
}
if ! strings . Contains ( string ( metadataData ) , paths . GeneratedTextRaw ) || ! strings . Contains ( string ( metadataData ) , paths . GeneratedTextResult ) {
t . Fatalf ( "metadata missing structured failure links:\n%s" , string ( metadataData ) )
}
}
func TestGenerateHourlyReportPreservesRawTextOnValidationFailure ( t * testing . T ) {
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
2026-06-16 15:30:23 +00:00
renderer := successfulGeneratedTextRenderer ( ` {
2026-06-14 05:08:26 +00:00
"summary" : "Storm chances increase through late morning." ,
2026-06-14 12:54:32 -05:00
"forecast_discussion" : "A front will keep the region unsettled." ,
2026-06-14 05:08:26 +00:00
"details" : "not allowed"
2026-06-16 15:30:23 +00:00
} ` )
2026-06-14 05:08:26 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:08:26 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
assertGeneratedReportError ( t , err , resolved , "validate generated text" )
assertNoGeneratedFailureSideEffects ( t , notifier , outputPath )
paths := hourlyArtifactPaths ( t , store , resolved )
assertPathsExist ( t , paths . Preflight , paths . Metadata , paths . GeneratedTextRaw , paths . GeneratedTextResult )
assertPathsMissing ( t , paths . GeneratedText , paths . RenderContext , paths . RenderedReport )
raw , readErr := os . ReadFile ( paths . GeneratedTextRaw )
if readErr != nil {
t . Fatalf ( "read raw generated text: %v" , readErr )
}
if ! strings . Contains ( string ( raw ) , ` "details": "not allowed" ` ) {
t . Fatalf ( "raw generated text was not preserved:\n%s" , string ( raw ) )
}
}
2026-06-15 12:24:47 +00:00
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun ( t * testing . T ) {
2026-06-14 05:08:26 +00:00
cfg , resolved , store , notifier , outputPath := hourlyGeneratedTextFixture ( t )
resolved . Definition . TemplateID = "missing-template"
2026-06-16 15:30:23 +00:00
renderer := successfulGeneratedTextRenderer ( validHourlyGeneratedTextJSON ( ) )
2026-06-14 05:08:26 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-14 05:08:26 +00:00
Resolved : resolved ,
OutputPath : outputPath ,
Renderer : renderer ,
Store : store ,
Notifier : notifier ,
} )
2026-06-15 12:24:47 +00:00
assertGeneratedReportError ( t , err , resolved , "lookup generated text catalog" )
if renderer . structuredRunCalls != 0 || renderer . runCalls != 0 {
t . Fatalf ( "calls structured=%d run=%d, want no generated text run" , renderer . structuredRunCalls , renderer . runCalls )
}
2026-06-14 05:08:26 +00:00
assertNoGeneratedFailureSideEffects ( t , notifier , outputPath )
paths := hourlyArtifactPaths ( t , store , resolved )
2026-06-15 12:24:47 +00:00
assertPathsExist ( t , paths . Preflight , paths . Metadata )
assertPathsMissing ( t , paths . GeneratedTextRaw , paths . GeneratedTextResult , paths . GeneratedText , paths . RenderContext , paths . RenderedReport )
2026-06-14 05:08:26 +00:00
metadataData , readErr := os . ReadFile ( paths . Metadata )
if readErr != nil {
t . Fatalf ( "read metadata: %v" , readErr )
}
2026-06-15 12:24:47 +00:00
if ! strings . Contains ( string ( metadataData ) , paths . GeneratedTextRaw ) {
t . Fatalf ( "metadata missing generated text artifact path:\n%s" , string ( metadataData ) )
2026-06-14 23:44:31 +00:00
}
2026-06-14 23:25:55 +00:00
}
2026-06-07 23:38:03 +00:00
func TestGenerateReportDisabledNotificationDoesNotCallNotifier ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-07 23:38:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-07 23:38:03 +00:00
notifier := & recordingNotifier { }
2026-06-16 15:30:23 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : successfulRenderer ( "# Daily Report\n" ) ,
Notifier : notifier ,
2026-06-07 23:38:03 +00:00
} )
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 )
2026-06-16 15:30:23 +00:00
cfg := dailyNotificationConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-07 23:38:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-07 23:38:03 +00:00
notifier := & recordingNotifier {
2026-06-16 15:30:23 +00:00
result : successfulNotificationResult ( ) ,
2026-06-07 23:38:03 +00:00
}
outputPath := filepath . Join ( t . TempDir ( ) , "daily-copy.md" )
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
Config : cfg ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-06-07 23:38:03 +00:00
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" )
}
2026-06-07 20:21:26 -05:00
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 )
}
2026-06-08 07:04:31 -05:00
var notificationArtifact state . DistributorNotificationArtifact
if err := json . Unmarshal ( notificationData , & notificationArtifact ) ; err != nil {
t . Fatalf ( "decode notification artifact: %v" , err )
}
2026-06-08 10:29:42 -05:00
wantBundlePaths := [ ] string {
2026-06-20 02:55:06 +00:00
"daily/2026-05-29/" + result . Metadata . RunID + ".md" ,
"daily/2026-05-29/index.md" ,
2026-06-08 10:29:42 -05:00
}
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" ) {
2026-06-08 07:04:31 -05:00
t . Fatalf ( "notification artifact = %#v, want requested pipeline, status report, and created timestamp" , notificationArtifact )
2026-06-07 23:38:03 +00:00
}
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 )
}
2026-06-08 10:29:42 -05:00
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( wantBundlePaths , "\n" ) {
t . Fatalf ( "notification BundlePaths = %#v, want %#v" , req . BundlePaths , wantBundlePaths )
2026-06-07 23:38:03 +00:00
}
2026-06-08 07:04:31 -05:00
if req . PipelineID != "weatherreporter.daily" {
t . Fatalf ( "notification PipelineID = %q, want rendered pipeline" , req . PipelineID )
}
2026-06-15 16:43:06 +00:00
if req . BundleID != "weatherreporter.home.daily" {
2026-06-07 23:38:03 +00:00
t . Fatalf ( "notification BundleID = %q, want default template" , req . BundleID )
}
2026-06-08 07:04:31 -05:00
if req . IdempotencyKey != req . BundleID + "." + result . Metadata . RunID {
t . Fatalf ( "IdempotencyKey = %q, want per-run key" , req . IdempotencyKey )
2026-06-07 23:38:03 +00:00
}
if req . RunID != result . Metadata . RunID {
t . Fatalf ( "notification RunID = %q, want report run id %q" , req . RunID , result . Metadata . RunID )
}
2026-06-07 20:21:26 -05:00
if ! req . CreatedAt . Equal ( result . Metadata . GeneratedAt ) {
t . Fatalf ( "notification CreatedAt = %s, want generated at %s" , req . CreatedAt , result . Metadata . GeneratedAt )
}
2026-06-07 23:38:03 +00:00
}
func TestGenerateReportNotificationFailureFailsReport ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyNotificationConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-07 23:38:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-07 23:38:03 +00:00
notifier := & recordingNotifier { err : errors . New ( "upload rejected" ) }
2026-06-16 15:30:23 +00:00
store := recordingFilesystemStore ( t , cfg )
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : successfulRenderer ( "# Daily Report\n" ) ,
Store : store ,
Notifier : notifier ,
2026-06-07 23:38:03 +00:00
} )
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 ) )
}
2026-06-07 20:21:26 -05:00
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 , & notification ) ; 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 )
}
2026-06-07 23:38:03 +00:00
}
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 {
2026-06-15 16:43:06 +00:00
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult { ExitCode : 2 , Stderr : "run failed" } ,
structuredRunErr : errors . New ( "run failed" ) ,
2026-06-07 23:38:03 +00:00
} ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
2026-06-16 15:30:23 +00:00
cfg := dailyNotificationConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-07 23:38:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-07 23:38:03 +00:00
notifier := & recordingNotifier { }
2026-06-16 15:30:23 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : tt . renderer ,
Notifier : notifier ,
2026-06-07 23:38:03 +00:00
} )
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 )
}
} )
}
}
2026-06-17 15:50:04 +00:00
func TestGenerateReportRequiresCollectedBundleBeforeStateWrites ( t * testing . T ) {
2026-06-07 23:38:03 +00:00
cfg := config . Defaults ( )
cfg . WeatherAPI . BaseURL = ""
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Workspace . Root = t . TempDir ( )
cfg . Notify . Distributor . Enabled = true
2026-06-08 07:04:31 -05:00
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{artifact_group}"
2026-06-16 15:30:23 +00:00
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-07 23:38:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-07 23:38:03 +00:00
notifier := & recordingNotifier { }
2026-06-17 15:50:04 +00:00
store := recordingFilesystemStore ( t , cfg )
2026-06-07 23:38:03 +00:00
2026-06-16 15:30:23 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-07 23:38:03 +00:00
Config : cfg ,
Resolved : resolved ,
Renderer : successfulRenderer ( "# Daily Report\n" ) ,
2026-06-17 15:50:04 +00:00
Store : store ,
2026-06-07 23:38:03 +00:00
Notifier : notifier ,
} )
if err == nil {
2026-06-17 15:50:04 +00:00
t . Fatal ( "GenerateReport() error = nil, want collected bundle error" )
}
if ! strings . Contains ( err . Error ( ) , "collected weather bundle is required" ) {
t . Fatalf ( "GenerateReport() error = %q, want collected bundle context" , err . Error ( ) )
2026-06-07 23:38:03 +00:00
}
if len ( notifier . requests ) != 0 {
2026-06-17 15:50:04 +00:00
t . Fatalf ( "notification requests = %#v, want none without collected data" , notifier . requests )
}
if len ( store . calls ) != 0 {
t . Fatalf ( "state calls = %#v, want no state writes without collected data" , store . calls )
2026-06-07 23:38:03 +00:00
}
}
2026-05-29 20:44:25 +00:00
func TestGenerateReportPersistsFailedPreflight ( t * testing . T ) {
2026-05-29 17:32:57 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 17:32:57 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 17:32:57 +00:00
renderer := & recordingRenderer {
2026-05-29 17:47:42 +00:00
renderResult : & scriptorium . RenderResult {
2026-05-29 17:32:57 +00:00
Command : [ ] string { "scriptorium" , "render" } ,
Stderr : "render failed" ,
ExitCode : 1 ,
} ,
err : errors . New ( "scriptorium render exited with code 1: render failed" ) ,
}
2026-06-16 15:30:23 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
2026-05-29 17:32:57 +00:00
} )
if err == nil {
2026-05-29 20:44:25 +00:00
t . Fatal ( "GenerateReport() error = nil, want render error" )
2026-05-29 17:32:57 +00:00
}
2026-06-16 15:30:23 +00:00
store := recordingFilesystemStore ( t , cfg )
2026-05-29 17:40:38 +00:00
paths , err := store . Paths ( resolved )
if err != nil {
t . Fatalf ( "Paths() error = %v" , err )
}
preflightPath := paths . Preflight
2026-05-29 17:32:57 +00:00
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 ) )
}
2026-05-29 17:40:38 +00:00
if _ , err := os . Stat ( paths . Metadata ) ; err != nil {
t . Fatalf ( "expected metadata for failed preflight %q: %v" , paths . Metadata , err )
}
2026-05-29 17:47:42 +00:00
if renderer . runCalls != 0 {
t . Fatalf ( "run calls = %d, want none after failed preflight" , renderer . runCalls )
}
}
2026-05-29 20:44:25 +00:00
func TestGenerateReportReturnsRunErrorAfterPreflight ( t * testing . T ) {
2026-05-29 17:47:42 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 17:47:42 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 17:47:42 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
2026-06-15 16:43:06 +00:00
structuredRunResult : & scriptorium . StructuredRunResult {
2026-05-29 17:47:42 +00:00
Stderr : "validation failed" ,
ExitCode : 2 ,
} ,
2026-06-15 16:43:06 +00:00
structuredRunErr : errors . New ( "scriptorium run exited with code 2: validation failed" ) ,
2026-05-29 17:47:42 +00:00
}
2026-06-16 15:30:23 +00:00
_ , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
2026-05-29 17:47:42 +00:00
} )
if err == nil {
2026-05-29 20:44:25 +00:00
t . Fatal ( "GenerateReport() error = nil, want run error" )
2026-05-29 17:47:42 +00:00
}
2026-06-15 16:43:06 +00:00
if renderer . renderCalls != 1 || renderer . structuredRunCalls != 1 || renderer . runCalls != 0 {
t . Fatalf ( "calls render=%d structured=%d run=%d, want render and structured run only" , renderer . renderCalls , renderer . structuredRunCalls , renderer . runCalls )
2026-05-29 17:47:42 +00:00
}
2026-06-16 15:30:23 +00:00
store := recordingFilesystemStore ( t , cfg )
2026-05-29 17:47:42 +00:00
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 )
}
2026-06-15 16:43:06 +00:00
if _ , err := os . Stat ( paths . GeneratedTextRaw ) ; err != nil {
t . Fatalf ( "expected raw generated text from failed run %q: %v" , paths . GeneratedTextRaw , err )
}
if _ , err := os . Stat ( paths . RenderedReport ) ; ! os . IsNotExist ( err ) {
t . Fatalf ( "rendered report exists after failed generated-text run: %v" , err )
2026-05-29 17:47:42 +00:00
}
2026-05-29 17:32:57 +00:00
}
2026-05-29 20:44:25 +00:00
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot ( t * testing . T ) {
2026-05-29 17:53:03 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 17:53:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T04:00:00-05:00" )
2026-06-09 21:33:34 +00:00
savePriorRun ( t , store , priorResolved , priorDailyModuleSnapshot ( t , priorResolved ) )
2026-05-29 17:53:03 +00:00
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 17:53:03 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 17:53:03 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
runBody : "# Daily Report\n" ,
}
2026-05-29 20:44:25 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : renderer ,
Store : store ,
2026-05-29 17:53:03 +00:00
} )
if err != nil {
2026-05-29 20:44:25 +00:00
t . Fatalf ( "GenerateReport() error = %v" , err )
2026-05-29 17:53:03 +00:00
}
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 ) )
}
}
2026-06-15 14:54:37 +00:00
func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-15 14:58:36 +00:00
Report : ReportToday ,
2026-06-15 14:54:37 +00:00
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T04:00:00-05:00" )
2026-06-15 14:54:37 +00:00
savePriorRun ( t , store , priorResolved , priorDailyModuleSnapshot ( t , priorResolved ) )
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-15 14:58:36 +00:00
Report : ReportToday ,
2026-06-15 14:54:37 +00:00
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
renderer := successfulGeneratedTextRenderer ( validTodayGeneratedTextJSON ( ) )
2026-06-15 14:54:37 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : renderer ,
Store : store ,
2026-06-15 14:54:37 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if renderer . runCalls != 0 || renderer . structuredRunCalls != 1 {
t . Fatalf ( "renderer calls run=%d structured=%d, want generated-text flow" , renderer . runCalls , renderer . structuredRunCalls )
}
if result . Metadata . ReportID != report . Today || result . Metadata . Variant != "today" || result . Metadata . GeneratedTextSchemaID != "today" {
t . Fatalf ( "metadata = %#v, want Today generated-text metadata" , result . Metadata )
}
if ! strings . Contains ( result . Metadata . RunID , "_today" ) {
t . Fatalf ( "RunID = %q, want Today report ID suffix" , result . Metadata . RunID )
}
if ! strings . Contains ( result . DataPackagePath , filepath . Join ( "data-packages" , "today" , "2026-05-29" ) ) {
t . Fatalf ( "DataPackagePath = %q, want Today artifact group" , result . DataPackagePath )
}
if ! strings . Contains ( result . ReportPath , filepath . Join ( "reports" , "today" ) ) {
t . Fatalf ( "ReportPath = %q, want Today report group" , result . ReportPath )
}
if _ , ok := result . ModuleSnapshot . LookupStanza ( "today_planning" ) ; ! ok {
t . Fatal ( "today_planning stanza missing" )
}
if _ , ok := result . ModuleSnapshot . LookupStanza ( "tomorrow_planning" ) ; ok {
t . Fatal ( "tomorrow_planning stanza present, want Today-specific planning" )
}
if result . PriorSnapshot == nil || len ( result . RecentChanges ) == 0 {
t . Fatalf ( "prior=%#v recentChanges=%#v, want Today daily comparison changes" , result . PriorSnapshot , result . RecentChanges )
}
data , err := os . ReadFile ( result . DataPackagePath )
if err != nil {
t . Fatalf ( "read data package: %v" , err )
}
for _ , want := range [ ] string { "id: today" , "prompt_id: weather.today_generated_text" , "today_planning:" , "recent_changes:" } {
if ! strings . Contains ( string ( data ) , want ) {
t . Fatalf ( "data package missing %q:\n%s" , want , string ( data ) )
}
}
reportData , err := os . ReadFile ( result . ReportPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
for _ , want := range [ ] string { "# Today's Weather" , "Today starts with showers before improving." } {
if ! strings . Contains ( string ( reportData ) , want ) {
t . Fatalf ( "today report missing %q:\n%s" , want , string ( reportData ) )
}
}
}
2026-05-29 18:00:27 +00:00
func TestGenerateTomorrowReportUsesTomorrowBriefingDate ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:00:27 +00:00
Report : ReportTomorrow ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T18:00:00-05:00" )
renderer := successfulGeneratedTextRenderer ( validTomorrowGeneratedTextJSON ( ) )
2026-05-29 18:00:27 +00:00
2026-05-29 20:44:25 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
2026-05-29 18:00:27 +00:00
} )
if err != nil {
2026-05-29 20:44:25 +00:00
t . Fatalf ( "GenerateReport() error = %v" , err )
2026-05-29 18:00:27 +00:00
}
2026-06-14 23:44:31 +00:00
if renderer . runCalls != 0 {
t . Fatalf ( "markdown run calls = %d, want none" , renderer . runCalls )
}
if renderer . structuredRunCalls != 1 {
t . Fatalf ( "structured run calls = %d, want 1" , renderer . structuredRunCalls )
}
if renderer . structuredRunRequest . OutputPath != result . GeneratedTextRawPath {
t . Fatalf ( "structured run OutputPath = %q, want %q" , renderer . structuredRunRequest . OutputPath , result . GeneratedTextRawPath )
}
2026-05-29 18:00:27 +00:00
2026-06-14 23:22:31 +00:00
if result . Metadata . ReportID != report . Tomorrow || result . Metadata . Variant != "tomorrow" {
2026-06-09 21:33:34 +00:00
t . Fatalf ( "metadata report/variant = %q/%q, want tomorrow" , result . Metadata . ReportID , result . Metadata . Variant )
2026-05-29 18:00:27 +00:00
}
2026-06-14 23:44:31 +00:00
if result . Metadata . GeneratedTextSchemaID != "tomorrow" || result . Metadata . GeneratedTextPath != result . GeneratedTextPath || result . Metadata . RenderContextPath != result . RenderContextPath || result . Metadata . RenderedReportPath != result . ReportPath {
t . Fatalf ( "metadata generated-text links = %#v, want tomorrow generated-text artifacts" , result . Metadata )
}
2026-06-09 21:33:34 +00:00
dailySummary , ok , err := module . StanzaValue [ map [ string ] any ] ( result . ModuleSnapshot , "derived_daily_summary" )
if err != nil {
t . Fatalf ( "decode daily summary: %v" , err )
}
2026-06-10 10:36:52 -05:00
if ! ok || dailySummary [ "date" ] != "Saturday, May 30, 2026" {
2026-06-09 21:33:34 +00:00
t . Fatalf ( "daily summary = %#v, want tomorrow date" , dailySummary )
2026-05-29 18:00:27 +00:00
}
2026-06-09 21:33:34 +00:00
if _ , ok := result . ModuleSnapshot . LookupStanza ( "tomorrow_planning" ) ; ! ok {
t . Fatal ( "tomorrow_planning stanza missing" )
2026-05-29 18:00:27 +00:00
}
2026-06-14 23:22:31 +00:00
if ! strings . Contains ( filepath . Base ( result . ReportPath ) , "tomorrow" ) {
2026-05-29 18:00:27 +00:00
t . Fatalf ( "ReportPath = %q, want managed tomorrow report path" , result . ReportPath )
}
2026-06-14 23:44:31 +00:00
assertPathsExist ( t , result . GeneratedTextRawPath , result . GeneratedTextResultPath , result . GeneratedTextPath , result . RenderContextPath , result . ReportPath )
renderContext , err := os . ReadFile ( result . RenderContextPath )
if err != nil {
t . Fatalf ( "read render context: %v" , err )
}
for _ , want := range [ ] string { ` "Title": "Saturday's Weather" ` , ` "GeneratedText": { ` , ` "forecast_discussion": [ ` , ` "Dayparts": [ ` } {
if ! strings . Contains ( string ( renderContext ) , want ) {
t . Fatalf ( "render context missing %q:\n%s" , want , string ( renderContext ) )
}
}
reportData , err := os . ReadFile ( result . ReportPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
for _ , want := range [ ] string { "# Saturday's Weather" , "## Daypart Forecast" , "## Forecast Discussion" , "Tomorrow starts with showers before improving." } {
if ! strings . Contains ( string ( reportData ) , want ) {
t . Fatalf ( "tomorrow report missing %q:\n%s" , want , string ( reportData ) )
}
}
2026-05-29 18:00:27 +00:00
}
2026-06-14 23:22:31 +00:00
func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot ( t * testing . T ) {
2026-05-29 18:00:27 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-14 23:22:31 +00:00
Report : ReportTomorrow ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T17:00:00-05:00" )
2026-06-09 21:33:34 +00:00
savePriorRun ( t , store , priorResolved , priorDailyModuleSnapshot ( t , priorResolved ) )
2026-05-29 18:00:27 +00:00
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:00:27 +00:00
Report : ReportTomorrow ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T18:00:00-05:00" )
renderer := successfulGeneratedTextRenderer ( validTomorrowGeneratedTextJSON ( ) )
2026-05-29 18:00:27 +00:00
2026-05-29 20:44:25 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : renderer ,
Store : store ,
2026-05-29 18:00:27 +00:00
} )
if err != nil {
2026-05-29 20:44:25 +00:00
t . Fatalf ( "GenerateReport() error = %v" , err )
2026-05-29 18:00:27 +00:00
}
if result . PriorSnapshot == nil {
2026-06-14 23:22:31 +00:00
t . Fatal ( "PriorSnapshot = nil, want compatible prior tomorrow snapshot" )
2026-05-29 18:00:27 +00:00
}
if len ( result . RecentChanges ) == 0 {
2026-06-14 23:22:31 +00:00
t . Fatal ( "RecentChanges length = 0, want changes from compatible prior tomorrow snapshot" )
2026-05-29 18:00:27 +00:00
}
}
2026-06-14 23:49:18 +00:00
func TestDailyReportIgnoresPriorTomorrowSnapshot ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-14 23:49:18 +00:00
Report : ReportTomorrow ,
2026-06-16 15:30:23 +00:00
} , "2026-05-28T18:00:00-05:00" )
2026-06-14 23:49:18 +00:00
savePriorRun ( t , store , priorResolved , priorDailyModuleSnapshot ( t , priorResolved ) )
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-06-14 23:49:18 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-06-14 23:49:18 +00:00
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : successfulRenderer ( "# Daily Report\n" ) ,
Store : store ,
2026-06-14 23:49:18 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
if result . PriorSnapshot != nil {
t . Fatalf ( "PriorSnapshot = %#v, want nil for prior tomorrow snapshot" , result . PriorSnapshot )
}
if len ( result . RecentChanges ) != 0 {
t . Fatalf ( "RecentChanges = %#v, want none from incompatible prior tomorrow snapshot" , result . RecentChanges )
}
}
2026-05-29 18:08:50 +00:00
func TestGenerateThreeDayReportWritesReportAndRecentChanges ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:08:50 +00:00
Report : ReportThreeDay ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T04:00:00-05:00" )
2026-06-09 21:33:34 +00:00
savePriorRun ( t , store , priorResolved , priorOutlookModuleSnapshot ( t , "2026-05-30" ) )
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:08:50 +00:00
Report : ReportThreeDay ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 18:08:50 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
runBody : "# 3-Day Outlook\n" ,
}
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : renderer ,
Store : store ,
2026-05-29 18:08:50 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
2026-06-09 21:33:34 +00:00
dayparts , ok , err := module . StanzaValue [ map [ string ] any ] ( result . ModuleSnapshot , "derived_daypart_summaries" )
if err != nil {
t . Fatalf ( "decode daypart summaries: %v" , err )
2026-05-29 18:08:50 +00:00
}
2026-06-09 21:33:34 +00:00
if ! ok || len ( dayparts ) == 0 {
t . Fatalf ( "daypart summaries = %#v, want 3-day module content" , dayparts )
2026-05-29 18:08:50 +00:00
}
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" )
}
}
2026-05-29 18:16:10 +00:00
func TestGenerateWeekendReportWritesReportAndRecentChanges ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:16:10 +00:00
Report : ReportWeekend ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T04:00:00-05:00" )
2026-06-09 21:33:34 +00:00
savePriorRun ( t , store , priorResolved , priorOutlookModuleSnapshot ( t , "2026-05-30" ) )
2026-06-16 15:30:23 +00:00
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:16:10 +00:00
Report : ReportWeekend ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 18:16:10 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
runBody : "# Weekend Outlook\n" ,
}
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : currentResolved ,
Renderer : renderer ,
Store : store ,
2026-05-29 18:16:10 +00:00
} )
if err != nil {
t . Fatalf ( "GenerateReport() error = %v" , err )
}
2026-06-09 21:33:34 +00:00
dayparts , ok , err := module . StanzaValue [ map [ string ] any ] ( result . ModuleSnapshot , "derived_daypart_summaries" )
if err != nil {
t . Fatalf ( "decode daypart summaries: %v" , err )
2026-05-29 18:16:10 +00:00
}
2026-06-09 21:33:34 +00:00
if ! ok || len ( dayparts ) == 0 {
t . Fatalf ( "daypart summaries = %#v, want weekend module content" , dayparts )
2026-05-29 18:16:10 +00:00
}
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" )
}
}
2026-05-29 18:28:25 +00:00
func TestGenerateStormReportWritesReport ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:28:25 +00:00
Report : ReportStorm ,
StormStart : mustParse ( "2026-05-29T06:00:00-05:00" ) ,
StormEnd : mustParse ( "2026-05-29T10:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 18:28:25 +00:00
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 ,
2026-06-17 15:50:04 +00:00
Collection : collectionForTest ( t , cfg ) ,
2026-05-29 18:28:25 +00:00
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 )
}
2026-06-09 21:33:34 +00:00
if _ , ok := result . ModuleSnapshot . LookupStanza ( "precip_timing" ) ; ! ok {
t . Fatal ( "precip_timing stanza missing" )
2026-05-29 18:28:25 +00:00
}
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 )
}
2026-06-09 21:08:17 +00:00
if ! strings . Contains ( string ( data ) , "id: storm" ) || ! strings . Contains ( string ( data ) , "prompt_id: weather.storm_report" ) || ! strings . Contains ( string ( data ) , "precip_timing:" ) {
2026-05-29 18:28:25 +00:00
t . Fatalf ( "data package missing storm content:\n%s" , string ( data ) )
}
}
2026-05-29 18:33:24 +00:00
func TestInspectGeneratedReportArtifacts ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:33:24 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 18:33:24 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
runBody : "# Daily Report\n" ,
}
result , err := GenerateReport ( context . Background ( ) , ReportRequest {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : renderer ,
2026-05-29 18:33:24 +00:00
} )
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 )
}
2026-06-09 21:33:34 +00:00
if metadata . ModuleSnapshotPath != result . ModuleSnapshotPath || metadata . DataPackagePath != result . DataPackagePath {
2026-05-29 18:33:24 +00:00
t . Fatalf ( "metadata paths = %#v, want generated artifact paths" , metadata )
}
2026-06-09 20:58:35 +00:00
moduleSnapshot , err := InspectModules ( context . Background ( ) , InspectRunRequest { Config : cfg , RunID : result . Metadata . RunID } )
2026-05-29 18:33:24 +00:00
if err != nil {
2026-06-09 20:58:35 +00:00
t . Fatalf ( "InspectModules() error = %v" , err )
2026-05-29 18:33:24 +00:00
}
2026-06-09 20:58:35 +00:00
if moduleSnapshot . SchemaVersion != module . SnapshotSchemaVersion || len ( moduleSnapshot . Outputs ) == 0 {
t . Fatalf ( "module snapshot = %#v, want persisted outputs" , moduleSnapshot )
2026-05-29 18:33:24 +00:00
}
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 )
}
2026-06-10 08:24:50 -05:00
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 )
2026-05-29 18:33:24 +00:00
}
}
func TestInspectPriorSnapshot ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
store := recordingFilesystemStore ( t , cfg )
priorResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:33:24 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T04:00:00-05:00" )
currentResolved := resolveGenerateForTest ( t , cfg , GenerateRequest {
2026-05-29 18:33:24 +00:00
Report : ReportDaily ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
2026-06-16 15:30:23 +00:00
} , "2026-05-29T05:00:00-05:00" )
2026-05-29 18:33:24 +00:00
renderer := & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
runBody : "# Daily Report\n" ,
}
2026-06-17 15:50:04 +00:00
if _ , err := GenerateReport ( context . Background ( ) , ReportRequest { Config : cfg , Collection : collectionForTest ( t , cfg ) , Resolved : priorResolved , Renderer : renderer , Store : store } ) ; err != nil {
2026-05-29 18:33:24 +00:00
t . Fatalf ( "GenerateReport(prior) error = %v" , err )
}
2026-06-17 15:50:04 +00:00
current , err := GenerateReport ( context . Background ( ) , ReportRequest { Config : cfg , Collection : collectionForTest ( t , cfg ) , Resolved : currentResolved , Renderer : renderer , Store : store } )
2026-05-29 18:33:24 +00:00
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 ( ) )
}
}
2026-05-29 17:17:50 +00:00
func TestResolveGenerateMapsCommandToReportDefinition ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone = "America/Chicago"
2026-06-12 17:36:58 +00:00
now := mustParse ( "2026-05-29T08:00:00-05:00" )
2026-05-29 17:17:50 +00:00
2026-06-12 17:36:58 +00:00
tests := [ ] struct {
name string
kind ReportKind
wantID report . ID
wantPrompt string
wantStart string
wantEnd string
requestDate time . Time
} {
{
name : "tomorrow" ,
kind : ReportTomorrow ,
2026-06-14 23:22:31 +00:00
wantID : report . Tomorrow ,
2026-06-14 23:44:31 +00:00
wantPrompt : "weather.tomorrow_generated_text" ,
2026-06-12 17:36:58 +00:00
wantStart : "2026-05-30T00:00:00-05:00" ,
wantEnd : "2026-05-31T00:00:00-05:00" ,
requestDate : time . Time { } ,
} ,
{
2026-06-14 04:26:05 +00:00
name : "hourly" ,
kind : ReportHourly ,
wantID : report . Hourly ,
2026-06-14 04:35:52 +00:00
wantPrompt : "weather.hourly_generated_text" ,
2026-06-12 17:36:58 +00:00
wantStart : "2026-05-29T08:00:00-05:00" ,
wantEnd : "2026-05-29T14:00:00-05:00" ,
} ,
2026-05-29 17:17:50 +00:00
}
2026-06-12 17:36:58 +00: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 )
}
} )
2026-05-29 17:17:50 +00:00
}
}
2026-06-15 16:46:10 +00:00
func TestResolveGenerateDailyRequiresDate ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone = "America/Chicago"
_ , err := ResolveGenerate ( GenerateRequest {
Config : cfg ,
Report : ReportDaily ,
} , mustParse ( "2026-05-29T08:00:00-05:00" ) )
if err == nil {
t . Fatal ( "ResolveGenerate() error = nil, want required date error" )
}
if ! strings . Contains ( err . Error ( ) , "requires an explicit date" ) {
t . Fatalf ( "ResolveGenerate() error = %q, want required date context" , err . Error ( ) )
}
}
2026-06-09 20:51:22 +00:00
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
` ) , 0 o600 ) ; 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 )
}
}
2026-06-15 12:36:53 +00:00
func TestResolveGenerateRejectsInvalidProgrammaticReportOverrides ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . Reports = map [ string ] config . ReportConfig {
"moon" : { } ,
}
_ , err := ResolveGenerate ( GenerateRequest {
Config : cfg ,
Report : ReportDaily ,
} , mustParse ( "2026-05-29T05:00:00-05:00" ) )
if err == nil {
t . Fatal ( "ResolveGenerate() error = nil, want report override error" )
}
if ! strings . Contains ( err . Error ( ) , "reports.moon" ) {
t . Fatalf ( "ResolveGenerate() error = %q, want report override context" , err . Error ( ) )
}
}
2026-06-09 20:51:22 +00:00
func moduleIDsForTest ( ids [ ] module . ID ) [ ] string {
out := make ( [ ] string , 0 , len ( ids ) )
for _ , id := range ids {
out = append ( out , string ( id ) )
}
return out
}
2026-06-12 17:40:54 +00:00
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 )
}
2026-05-29 17:23:49 +00:00
func dailyBundleServer ( t * testing . T ) * httptest . Server {
2026-06-12 15:14:09 +00:00
t . Helper ( )
return dailyBundleServerWithConvectiveResponse ( t , emptyConvectiveOutlooksResponse )
}
func dailyBundleServerWithConvectiveResponse ( t * testing . T , convectiveResponse string ) * httptest . Server {
2026-05-29 17:23:49 +00:00
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" :
2026-05-29 20:04:27 -05:00
_ , _ = w . Write ( [ ] byte ( ` { "data": { "conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}} ` ) )
2026-05-29 17:23:49 +00:00
case "/forecast/hourly" :
2026-05-29 18:00:27 +00:00
_ , _ = 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}]}} ` ) )
2026-05-29 17:23:49 +00:00
case "/forecast/narrative" :
2026-05-29 18:00:27 +00:00
_ , _ = 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."}]}} ` ) )
2026-05-29 17:23:49 +00:00
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" :
2026-05-29 23:29:04 -05:00
_ , _ = 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."}}} ` ) )
2026-05-30 07:47:49 -05:00
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"}} ` ) )
2026-06-12 14:51:54 +00:00
case "/outlooks/convective" :
2026-06-12 15:14:09 +00:00
_ , _ = w . Write ( [ ] byte ( convectiveResponse ) )
2026-05-29 17:23:49 +00:00
default :
http . NotFound ( w , r )
}
} ) )
t . Cleanup ( server . Close )
return server
}
2026-06-14 04:26:05 +00:00
func hourlyBundleServer ( t * testing . T ) * httptest . Server {
2026-06-12 17:40:54 +00:00
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" :
2026-06-16 21:09:17 -05:00
_ , _ = 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","instruction":"Avoid low-water crossings.","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"}]}} ` ) )
2026-06-12 17:40:54 +00:00
case "/discussion" :
2026-06-14 04:26:05 +00:00
_ , _ = 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 hourly report."},"longTerm": { "qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}} ` ) )
2026-06-12 17:40:54 +00:00
case "/weatherstories/latest" :
2026-06-14 04:26:05 +00:00
_ , _ = 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":"Hourly 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/hourly"}} ` ) )
2026-06-12 17:40:54 +00:00
case "/outlooks/convective" :
2026-06-14 04:26:05 +00:00
_ , _ = w . Write ( [ ] byte ( ` { "data": { "locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[ { "id":"day1-hourly","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":"hourly severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the hourly 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"}]}} ` ) )
2026-06-12 17:40:54 +00:00
default :
http . NotFound ( w , r )
}
} ) )
t . Cleanup ( server . Close )
return server
}
2026-06-12 15:14:09 +00:00
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"}]}} `
2026-05-29 17:17:50 +00: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 )
}
}
2026-06-20 02:49:39 +00:00
func TestDistributorTemplateValuesDeriveStormID ( 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 )
}
values , err := distributorTemplateValuesForReport ( cfg , resolved , "run" , "" )
if err != nil {
t . Fatalf ( "distributorTemplateValuesForReport() error = %v" , err )
}
if values . StormID != "2026-05-29T1800-2026-05-30T0600" {
t . Fatalf ( "StormID = %q, want storm valid-period stamp" , values . StormID )
}
}
func TestDistributorTemplateValuesLeaveStormIDEmptyForOtherReports ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . Timezone = "America/Chicago"
location , err := timeutil . LoadLocation ( cfg . WeatherAPI . Timezone )
if err != nil {
t . Fatalf ( "load location: %v" , err )
}
resolved , err := report . Resolve ( report . Today , report . ResolveRequest {
Now : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Location : location ,
} )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
values , err := distributorTemplateValuesForReport ( cfg , resolved , "run" , "" )
if err != nil {
t . Fatalf ( "distributorTemplateValuesForReport() error = %v" , err )
}
if values . StormID != "" {
t . Fatalf ( "StormID = %q, want empty for %s" , values . StormID , resolved . Definition . ID )
}
}
2026-06-20 02:55:06 +00:00
func TestBuildNotificationRequestUsesReportDefaultBundlePaths ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . Location . ID = "home"
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{report_id}"
location := mustLoadTestLocation ( t , cfg . WeatherAPI . Timezone )
now := mustParse ( "2026-05-29T12:00:00-05:00" )
registry := report . DefaultRegistry ( )
tests := [ ] struct {
id report . ID
req report . ResolveRequest
want func ( state . Metadata ) [ ] string
source string
} {
{
id : report . Hourly ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string { "hourly/index.md" }
} ,
source : "/managed/hourly.md" ,
} ,
{
id : report . Daily ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"daily/2026-05-29/" + metadata . RunID + ".md" ,
"daily/2026-05-29/index.md" ,
}
} ,
source : "/managed/daily.md" ,
} ,
{
id : report . Today ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"daily/2026-05-29/" + metadata . RunID + ".md" ,
"daily/2026-05-29/index.md" ,
"today/index.md" ,
}
} ,
source : "/managed/today.md" ,
} ,
{
id : report . Tomorrow ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"daily/2026-05-30/" + metadata . RunID + ".md" ,
"daily/2026-05-30/index.md" ,
"tomorrow/index.md" ,
}
} ,
source : "/managed/tomorrow.md" ,
} ,
{
id : report . ThreeDay ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"three-day/2026-05-29/" + metadata . RunID + ".md" ,
"three-day/2026-05-29/index.md" ,
}
} ,
source : "/managed/three-day.md" ,
} ,
{
id : report . Weekend ,
req : report . ResolveRequest {
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Location : location ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"weekend/2026-05-29/" + metadata . RunID + ".md" ,
"weekend/2026-05-29/index.md" ,
}
} ,
source : "/managed/weekend.md" ,
} ,
{
id : report . Storm ,
req : report . ResolveRequest {
Now : now ,
Location : location ,
StormStart : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
StormEnd : mustParse ( "2026-05-30T06:00:00-05:00" ) ,
} ,
want : func ( metadata state . Metadata ) [ ] string {
return [ ] string {
"storm/2026-05-29T1800-2026-05-30T0600/" + metadata . RunID + ".md" ,
"storm/2026-05-29T1800-2026-05-30T0600/index.md" ,
}
} ,
source : "/managed/storm.md" ,
} ,
}
for _ , tt := range tests {
t . Run ( string ( tt . id ) , func ( t * testing . T ) {
resolved , err := registry . Resolve ( tt . id , tt . req )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
metadata := notificationMetadataForTest ( resolved )
req , err := buildNotificationRequest ( cfg , resolved , tt . source , metadata )
if err != nil {
t . Fatalf ( "buildNotificationRequest() error = %v" , err )
}
want := tt . want ( metadata )
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( want , "\n" ) {
t . Fatalf ( "BundlePaths = %#v, want %#v" , req . BundlePaths , want )
}
if req . ReportPath != tt . source {
t . Fatalf ( "ReportPath = %q, want %q" , req . ReportPath , tt . source )
}
} )
}
}
func TestBuildNotificationRequestUsesReportDistributorPathOverride ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . Location . ID = "home"
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{report_id}"
applyReportDistributorPathOverrides ( t , & cfg , `
reports :
daily :
distributor :
path_templates :
- "custom/{report_id}/{run_id}.md"
` )
location := mustLoadTestLocation ( t , cfg . WeatherAPI . Timezone )
resolved , err := report . Resolve ( report . Daily , report . ResolveRequest {
Now : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Location : location ,
Date : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
} )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
metadata := notificationMetadataForTest ( resolved )
req , err := buildNotificationRequest ( cfg , resolved , "/managed/daily.md" , metadata )
if err != nil {
t . Fatalf ( "buildNotificationRequest() error = %v" , err )
}
want := [ ] string { "custom/daily/" + metadata . RunID + ".md" }
if strings . Join ( req . BundlePaths , "\n" ) != strings . Join ( want , "\n" ) {
t . Fatalf ( "BundlePaths = %#v, want override %#v" , req . BundlePaths , want )
}
}
func TestBuildNotificationRequestRequiresReportBundlePaths ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . Location . ID = "home"
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{report_id}"
location := mustLoadTestLocation ( t , cfg . WeatherAPI . Timezone )
resolved , err := report . Resolve ( report . Hourly , report . ResolveRequest {
Now : mustParse ( "2026-05-29T12:00:00-05:00" ) ,
Location : location ,
} )
if err != nil {
t . Fatalf ( "Resolve() error = %v" , err )
}
resolved . Definition . DistributorPathTemplates = nil
metadata := notificationMetadataForTest ( resolved )
_ , err = buildNotificationRequest ( cfg , resolved , "/managed/hourly.md" , metadata )
if err == nil {
t . Fatal ( "buildNotificationRequest() error = nil, want missing path templates error" )
}
for _ , want := range [ ] string { ` report "hourly" ` , metadata . RunID , ` /managed/hourly.md ` , "no distributor path templates" } {
if ! strings . Contains ( err . Error ( ) , want ) {
t . Fatalf ( "error = %q, want %q" , err . Error ( ) , want )
}
}
}
2026-06-17 20:44:34 +00:00
func TestBatchRunIDUsesUTCStartAndBatchName ( t * testing . T ) {
tests := [ ] struct {
name string
startedAt time . Time
batch BatchKind
want string
} {
{
name : "morning" ,
startedAt : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
batch : BatchMorning ,
want : "20260529T100000.000000000Z_morning" ,
} ,
{
name : "evening" ,
startedAt : mustParse ( "2026-05-29T18:30:45-05:00" ) ,
batch : BatchEvening ,
want : "20260529T233045.000000000Z_evening" ,
} ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
if got := batchRunID ( tt . startedAt , tt . batch ) ; got != tt . want {
t . Fatalf ( "batchRunID() = %q, want %q" , got , tt . want )
}
} )
}
}
func TestRenderBatchNotificationIdentity ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . Location . ID = "home"
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Notify . Distributor . Batch . PipelineIDTemplate = "weatherreporter.{batch_started_date}"
cfg . Notify . Distributor . Batch . BundleIDTemplate = "weatherreporter.{location_id}.{batch}"
cfg . Notify . Distributor . Batch . IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}"
startedAt := mustParse ( "2026-05-30T03:30:00Z" )
runID := batchRunID ( startedAt , BatchEvening )
identity , err := renderBatchNotificationIdentity ( cfg , BatchEvening , runID , startedAt )
if err != nil {
t . Fatalf ( "renderBatchNotificationIdentity() error = %v" , err )
}
if identity . PipelineID != "weatherreporter.2026-05-29" {
t . Fatalf ( "PipelineID = %q, want local batch date" , identity . PipelineID )
}
if identity . BundleID != "weatherreporter.home.evening" {
t . Fatalf ( "BundleID = %q, want rendered bundle id" , identity . BundleID )
}
wantKey := "weatherreporter.home.evening.20260530T033000.000000000Z_evening"
if identity . IdempotencyKey != wantKey {
t . Fatalf ( "IdempotencyKey = %q, want %q" , identity . IdempotencyKey , wantKey )
}
}
func TestBatchResultJSONOmitsNilNotification ( t * testing . T ) {
data , err := json . Marshal ( BatchResult {
Batch : BatchMorning ,
Reports : [ ] BatchReportResult { } ,
} )
if err != nil {
t . Fatalf ( "Marshal() error = %v" , err )
}
if strings . Contains ( string ( data ) , "notification" ) {
t . Fatalf ( "BatchResult JSON = %s, want no notification field" , data )
}
}
func TestBatchResultJSONIncludesNotification ( t * testing . T ) {
result := BatchResult {
Batch : BatchEvening ,
Notification : & BatchNotificationResult {
Status : "accepted" ,
RunID : "distributor-run" ,
PipelineID : "weatherreporter" ,
BundleID : "weatherreporter.home.evening" ,
IdempotencyKey : "weatherreporter.home.evening.20260529T233000.000000000Z_evening" ,
Path : "notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json" ,
IncludedReports : [ ] BatchNotificationReport {
{
ReportID : report . Tomorrow ,
RunID : "20260529T233000.000000000Z_tomorrow" ,
SourcePath : "reports/tomorrow.md" ,
BundlePaths : [ ] string { "tomorrow/index.md" } ,
} ,
} ,
} ,
Reports : [ ] BatchReportResult { } ,
}
data , err := json . Marshal ( result )
if err != nil {
t . Fatalf ( "Marshal() error = %v" , err )
}
for _ , want := range [ ] string {
` "notification": { ` ,
` "status":"accepted" ` ,
` "runId":"distributor-run" ` ,
` "pipelineId":"weatherreporter" ` ,
` "bundleId":"weatherreporter.home.evening" ` ,
` "idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening" ` ,
` "path":"notifications/batches/evening/2026-05-29/20260529T233000.000000000Z_evening.distributor.json" ` ,
` "includedReports":[ ` ,
` "reportId":"tomorrow" ` ,
` "sourcePath":"reports/tomorrow.md" ` ,
` "bundlePaths":["tomorrow/index.md"] ` ,
} {
if ! strings . Contains ( string ( data ) , want ) {
t . Fatalf ( "BatchResult JSON = %s, want %s" , data , want )
}
}
}
2026-06-17 20:48:38 +00:00
func TestBuildBatchNotificationRequestIncludesEveningReports ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
cfg . Notify . Distributor . Batch . PipelineIDTemplate = "weatherreporter.{batch}.{batch_started_date}"
cfg . Notify . Distributor . Batch . BundleIDTemplate = "weatherreporter.{location_id}.{batch}"
cfg . Notify . Distributor . Batch . IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}"
startedAt := mustParse ( "2026-05-29T18:00:00-05:00" )
planned , reports := plannedBatchNotificationReports ( t , cfg , BatchEvening , startedAt , "2026-05-31" , "2026-06-01" )
runID := batchRunID ( startedAt , BatchEvening )
req , err := buildBatchNotificationRequest ( cfg , BatchEvening , runID , startedAt , reports , planned )
if err != nil {
t . Fatalf ( "buildBatchNotificationRequest() error = %v" , err )
}
if req . Batch != BatchEvening || req . RunID != runID {
t . Fatalf ( "batch identity = %s/%s, want %s/%s" , req . Batch , req . RunID , BatchEvening , runID )
}
if req . PipelineID != "weatherreporter.evening.2026-05-29" {
t . Fatalf ( "PipelineID = %q, want rendered batch pipeline" , req . PipelineID )
}
if req . BundleID != "weatherreporter.home.evening" {
t . Fatalf ( "BundleID = %q, want rendered batch bundle id" , req . BundleID )
}
wantKey := "weatherreporter.home.evening." + runID
if req . IdempotencyKey != wantKey {
t . Fatalf ( "IdempotencyKey = %q, want %q" , req . IdempotencyKey , wantKey )
}
if ! req . CreatedAt . Equal ( startedAt ) {
t . Fatalf ( "CreatedAt = %s, want %s" , req . CreatedAt , startedAt )
}
if len ( req . IncludedReports ) != 3 {
t . Fatalf ( "IncludedReports = %d, want 3" , len ( req . IncludedReports ) )
}
2026-06-20 02:55:06 +00:00
if len ( req . Files ) != 7 {
t . Fatalf ( "Files = %d, want report-specific mappings" , len ( req . Files ) )
2026-06-17 20:48:38 +00:00
}
wantBundlePaths := map [ string ] struct { } { }
for _ , plannedReport := range planned {
resolved := plannedReport . Resolved
runID := resolved . Metadata ( ) . RunID
2026-06-20 02:55:06 +00:00
validStart := resolved . ValidPeriod . Start . In ( mustLoadTestLocation ( t , cfg . WeatherAPI . Timezone ) ) . Format ( timeutil . DateLayout )
switch resolved . Definition . ID {
case report . Tomorrow :
wantBundlePaths [ "daily/" + validStart + "/" + runID + ".md" ] = struct { } { }
wantBundlePaths [ "daily/" + validStart + "/index.md" ] = struct { } { }
wantBundlePaths [ "tomorrow/index.md" ] = struct { } { }
case report . Daily :
wantBundlePaths [ "daily/" + validStart + "/" + runID + ".md" ] = struct { } { }
wantBundlePaths [ "daily/" + validStart + "/index.md" ] = struct { } { }
default :
t . Fatalf ( "unexpected planned report %s" , resolved . Definition . ID )
2026-06-17 20:48:38 +00:00
}
}
gotBundlePaths := map [ string ] struct { } { }
gotSourcePaths := map [ string ] struct { } { }
for _ , file := range req . Files {
gotBundlePaths [ file . BundlePath ] = struct { } { }
gotSourcePaths [ file . SourcePath ] = struct { } { }
}
for want := range wantBundlePaths {
if _ , ok := gotBundlePaths [ want ] ; ! ok {
t . Fatalf ( "bundle paths = %#v, missing %q" , gotBundlePaths , want )
}
}
for _ , item := range reports {
if _ , ok := gotSourcePaths [ item . ReportPath ] ; ! ok {
t . Fatalf ( "source paths = %#v, missing managed report path %q" , gotSourcePaths , item . ReportPath )
}
}
uploadReq := batchDistributorUploadRequest ( req )
if uploadReq . PipelineID != req . PipelineID || uploadReq . BundleID != req . BundleID || uploadReq . IdempotencyKey != req . IdempotencyKey || ! uploadReq . CreatedAt . Equal ( startedAt ) {
t . Fatalf ( "upload request = %#v, want batch notification identity" , uploadReq )
}
if len ( uploadReq . Files ) != len ( req . Files ) {
t . Fatalf ( "upload files = %d, want %d" , len ( uploadReq . Files ) , len ( req . Files ) )
}
}
2026-06-20 02:55:06 +00:00
func TestBuildBatchNotificationRequestUsesReportOverridesAndDefaults ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
applyReportDistributorPathOverrides ( t , & cfg , `
reports :
daily :
distributor :
path_templates :
- "custom-daily/{valid_start_date}/{run_id}.md"
` )
startedAt := mustParse ( "2026-05-29T18:00:00-05:00" )
planned , reports := plannedBatchNotificationReports ( t , cfg , BatchEvening , startedAt , "2026-05-31" )
runID := batchRunID ( startedAt , BatchEvening )
req , err := buildBatchNotificationRequest ( cfg , BatchEvening , runID , startedAt , reports , planned )
if err != nil {
t . Fatalf ( "buildBatchNotificationRequest() error = %v" , err )
}
gotBundlePaths := map [ string ] struct { } { }
for _ , file := range req . Files {
gotBundlePaths [ file . BundlePath ] = struct { } { }
}
for _ , plannedReport := range planned {
reportRunID := plannedReport . Resolved . Metadata ( ) . RunID
switch plannedReport . Resolved . Definition . ID {
case report . Tomorrow :
for _ , want := range [ ] string {
"daily/2026-05-30/" + reportRunID + ".md" ,
"daily/2026-05-30/index.md" ,
"tomorrow/index.md" ,
} {
if _ , ok := gotBundlePaths [ want ] ; ! ok {
t . Fatalf ( "bundle paths = %#v, missing default path %q" , gotBundlePaths , want )
}
}
case report . Daily :
want := "custom-daily/2026-05-31/" + reportRunID + ".md"
if _ , ok := gotBundlePaths [ want ] ; ! ok {
t . Fatalf ( "bundle paths = %#v, missing override path %q" , gotBundlePaths , want )
}
if _ , ok := gotBundlePaths [ "daily/2026-05-31/index.md" ] ; ok {
t . Fatalf ( "bundle paths = %#v, want daily defaults replaced by override" , gotBundlePaths )
}
}
}
}
2026-06-17 20:48:38 +00:00
func TestBuildBatchNotificationRequestRejectsDuplicateBundlePaths ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
2026-06-20 02:55:06 +00:00
applyReportDistributorPathOverrides ( t , & cfg , `
reports :
tomorrow :
distributor :
path_templates :
- "index.md"
daily :
distributor :
path_templates :
- "index.md"
` )
2026-06-17 20:48:38 +00:00
startedAt := mustParse ( "2026-05-29T18:00:00-05:00" )
planned , reports := plannedBatchNotificationReports ( t , cfg , BatchEvening , startedAt , "2026-05-31" )
_ , err := buildBatchNotificationRequest ( cfg , BatchEvening , batchRunID ( startedAt , BatchEvening ) , startedAt , reports , planned )
if err == nil {
t . Fatal ( "buildBatchNotificationRequest() error = nil, want duplicate path error" )
}
for _ , want := range [ ] string { "duplicate bundle path" , "index.md" , "report" , "run" , "source path" } {
if ! strings . Contains ( err . Error ( ) , want ) {
t . Fatalf ( "error = %q, want %q" , err . Error ( ) , want )
}
}
}
func TestBuildBatchNotificationRequestRejectsMissingReportPath ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
startedAt := mustParse ( "2026-05-29T18:00:00-05:00" )
planned , reports := plannedBatchNotificationReports ( t , cfg , BatchEvening , startedAt , "2026-05-31" )
reports [ 0 ] . ReportPath = ""
_ , err := buildBatchNotificationRequest ( cfg , BatchEvening , batchRunID ( startedAt , BatchEvening ) , startedAt , reports , planned )
if err == nil {
t . Fatal ( "buildBatchNotificationRequest() error = nil, want missing path error" )
}
for _ , want := range [ ] string { "missing managed report path" , string ( reports [ 0 ] . ReportID ) , reports [ 0 ] . RunID } {
if ! strings . Contains ( err . Error ( ) , want ) {
t . Fatalf ( "error = %q, want %q" , err . Error ( ) , want )
}
}
}
2026-05-29 18:20:39 +00:00
func TestRunBatchContinuesAfterReportFailure ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-17 21:00:03 +00:00
cfg := dailyNotificationConfig ( t , server )
2026-06-17 16:04:14 +00:00
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
collector := & recordingCollector { result : & collection }
2026-06-17 21:00:03 +00:00
notifier := & recordingNotifier { }
2026-05-29 18:20:39 +00:00
renderer := & selectiveRenderer {
2026-06-17 15:59:13 +00:00
failRenderPrompt : "weather.tomorrow_generated_text" ,
2026-05-29 18:20:39 +00:00
runBody : "# Batch Report\n" ,
}
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
2026-06-17 16:04:14 +00:00
Config : cfg ,
Batch : BatchMorning ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
Renderer : renderer ,
2026-06-17 21:00:03 +00:00
Notifier : notifier ,
2026-05-29 18:20:39 +00:00
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
2026-06-17 16:04:14 +00:00
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 )
2026-05-29 18:20:39 +00:00
}
2026-06-17 16:04:14 +00:00
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want one collection for batch" , len ( collector . requests ) )
}
if renderer . runCalls != 0 || renderer . structuredRunCalls != 2 {
2026-06-15 14:54:37 +00:00
t . Fatalf ( "renderer calls run=%d structured=%d, want successful reports to continue" , renderer . runCalls , renderer . structuredRunCalls )
2026-05-29 18:20:39 +00:00
}
2026-06-17 21:00:03 +00:00
if len ( notifier . requests ) != 0 || len ( notifier . batchRequests ) != 0 {
t . Fatalf ( "notification requests report=%d batch=%d, want none after report failure" , len ( notifier . requests ) , len ( notifier . batchRequests ) )
}
if result . Notification == nil || result . Notification . Status != "skipped" || result . Notification . Reason != "one or more reports failed" {
t . Fatalf ( "batch notification = %#v, want skipped after report failure" , result . Notification )
}
2026-06-17 15:59:13 +00:00
var failedTomorrow bool
2026-06-17 16:04:14 +00:00
var succeededDaily bool
2026-05-29 18:20:39 +00:00
for _ , item := range result . Reports {
2026-06-17 15:59:13 +00:00
if item . ReportID == report . Tomorrow && item . Status == "failed" && strings . Contains ( item . Error , "render failed" ) {
failedTomorrow = true
2026-05-29 18:20:39 +00:00
}
2026-06-17 16:04:14 +00:00
if item . ReportID == report . Daily && item . Status == "succeeded" {
succeededDaily = true
}
2026-06-17 15:59:13 +00:00
if item . ReportID != report . Tomorrow && item . Status != "succeeded" {
2026-05-29 18:20:39 +00:00
t . Fatalf ( "report %s status = %s, want succeeded" , item . ReportID , item . Status )
}
}
2026-06-17 15:59:13 +00:00
if ! failedTomorrow {
t . Fatalf ( "reports = %#v, want failed Tomorrow item" , result . Reports )
2026-05-29 18:20:39 +00:00
}
2026-06-17 16:04:14 +00:00
if ! succeededDaily {
t . Fatalf ( "reports = %#v, want Daily report to continue after Tomorrow failure" , result . Reports )
}
2026-05-29 18:20:39 +00:00
}
2026-06-17 21:00:03 +00:00
func TestRunBatchMorningSendsOneBatchNotification ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
collector := & recordingCollector { result : & collection }
notifier := & recordingNotifier { batchResult : successfulBatchNotificationResult ( ) }
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchMorning ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Total != 3 || result . Succeeded != 3 || result . Failed != 0 {
t . Fatalf ( "summary total/succeeded/failed = %d/%d/%d, want 3/3/0" , result . Total , result . Succeeded , result . Failed )
}
if len ( notifier . requests ) != 0 {
t . Fatalf ( "per-report notification requests = %#v, want none" , notifier . requests )
}
if len ( notifier . batchRequests ) != 1 {
t . Fatalf ( "batch notification requests = %d, want 1" , len ( notifier . batchRequests ) )
}
req := notifier . batchRequests [ 0 ]
if req . Batch != BatchMorning || req . RunID != "20260529T100000.000000000Z_morning" {
t . Fatalf ( "batch request identity = %s/%s, want morning run id" , req . Batch , req . RunID )
}
2026-06-20 02:55:06 +00:00
if len ( req . IncludedReports ) != 3 || len ( req . Files ) != 8 {
t . Fatalf ( "batch request reports/files = %d/%d, want 3/8" , len ( req . IncludedReports ) , len ( req . Files ) )
2026-06-17 21:00:03 +00:00
}
for _ , file := range req . Files {
if file . SourcePath == "" || file . BundlePath == "" {
t . Fatalf ( "batch file = %#v, want source and bundle path" , file )
}
}
if result . Notification == nil || result . Notification . Status != "succeeded" || result . Notification . RunID != "batch-distributor-run" || result . Notification . Path == "" {
t . Fatalf ( "batch notification = %#v, want succeeded result with artifact path" , result . Notification )
}
if len ( result . Notification . IncludedReports ) != 3 {
t . Fatalf ( "batch notification included reports = %d, want 3" , len ( result . Notification . IncludedReports ) )
}
artifact := readBatchNotificationForTest ( t , result . Notification . Path )
if artifact . Status != "succeeded" || artifact . Upload == nil || artifact . Upload . RunID != "batch-distributor-run" || artifact . RunStatus == nil {
t . Fatalf ( "batch notification artifact = %#v, want succeeded upload and run status" , artifact )
}
if len ( artifact . Reports ) != 3 {
t . Fatalf ( "artifact included reports = %d, want 3" , len ( artifact . Reports ) )
}
}
2026-06-17 20:52:27 +00:00
func TestRunBatchSuppressesPerReportNotification ( t * testing . T ) {
2026-06-07 23:38:03 +00:00
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyNotificationConfig ( t , server )
2026-06-17 16:04:14 +00:00
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
collector := & recordingCollector { result : & collection }
2026-06-17 20:52:27 +00:00
store := recordingFilesystemStore ( t , cfg )
notifier := & recordingNotifier { err : errors . New ( "distributor unavailable" ) }
2026-06-07 23:38:03 +00:00
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
2026-06-17 16:04:14 +00:00
Config : cfg ,
2026-06-17 20:52:27 +00:00
Batch : BatchEvening ,
Now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
2026-06-17 16:04:14 +00:00
Collector : collector ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
2026-06-17 20:52:27 +00:00
Store : store ,
2026-06-17 16:04:14 +00:00
Notifier : notifier ,
2026-06-07 23:38:03 +00:00
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
2026-06-17 20:52:27 +00:00
if result . Total != 2 || result . Succeeded != 2 || result . Failed != 0 {
t . Fatalf ( "summary total/succeeded/failed = %d/%d/%d, want 2/2/0" , result . Total , result . Succeeded , result . Failed )
2026-06-17 16:04:14 +00:00
}
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want one collection for batch" , len ( collector . requests ) )
2026-06-07 23:38:03 +00:00
}
2026-06-17 20:52:27 +00:00
if len ( notifier . requests ) != 0 {
t . Fatalf ( "notification requests = %#v, want none for batch-generated reports" , notifier . requests )
2026-06-07 23:38:03 +00:00
}
2026-06-17 21:00:03 +00:00
if len ( notifier . batchRequests ) != 1 {
t . Fatalf ( "batch notification requests = %d, want 1" , len ( notifier . batchRequests ) )
}
2026-06-07 23:38:03 +00:00
for _ , item := range result . Reports {
if item . Status != "succeeded" {
t . Fatalf ( "report %s status = %s, want succeeded" , item . ReportID , item . Status )
}
2026-06-17 20:52:27 +00:00
if item . NotificationStatus != "" || item . NotificationRunID != "" || item . NotificationPipelineID != "" || item . NotificationError != "" || item . NotificationPath != "" {
t . Fatalf ( "report %s notification fields = %#v, want empty per-report notification fields" , item . ReportID , item )
2026-06-07 23:42:27 +00:00
}
2026-06-17 20:52:27 +00:00
metadata := readMetadataForTest ( t , item . MetadataPath )
if metadata . NotificationPath != "" {
t . Fatalf ( "report %s metadata NotificationPath = %q, want empty" , item . ReportID , metadata . NotificationPath )
2026-06-08 07:04:31 -05:00
}
2026-06-07 23:38:03 +00:00
}
2026-06-17 21:00:03 +00:00
for _ , item := range result . Reports {
reportNotificationDir := filepath . Join ( cfg . Workspace . Root , cfg . Workspace . NotificationsDir , string ( item . ReportID ) )
if _ , err := os . Stat ( reportNotificationDir ) ; err == nil {
t . Fatalf ( "per-report notification directory %q exists, want none" , reportNotificationDir )
} else if ! os . IsNotExist ( err ) {
t . Fatalf ( "stat per-report notification directory %q: %v" , reportNotificationDir , err )
}
}
}
func TestRunBatchNotificationFailureKeepsReportItemsSucceeded ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
notifier := & recordingNotifier { batchErr : errors . New ( "batch upload rejected" ) }
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchEvening ,
Now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
Collector : & recordingCollector { result : & collection } ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Failed != 1 || result . Succeeded != 2 {
t . Fatalf ( "summary succeeded/failed = %d/%d, want report successes plus notification failure" , result . Succeeded , result . Failed )
}
for _ , item := range result . Reports {
if item . Status != "succeeded" {
t . Fatalf ( "report %s status = %s, want succeeded despite batch notification failure" , item . ReportID , item . Status )
}
}
if result . Notification == nil || result . Notification . Status != "failed" || ! strings . Contains ( result . Notification . Error , "batch upload rejected" ) || result . Notification . Path == "" {
t . Fatalf ( "batch notification = %#v, want failed upload result" , result . Notification )
}
artifact := readBatchNotificationForTest ( t , result . Notification . Path )
if artifact . Status != "failed" || ! strings . Contains ( artifact . Error , "batch upload rejected" ) {
t . Fatalf ( "batch notification artifact = %#v, want failed upload error" , artifact )
}
err = RunBatch ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchEvening ,
Now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
Collector : & recordingCollector { result : & collection } ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : & recordingNotifier { batchErr : errors . New ( "batch upload rejected" ) } ,
} )
var batchErr BatchError
if ! errors . As ( err , & batchErr ) {
t . Fatalf ( "RunBatch() error = %T %v, want BatchError" , err , err )
}
if batchErr . Result == nil || ! batchNotificationFailed ( batchErr . Result ) || batchReportFailures ( batchErr . Result ) != 0 {
t . Fatalf ( "RunBatch() result = %#v, want notification-only batch failure" , batchErr . Result )
}
}
func TestRunBatchNotificationStatusErrorPersistsStatusReport ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
notifier := & recordingNotifier {
batchResult : & NotificationResult {
RunID : "batch-distributor-run" ,
Status : "accepted" ,
UploadStatus : "accepted" ,
StatusError : "status lookup unavailable" ,
Report : [ ] byte ( ` { "actions":[ { "action":"replace_older"}]} ` ) ,
} ,
}
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchMorning ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : & recordingCollector { result : & collection } ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Failed != 0 || result . Notification == nil || result . Notification . Path == "" {
t . Fatalf ( "result = %#v, want status-error notification artifact without batch failure" , result )
}
artifact := readBatchNotificationForTest ( t , result . Notification . Path )
if artifact . StatusError != "status lookup unavailable" || artifact . RunStatus == nil || ! strings . Contains ( string ( artifact . RunStatus . Report ) , "replace_older" ) {
t . Fatalf ( "batch notification artifact = %#v, want status error and raw status report" , artifact )
}
}
func TestRunBatchDisabledDistributorSkipsBatchNotification ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyWorkspaceConfig ( t , server )
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
notifier := & recordingNotifier { }
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchEvening ,
Now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
Collector : & recordingCollector { result : & collection } ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Notification != nil || result . Failed != 0 {
t . Fatalf ( "result notification/failed = %#v/%d, want disabled notification omitted" , result . Notification , result . Failed )
}
if len ( notifier . requests ) != 0 || len ( notifier . batchRequests ) != 0 {
t . Fatalf ( "notification requests report=%d batch=%d, want none when distributor disabled" , len ( notifier . requests ) , len ( notifier . batchRequests ) )
}
}
func TestRunBatchDisabledBatchNotificationSkipsNotifier ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
cfg . Notify . Distributor . Batch . Enabled = false
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
notifier := & recordingNotifier { }
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchEvening ,
Now : mustParse ( "2026-05-29T18:00:00-05:00" ) ,
Collector : & recordingCollector { result : & collection } ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Notification != nil || result . Failed != 0 {
t . Fatalf ( "result notification/failed = %#v/%d, want disabled batch notification omitted" , result . Notification , result . Failed )
}
if len ( notifier . requests ) != 0 || len ( notifier . batchRequests ) != 0 {
t . Fatalf ( "notification requests report=%d batch=%d, want none when batch notification disabled" , len ( notifier . requests ) , len ( notifier . batchRequests ) )
2026-06-15 12:28:54 +00:00
}
2026-06-07 23:38:03 +00:00
}
2026-05-29 18:20:39 +00:00
func TestRunBatchUsesOutputDirectory ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
2026-06-17 16:04:14 +00:00
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" )
cfg . WeatherAPI . BaseURL = ""
collector := & recordingCollector { result : & collection }
2026-05-29 18:20:39 +00:00
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 ,
2026-06-17 16:04:14 +00:00
Collector : collector ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
2026-05-29 18:20:39 +00:00
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
2026-06-17 16:04:14 +00:00
if result . Failed != 0 || len ( result . Reports ) != 2 {
t . Fatalf ( "summary = %#v, want two successful reports" , result )
2026-05-29 18:20:39 +00:00
}
2026-06-17 16:04:14 +00:00
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want one collection for evening batch" , len ( collector . requests ) )
2026-06-15 12:28:54 +00:00
}
2026-06-17 16:04:14 +00:00
wantByReport := map [ report . ID ] string {
report . Tomorrow : filepath . Join ( outputDir , "tomorrow.md" ) ,
report . Daily : filepath . Join ( outputDir , "daily-2026-05-31.md" ) ,
2026-06-15 12:28:54 +00:00
}
2026-06-17 16:04:14 +00:00
for _ , item := range result . Reports {
want := wantByReport [ item . ReportID ]
if want == "" {
t . Fatalf ( "unexpected report item = %#v" , item )
}
if item . OutputPath != want {
t . Fatalf ( "%s OutputPath = %q, want %q" , item . ReportID , item . OutputPath , want )
}
if _ , err := os . Stat ( want ) ; err != nil {
t . Fatalf ( "expected output copy %q: %v" , want , err )
}
reportData , err := os . ReadFile ( item . ReportPath )
if err != nil {
t . Fatalf ( "read managed report: %v" , err )
}
copyData , err := os . ReadFile ( want )
if err != nil {
t . Fatalf ( "read output copy: %v" , err )
}
if string ( copyData ) != string ( reportData ) {
t . Fatalf ( "batch output copy differs from managed report for %s" , item . ReportID )
}
2026-06-15 12:28:54 +00:00
}
2026-05-29 18:20:39 +00:00
}
2026-06-17 14:39:53 -05:00
func TestRunBatchDynamicDailyReportsHaveDistinctIdentity ( t * testing . T ) {
server := dailyBundleServer ( t )
cfg := dailyNotificationConfig ( t , server )
collection := collectionWithFutureDailyForTest ( t , cfg , "2026-05-31" , "2026-06-01" )
cfg . WeatherAPI . BaseURL = ""
collector := & recordingCollector { result : & collection }
notifier := & recordingNotifier { }
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 ,
Collector : collector ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
Notifier : notifier ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
if result . Failed != 0 || len ( result . Reports ) != 3 {
t . Fatalf ( "summary = %#v, want three successful reports" , result )
}
2026-06-17 20:52:27 +00:00
if len ( notifier . requests ) != 0 {
t . Fatalf ( "notification requests = %#v, want none for batch-generated reports" , notifier . requests )
2026-06-17 14:39:53 -05:00
}
2026-06-17 21:00:03 +00:00
if len ( notifier . batchRequests ) != 1 {
t . Fatalf ( "batch notification requests = %d, want 1" , len ( notifier . batchRequests ) )
}
2026-06-17 14:39:53 -05:00
dailyByDate := map [ string ] BatchReportResult { }
runIDs := map [ string ] struct { } { }
reportPaths := map [ string ] struct { } { }
metadataPaths := map [ string ] struct { } { }
dataPackagePaths := map [ string ] struct { } { }
for _ , item := range result . Reports {
if _ , ok := runIDs [ item . RunID ] ; ok {
t . Fatalf ( "duplicate RunID in batch result: %q" , item . RunID )
}
runIDs [ item . RunID ] = struct { } { }
if _ , ok := reportPaths [ item . ReportPath ] ; ok {
t . Fatalf ( "duplicate ReportPath in batch result: %q" , item . ReportPath )
}
reportPaths [ item . ReportPath ] = struct { } { }
if _ , ok := metadataPaths [ item . MetadataPath ] ; ok {
t . Fatalf ( "duplicate MetadataPath in batch result: %q" , item . MetadataPath )
}
metadataPaths [ item . MetadataPath ] = struct { } { }
if _ , ok := dataPackagePaths [ item . DataPackagePath ] ; ok {
t . Fatalf ( "duplicate DataPackagePath in batch result: %q" , item . DataPackagePath )
}
dataPackagePaths [ item . DataPackagePath ] = struct { } { }
if item . ReportID == report . Daily {
if ! strings . HasPrefix ( item . RunID , "20260529T230000.000000000Z_daily_" ) {
t . Fatalf ( "Daily RunID = %q, want dated daily run id" , item . RunID )
}
date := strings . TrimPrefix ( filepath . Base ( item . OutputPath ) , "daily-" )
date = strings . TrimSuffix ( date , ".md" )
dailyByDate [ date ] = item
}
}
for _ , date := range [ ] string { "2026-05-31" , "2026-06-01" } {
item , ok := dailyByDate [ date ]
if ! ok {
t . Fatalf ( "daily outputs = %#v, want Daily output for %s" , dailyByDate , date )
}
if item . RunID != "20260529T230000.000000000Z_daily_" + date {
t . Fatalf ( "Daily %s RunID = %q, want date disambiguator" , date , item . RunID )
}
if item . OutputPath != filepath . Join ( outputDir , "daily-" + date + ".md" ) {
t . Fatalf ( "Daily %s OutputPath = %q, want date-qualified copy" , date , item . OutputPath )
}
}
2026-06-17 21:00:03 +00:00
if len ( notifier . batchRequests [ 0 ] . IncludedReports ) != len ( result . Reports ) {
t . Fatalf ( "batch included reports = %d, want %d" , len ( notifier . batchRequests [ 0 ] . IncludedReports ) , len ( result . Reports ) )
}
for _ , included := range notifier . batchRequests [ 0 ] . IncludedReports {
if strings . Contains ( included . SourcePath , outputDir ) {
t . Fatalf ( "batch notification source path = %q, want managed report path outside output dir" , included . SourcePath )
}
if _ , ok := reportPaths [ included . SourcePath ] ; ! ok {
t . Fatalf ( "batch notification source path = %q, want one of %#v" , included . SourcePath , reportPaths )
}
}
2026-06-17 14:39:53 -05:00
}
2026-06-15 14:58:36 +00:00
func TestRunBatchMorningUsesTodayOutputName ( t * testing . T ) {
server := dailyBundleServer ( t )
2026-06-16 15:30:23 +00:00
cfg := dailyWorkspaceConfig ( t , server )
2026-06-15 14:58:36 +00:00
outputDir := filepath . Join ( t . TempDir ( ) , "reports" )
result , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
Config : cfg ,
Batch : BatchMorning ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
OutputDir : outputDir ,
Renderer : & selectiveRenderer { runBody : "# Batch Report\n" } ,
} )
if err != nil {
t . Fatalf ( "RunBatchDetailed() error = %v" , err )
}
2026-06-17 15:59:13 +00:00
if result . Failed != 0 || len ( result . Reports ) != 2 {
2026-06-15 14:58:36 +00:00
t . Fatalf ( "summary = %#v, want successful morning batch" , result )
}
var todayItem * BatchReportResult
for i := range result . Reports {
if result . Reports [ i ] . ReportID == report . Today {
todayItem = & result . Reports [ i ]
break
}
}
if todayItem == nil {
t . Fatalf ( "reports = %#v, want Today item" , result . Reports )
}
want := filepath . Join ( outputDir , "today.md" )
if todayItem . OutputPath != want {
t . Fatalf ( "Today OutputPath = %q, want %q" , todayItem . OutputPath , want )
}
if _ , err := os . Stat ( want ) ; err != nil {
t . Fatalf ( "expected Today output copy %q: %v" , want , err )
}
if strings . Contains ( todayItem . ReportPath , outputDir ) {
t . Fatalf ( "Today ReportPath = %q, want managed report path separate from output copy" , todayItem . ReportPath )
}
}
2026-06-17 15:46:01 +00:00
func TestRunBatchDetailedUsesProvidedCollector ( t * testing . T ) {
cfg := config . Defaults ( )
cfg . WeatherAPI . BaseURL = ""
cfg . Workspace . Root = t . TempDir ( )
collector := & recordingCollector { err : errors . New ( "batch collector failed" ) }
renderer := & recordingRenderer { }
2026-06-17 15:59:13 +00:00
_ , err := RunBatchDetailed ( context . Background ( ) , BatchRequest {
2026-06-17 15:46:01 +00:00
Config : cfg ,
Batch : BatchMorning ,
Now : mustParse ( "2026-05-29T05:00:00-05:00" ) ,
Collector : collector ,
Renderer : renderer ,
} )
2026-06-17 15:59:13 +00:00
if err == nil || ! strings . Contains ( err . Error ( ) , "batch collector failed" ) {
t . Fatalf ( "RunBatchDetailed() error = %v, want fake collector error" , err )
2026-06-17 15:46:01 +00:00
}
2026-06-17 15:59:13 +00:00
if len ( collector . requests ) != 1 {
t . Fatalf ( "collector requests = %d, want one planning collection" , len ( collector . requests ) )
2026-06-17 15:46:01 +00:00
}
if renderer . renderCalls != 0 || renderer . runCalls != 0 || renderer . structuredRunCalls != 0 {
t . Fatalf ( "renderer calls render=%d run=%d structured=%d, want none after collection failure" , renderer . renderCalls , renderer . runCalls , renderer . structuredRunCalls )
}
}
2026-05-29 17:17:50 +00:00
func mustParse ( value string ) time . Time {
parsed , err := time . Parse ( time . RFC3339 , value )
if err != nil {
panic ( err )
}
return parsed
}
2026-05-29 17:32:57 +00:00
2026-05-29 20:44:25 +00:00
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
}
2026-06-16 15:30:23 +00:00
func dailyWorkspaceConfig ( t * testing . T , server * httptest . Server ) config . Config {
t . Helper ( )
cfg := dailyTestConfig ( t , server )
cfg . Workspace . Root = t . TempDir ( )
return cfg
}
func dailyNotificationConfig ( t * testing . T , server * httptest . Server ) config . Config {
t . Helper ( )
cfg := dailyWorkspaceConfig ( t , server )
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{artifact_group}"
return cfg
}
2026-06-20 02:55:06 +00:00
func notificationMetadataForTest ( resolved report . Resolved ) state . Metadata {
metadata := resolved . Metadata ( )
return state . Metadata {
RunID : metadata . RunID ,
ReportID : metadata . ReportID ,
PromptID : metadata . PromptID ,
GeneratedAt : metadata . GeneratedAt ,
Timezone : metadata . Timezone ,
ValidPeriod : metadata . ValidPeriod ,
}
}
func applyReportDistributorPathOverrides ( t * testing . T , cfg * config . Config , data string ) {
t . Helper ( )
path := filepath . Join ( t . TempDir ( ) , "config.yml" )
if err := os . WriteFile ( path , [ ] byte ( data ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config fixture: %v" , err )
}
loaded , err := config . LoadFile ( path )
if err != nil {
t . Fatalf ( "LoadFile() error = %v" , err )
}
if cfg . Reports == nil {
cfg . Reports = map [ string ] config . ReportConfig { }
}
for key , reportCfg := range loaded . Reports {
cfg . Reports [ key ] = reportCfg
}
}
2026-06-17 20:48:38 +00:00
func plannedBatchNotificationReports ( t * testing . T , cfg config . Config , batch BatchKind , now time . Time , futureDailyDates ... string ) ( [ ] plannedBatchReport , [ ] BatchReportResult ) {
t . Helper ( )
collection := collectionWithFutureDailyForTest ( t , cfg , futureDailyDates ... )
planned , err := planBatchRun ( BatchRequest { Config : cfg , Batch : batch } , now , collection )
if err != nil {
t . Fatalf ( "planBatchRun() error = %v" , err )
}
reportDir := filepath . Join ( t . TempDir ( ) , "managed-reports" )
results := make ( [ ] BatchReportResult , 0 , len ( planned ) )
for _ , item := range planned {
metadata := item . Resolved . Metadata ( )
results = append ( results , BatchReportResult {
ReportID : item . Resolved . Definition . ID ,
RunID : metadata . RunID ,
Status : "succeeded" ,
ReportPath : filepath . Join ( reportDir , string ( item . Resolved . Definition . ID ) , metadata . RunID + ".md" ) ,
} )
}
return planned , results
}
2026-06-17 15:50:04 +00:00
func collectionForTest ( t * testing . T , cfg config . Config ) collect . Result {
t . Helper ( )
bundle , err := FetchBundle ( context . Background ( ) , FetchBundleRequest { Config : cfg } )
if err != nil {
t . Fatalf ( "FetchBundle() error = %v" , err )
}
return collect . Result { Bundle : bundle }
}
2026-06-17 14:39:53 -05:00
func collectionWithFutureDailyForTest ( t * testing . T , cfg config . Config , dates ... string ) collect . Result {
2026-06-17 16:04:14 +00:00
t . Helper ( )
collection := collectionForTest ( t , cfg )
location := mustLoadTestLocation ( t , cfg . WeatherAPI . Timezone )
2026-06-17 14:39:53 -05:00
for _ , date := range dates {
collection . Bundle . Hourly . Periods = append ( collection . Bundle . Hourly . Periods , fullDayPeriods ( t , date , location ) ... )
}
2026-06-17 16:04:14 +00:00
return collection
}
2026-06-16 15:30:23 +00:00
func resolveGenerateForTest ( t * testing . T , cfg config . Config , req GenerateRequest , now string ) report . Resolved {
t . Helper ( )
req . Config = cfg
resolved , err := ResolveGenerate ( req , mustParse ( now ) )
if err != nil {
t . Fatalf ( "ResolveGenerate() error = %v" , err )
}
return resolved
}
func recordingFilesystemStore ( t * testing . T , cfg config . Config ) * recordingStore {
t . Helper ( )
filesystemStore , err := state . NewFilesystemStore ( cfg . Workspace )
if err != nil {
t . Fatalf ( "NewFilesystemStore() error = %v" , err )
}
return & recordingStore { Store : filesystemStore }
}
func successfulNotificationResult ( ) * NotificationResult {
return & NotificationResult {
RunID : "distributor-run" ,
Status : "succeeded" ,
UploadStatus : "accepted" ,
PipelineID : "reports" ,
Report : [ ] byte ( ` { "actions":[ { "action":"replace_older"}]} ` ) ,
}
}
2026-06-17 21:00:03 +00:00
func successfulBatchNotificationResult ( ) * NotificationResult {
return & NotificationResult {
RunID : "batch-distributor-run" ,
Status : "succeeded" ,
UploadStatus : "accepted" ,
Report : [ ] byte ( ` { "actions":[ { "action":"replace_older"}]} ` ) ,
}
}
2026-06-16 15:30:23 +00:00
func successfulGeneratedTextRenderer ( body string ) * recordingRenderer {
return & recordingRenderer {
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult { ExitCode : 0 } ,
structuredRunBody : body ,
}
}
func hourlyTestConfig ( t * testing . T , server * httptest . Server ) config . Config {
t . Helper ( )
cfg := config . Defaults ( )
cfg . WeatherAPI . BaseURL = server . URL + "/"
cfg . WeatherAPI . Timezone = "America/Chicago"
cfg . Workspace . Root = t . TempDir ( )
cfg . Location . ID = "home"
cfg . Location . Name = "Brentwood"
cfg . Location . Region = "MO"
return cfg
}
2026-06-14 05:08:26 +00:00
func hourlyGeneratedTextFixture ( t * testing . T ) ( config . Config , report . Resolved , * recordingStore , * recordingNotifier , string ) {
t . Helper ( )
server := hourlyBundleServer ( t )
cfg := hourlyGeneratedTextConfig ( t , server )
resolved , store , notifier , outputPath := resolveHourlyGeneratedTextFixture ( t , cfg )
return cfg , resolved , store , notifier , outputPath
}
func hourlyGeneratedTextConfig ( t * testing . T , server * httptest . Server ) config . Config {
t . Helper ( )
cfg := config . Defaults ( )
applyHourlyGeneratedTextSettings ( & cfg , t , server )
return cfg
}
func hourlyGeneratedTextConfigWithModules ( t * testing . T , server * httptest . Server , modules [ ] string ) config . Config {
t . Helper ( )
var data strings . Builder
data . WriteString ( "reports:\n hourly:\n deterministic_modules:\n" )
for _ , id := range modules {
_ , _ = fmt . Fprintf ( & data , " - %s\n" , id )
}
path := filepath . Join ( t . TempDir ( ) , "config.yml" )
if err := os . WriteFile ( path , [ ] byte ( data . String ( ) ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config fixture: %v" , err )
}
cfg , err := config . LoadFile ( path )
if err != nil {
t . Fatalf ( "LoadFile() error = %v" , err )
}
applyHourlyGeneratedTextSettings ( & cfg , t , server )
return cfg
}
func applyHourlyGeneratedTextSettings ( cfg * config . Config , t * testing . T , server * httptest . Server ) {
t . Helper ( )
2026-06-16 15:30:23 +00:00
base := hourlyTestConfig ( t , server )
cfg . WeatherAPI = base . WeatherAPI
cfg . Workspace = base . Workspace
cfg . Location = base . Location
2026-06-14 05:08:26 +00:00
cfg . Notify . Distributor . Enabled = true
cfg . Notify . Distributor . PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg . Notify . Distributor . BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
cfg . Notify . Distributor . IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}"
}
func resolveHourlyGeneratedTextFixture ( t * testing . T , cfg config . Config ) ( report . Resolved , * recordingStore , * recordingNotifier , string ) {
t . Helper ( )
2026-06-16 15:30:23 +00:00
resolved := resolveGenerateForTest ( t , cfg , GenerateRequest { Report : ReportHourly } , "2026-05-29T08:30:00-05:00" )
return resolved , recordingFilesystemStore ( t , cfg ) , & recordingNotifier { } , filepath . Join ( t . TempDir ( ) , "hourly-copy.md" )
2026-06-14 05:08:26 +00:00
}
func validHourlyGeneratedTextJSON ( ) string {
2026-06-14 12:54:32 -05:00
return ` { "summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"} `
2026-06-14 05:08:26 +00:00
}
2026-06-14 23:44:31 +00:00
func validTomorrowGeneratedTextJSON ( ) string {
return ` { "summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."} `
}
2026-06-15 14:54:37 +00:00
func validTodayGeneratedTextJSON ( ) string {
return ` { "summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."} `
}
2026-06-15 16:43:06 +00:00
func validDailyGeneratedTextJSON ( ) string {
return ` { "summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"} `
}
2026-06-12 15:14:09 +00:00
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 {
2026-06-17 15:50:04 +00:00
Config : cfg ,
Collection : collectionForTest ( t , cfg ) ,
Resolved : resolved ,
Renderer : successfulRenderer ( "# Daily Report\n" ) ,
2026-06-12 15:14:09 +00:00
} )
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
}
2026-06-15 20:42:48 +00:00
func assertRichPromptHelperArtifacts ( t * testing . T , result * ReportResult ) {
t . Helper ( )
snapshotData , err := os . ReadFile ( result . ModuleSnapshotPath )
if err != nil {
t . Fatalf ( "read module snapshot: %v" , err )
}
for _ , want := range [ ] string {
` "condition_text_lower" ` ,
` "hour_label" ` ,
` "text_description_lower" ` ,
` "mention_precipitation" ` ,
` "temperature_phrase_f" ` ,
` "dominant_condition_lower" ` ,
` "dominant_condition_display" ` ,
` "max_pop_time_label" ` ,
} {
if ! strings . Contains ( string ( snapshotData ) , want ) {
t . Fatalf ( "module snapshot missing rich helper field %q:\n%s" , want , string ( snapshotData ) )
}
}
renderContext , err := os . ReadFile ( result . RenderContextPath )
if err != nil {
t . Fatalf ( "read render context: %v" , err )
}
for _ , want := range [ ] string {
` "condition_text_lower" ` ,
` "hour_label" ` ,
` "text_description_lower" ` ,
` "mention_precipitation" ` ,
` "temperature_phrase_f" ` ,
` "dominant_condition_lower" ` ,
` "dominant_condition_display" ` ,
` "max_pop_time_label" ` ,
} {
if ! strings . Contains ( string ( renderContext ) , want ) {
t . Fatalf ( "render context missing rich helper field %q:\n%s" , want , string ( renderContext ) )
}
}
}
func assertCuratedPromptDataPackage ( t * testing . T , result * ReportResult ) {
t . Helper ( )
data := readDataPackageForTest ( t , result )
pkg , err := promptinput . LoadYAML ( data )
if err != nil {
t . Fatalf ( "decode data package: %v" , err )
}
current , ok := pkg . Briefing . Values [ "current_conditions" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "current_conditions = %#v, want prompt map" , pkg . Briefing . Values [ "current_conditions" ] )
}
assertMapOmitsKeys ( t , "current_conditions" , current , "condition_text_lower" , "wind_direction_text" )
hourly , ok := pkg . Briefing . Values [ "hourly_forecast" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "hourly_forecast = %#v, want prompt map" , pkg . Briefing . Values [ "hourly_forecast" ] )
}
periods , ok := hourly [ "periods" ] . ( [ ] any )
if ! ok || len ( periods ) == 0 {
t . Fatalf ( "hourly_forecast.periods = %#v, want prompt periods" , hourly [ "periods" ] )
}
firstPeriod , ok := periods [ 0 ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "hourly first period = %#v, want prompt map" , periods [ 0 ] )
}
assertMapOmitsKeys ( t , "hourly_forecast.periods[0]" , firstPeriod , "hour_label" , "text_description_lower" , "mention_precipitation" )
dayparts , ok := pkg . Briefing . Values [ "derived_daypart_summaries" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "derived_daypart_summaries = %#v, want prompt map" , pkg . Briefing . Values [ "derived_daypart_summaries" ] )
}
morning , ok := dayparts [ "morning" ] . ( map [ string ] any )
if ! ok {
t . Fatalf ( "derived_daypart_summaries.morning = %#v, want prompt map" , dayparts [ "morning" ] )
}
if morning [ "max_pop_time" ] != "6:00 AM" {
t . Fatalf ( "derived_daypart_summaries.morning.max_pop_time = %#v, want friendly label" , morning [ "max_pop_time" ] )
}
assertMapOmitsKeys ( t , "derived_daypart_summaries.morning" , morning , "temperature_phrase_f" , "dominant_condition_lower" , "dominant_condition_display" , "max_pop_time_label" )
}
func assertMapOmitsKeys ( t * testing . T , name string , value map [ string ] any , keys ... string ) {
t . Helper ( )
for _ , key := range keys {
if _ , ok := value [ key ] ; ok {
t . Fatalf ( "%s contains helper field %q: %#v" , name , key , value )
}
}
}
2026-06-12 12:00:40 -05:00
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 )
}
}
}
2026-05-29 20:44:25 +00:00
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 )
}
}
}
2026-06-14 05:08:26 +00:00
func assertPathsMissing ( t * testing . T , paths ... string ) {
t . Helper ( )
for _ , path := range paths {
if _ , err := os . Stat ( path ) ; err == nil {
t . Fatalf ( "artifact %q exists, want missing" , path )
} else if ! os . IsNotExist ( err ) {
t . Fatalf ( "stat artifact %q: %v" , path , err )
}
}
}
func hourlyArtifactPaths ( t * testing . T , store state . Store , resolved report . Resolved ) state . ArtifactPaths {
t . Helper ( )
paths , err := store . Paths ( resolved )
if err != nil {
t . Fatalf ( "Paths() error = %v" , err )
}
return paths
}
2026-06-15 12:28:54 +00:00
func readMetadataForTest ( t * testing . T , path string ) state . Metadata {
t . Helper ( )
data , err := os . ReadFile ( path )
if err != nil {
t . Fatalf ( "read metadata %q: %v" , path , err )
}
var metadata state . Metadata
if err := json . Unmarshal ( data , & metadata ) ; err != nil {
t . Fatalf ( "decode metadata %q: %v" , path , err )
}
return metadata
}
2026-06-17 21:00:03 +00:00
func readBatchNotificationForTest ( t * testing . T , path string ) state . BatchDistributorNotificationArtifact {
t . Helper ( )
data , err := os . ReadFile ( path )
if err != nil {
t . Fatalf ( "read batch notification %q: %v" , path , err )
}
var artifact state . BatchDistributorNotificationArtifact
if err := json . Unmarshal ( data , & artifact ) ; err != nil {
t . Fatalf ( "decode batch notification %q: %v" , path , err )
}
return artifact
}
2026-06-14 05:08:26 +00:00
func assertGeneratedReportError ( t * testing . T , err error , resolved report . Resolved , operation string ) {
t . Helper ( )
if err == nil {
t . Fatal ( "GenerateReport() error = nil, want generated-text report error" )
}
text := err . Error ( )
for _ , want := range [ ] string {
fmt . Sprintf ( "generate report %q" , resolved . Definition . ID ) ,
fmt . Sprintf ( "run %q" , resolved . Metadata ( ) . RunID ) ,
operation ,
} {
if ! strings . Contains ( text , want ) {
t . Fatalf ( "error = %q, want %q" , text , want )
}
}
}
func assertNoGeneratedFailureSideEffects ( t * testing . T , notifier * recordingNotifier , outputPath string ) {
t . Helper ( )
if len ( notifier . requests ) != 0 {
t . Fatalf ( "notification requests = %#v, want none after generated-text failure" , notifier . requests )
}
assertPathsMissing ( t , outputPath )
}
2026-06-09 21:33:34 +00:00
func savePriorRun ( t * testing . T , store state . Store , resolved report . Resolved , snapshot module . Snapshot ) {
2026-06-09 21:20:53 +00:00
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 )
}
2026-06-09 21:33:34 +00:00
_ , err = store . SaveMetadata ( context . Background ( ) , state . BuildMetadataFromBriefingMetadata ( resolved , appBriefingMetadata ( resolved ) , state . ArtifactPaths {
2026-06-09 21:20:53 +00:00
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 {
2026-06-10 09:35:39 -05:00
"date" : resolved . ValidPeriod . Start . Format ( timeutil . DateLayout ) ,
"low_temp_f" : low ,
"high_temp_f" : high ,
"daily_precipitation_probability" : precip ,
2026-06-09 21:20:53 +00:00
} } ,
{ ID : module . DerivedDaypartSummaries , StanzaName : "derived_daypart_summaries" , Value : map [ string ] any {
"morning" : map [ string ] any {
2026-06-12 12:00:40 -05:00
"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" ,
2026-06-09 21:20:53 +00:00
} ,
} } ,
{ 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 {
2026-06-10 10:36:52 -05:00
"date" : date ,
2026-06-12 12:00:40 -05:00
"period_begins" : date + " at 6:00 AM" ,
"period_ends" : date + " at 10:00 AM" ,
2026-06-09 21:20:53 +00:00
"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
}
2026-06-09 21:33:34 +00:00
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 ,
2026-05-29 18:16:10 +00:00
}
}
2026-05-29 17:32:57 +00:00
type recordingRenderer struct {
2026-06-14 05:01:26 +00:00
renderCalls int
runCalls int
structuredRunCalls int
renderRequest scriptorium . RenderRequest
runRequest scriptorium . RunRequest
structuredRunRequest scriptorium . StructuredRunRequest
renderResult * scriptorium . RenderResult
runResult * scriptorium . RunResult
structuredRunResult * scriptorium . StructuredRunResult
err error
runErr error
structuredRunErr error
runBody string
structuredRunBody string
2026-05-29 17:32:57 +00:00
}
2026-06-17 15:46:01 +00:00
type recordingCollector struct {
result * collect . Result
err error
requests [ ] collect . Request
}
func ( c * recordingCollector ) Run ( _ context . Context , req collect . Request ) ( * collect . Result , error ) {
c . requests = append ( c . requests , req )
if c . err != nil {
return nil , c . err
}
return c . result , nil
}
2026-06-09 21:12:13 +00:00
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 )
}
2026-06-14 05:01:26 +00:00
func ( s * recordingStore ) SaveGeneratedTextRaw ( ctx context . Context , resolved report . Resolved , data [ ] byte ) ( string , error ) {
s . calls = append ( s . calls , "generated_text_raw" )
return s . Store . SaveGeneratedTextRaw ( ctx , resolved , data )
}
func ( s * recordingStore ) SaveGeneratedTextResult ( ctx context . Context , resolved report . Resolved , value any ) ( string , error ) {
s . calls = append ( s . calls , "generated_text_result" )
return s . Store . SaveGeneratedTextResult ( ctx , resolved , value )
}
func ( s * recordingStore ) SaveGeneratedText ( ctx context . Context , resolved report . Resolved , data [ ] byte ) ( string , error ) {
s . calls = append ( s . calls , "generated_text" )
return s . Store . SaveGeneratedText ( ctx , resolved , data )
}
func ( s * recordingStore ) SaveRenderContext ( ctx context . Context , resolved report . Resolved , value any ) ( string , error ) {
s . calls = append ( s . calls , "render_context" )
return s . Store . SaveRenderContext ( ctx , resolved , value )
}
2026-06-09 21:12:13 +00:00
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 )
}
2026-06-07 23:38:03 +00:00
func successfulRenderer ( body string ) * recordingRenderer {
return & recordingRenderer {
2026-06-15 16:43:06 +00:00
renderResult : & scriptorium . RenderResult { ExitCode : 0 } ,
runResult : & scriptorium . RunResult { ExitCode : 0 } ,
structuredRunResult : & scriptorium . StructuredRunResult { ExitCode : 0 } ,
runBody : body ,
2026-06-07 23:38:03 +00:00
}
}
2026-05-29 18:20:39 +00:00
type selectiveRenderer struct {
2026-06-14 05:01:26 +00:00
renderCalls int
runCalls int
structuredRunCalls int
failRenderPrompt string
runBody string
2026-05-29 18:20:39 +00:00
}
2026-06-07 23:38:03 +00:00
type recordingNotifier struct {
2026-06-17 21:00:03 +00:00
requests [ ] NotificationRequest
batchRequests [ ] batchNotificationRequest
result * NotificationResult
batchResult * NotificationResult
err error
batchErr error
errByReport map [ report . ID ] error
2026-06-07 23:38:03 +00:00
}
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
}
2026-06-08 07:04:31 -05:00
if result . PipelineID == "" {
result . PipelineID = req . PipelineID
}
2026-06-07 23:38:03 +00:00
return & result , nil
}
return & NotificationResult {
2026-06-08 07:04:31 -05:00
PipelineID : req . PipelineID ,
2026-06-07 23:38:03 +00:00
BundleID : req . BundleID ,
IdempotencyKey : req . IdempotencyKey ,
Status : "accepted" ,
2026-06-07 20:21:26 -05:00
UploadStatus : "accepted" ,
2026-06-07 23:38:03 +00:00
} , nil
}
2026-06-17 21:00:03 +00:00
func ( n * recordingNotifier ) NotifyBatch ( _ context . Context , req batchNotificationRequest ) ( * NotificationResult , error ) {
n . batchRequests = append ( n . batchRequests , req )
if n . batchErr != nil {
return nil , n . batchErr
}
if n . batchResult != nil {
result := * n . batchResult
if result . 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 ,
RunID : "batch-distributor-run" ,
Status : "accepted" ,
UploadStatus : "accepted" ,
} , nil
}
2026-05-29 18:20:39 +00:00
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 ) , 0 o600 ) ; err != nil {
return nil , err
}
}
return & scriptorium . RunResult { ExitCode : 0 , OutputPath : req . OutputPath } , nil
}
2026-06-14 05:01:26 +00:00
func ( r * selectiveRenderer ) StructuredRun ( _ context . Context , req scriptorium . StructuredRunRequest ) ( * scriptorium . StructuredRunResult , error ) {
r . structuredRunCalls ++
2026-06-14 23:44:31 +00:00
body := validHourlyGeneratedTextJSON ( )
2026-06-15 14:54:37 +00:00
if req . PromptID == "weather.today_generated_text" {
body = validTodayGeneratedTextJSON ( )
}
2026-06-14 23:44:31 +00:00
if req . PromptID == "weather.tomorrow_generated_text" {
body = validTomorrowGeneratedTextJSON ( )
}
2026-06-15 16:43:06 +00:00
if req . PromptID == "weather.daily_generated_text" {
body = validDailyGeneratedTextJSON ( )
}
2026-06-14 23:44:31 +00:00
if err := os . WriteFile ( req . OutputPath , [ ] byte ( body ) , 0 o600 ) ; err != nil {
return nil , err
}
2026-06-14 05:01:26 +00:00
return & scriptorium . StructuredRunResult { ExitCode : 0 , OutputPath : req . OutputPath } , nil
}
2026-05-29 17:32:57 +00:00
func ( r * recordingRenderer ) Render ( _ context . Context , req scriptorium . RenderRequest ) ( * scriptorium . RenderResult , error ) {
2026-05-29 17:47:42 +00:00
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 ) , 0 o600 ) ; err != nil {
return nil , err
}
}
if r . runResult != nil {
r . runResult . OutputPath = req . OutputPath
}
return r . runResult , r . runErr
2026-05-29 17:32:57 +00:00
}
2026-06-14 05:01:26 +00:00
func ( r * recordingRenderer ) StructuredRun ( _ context . Context , req scriptorium . StructuredRunRequest ) ( * scriptorium . StructuredRunResult , error ) {
r . structuredRunCalls ++
r . structuredRunRequest = req
2026-06-15 16:43:06 +00:00
body := r . structuredRunBody
if body == "" {
body = validGeneratedTextJSONForPrompt ( req . PromptID )
}
if body != "" {
if err := os . WriteFile ( req . OutputPath , [ ] byte ( body ) , 0 o600 ) ; err != nil {
2026-06-14 05:01:26 +00:00
return nil , err
}
}
if r . structuredRunResult != nil {
r . structuredRunResult . OutputPath = req . OutputPath
}
return r . structuredRunResult , r . structuredRunErr
}
2026-06-15 16:43:06 +00:00
func validGeneratedTextJSONForPrompt ( promptID string ) string {
switch promptID {
case "weather.daily_generated_text" :
return validDailyGeneratedTextJSON ( )
case "weather.today_generated_text" :
return validTodayGeneratedTextJSON ( )
case "weather.tomorrow_generated_text" :
return validTomorrowGeneratedTextJSON ( )
case "weather.hourly_generated_text" :
return validHourlyGeneratedTextJSON ( )
default :
return ""
}
}