2026-05-29 16:52:02 +00:00
package cli
import (
"bytes"
"context"
2026-05-29 18:20:39 +00:00
"encoding/json"
2026-05-29 17:23:49 +00:00
"net/http"
"net/http/httptest"
"os"
"path/filepath"
2026-05-29 16:52:02 +00:00
"strings"
"testing"
2026-05-29 16:58:18 +00:00
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
2026-05-29 16:52:02 +00:00
)
func TestRunHelpLongFlag ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
err := Run ( context . Background ( ) , [ ] string { "--help" } , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-05-29 16:58:18 +00:00
if ! strings . Contains ( stdout . String ( ) , "generate daily" ) {
t . Fatalf ( "help output missing generate command:\n%s" , stdout . String ( ) )
2026-05-29 16:52:02 +00:00
}
}
func TestRunHelpShortFlag ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
err := Run ( context . Background ( ) , [ ] string { "-h" } , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-05-29 16:58:18 +00:00
if ! strings . Contains ( stdout . String ( ) , "weatherreporter run evening" ) {
t . Fatalf ( "help output missing run command:\n%s" , stdout . String ( ) )
}
}
func TestRunUnknownCommand ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
2026-05-29 18:33:24 +00:00
err := Run ( context . Background ( ) , [ ] string { "unknown" } , & stdout , & stderr )
2026-05-29 16:58:18 +00:00
if err == nil {
t . Fatal ( "Run() error = nil, want unknown command error" )
}
2026-05-29 18:33:24 +00:00
if ! strings . Contains ( err . Error ( ) , ` unknown command "unknown" ` ) {
2026-05-29 16:58:18 +00:00
t . Fatalf ( "Run() error = %q, want unknown command message" , err . Error ( ) )
2026-05-29 16:52:02 +00:00
}
}
2026-05-29 18:28:25 +00:00
func TestRunGenerateStormWritesMarkdownReport ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
outPath := filepath . Join ( tempDir , "storm.md" )
2026-05-29 16:52:02 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
2026-05-29 16:58:18 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-05-29 18:28:25 +00:00
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "storm" ,
"--config" , configPath ,
"--start" , "2026-05-29T06:00" ,
"--end" , "2026-05-29T10:00" ,
"--out" , outPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
report , err := os . ReadFile ( outPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
if ! strings . Contains ( string ( report ) , "# Daily Report" ) {
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "storm" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
data , err := os . ReadFile ( dataPackageMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
2026-05-29 16:58:18 +00:00
}
2026-05-29 18:28:25 +00:00
if ! strings . Contains ( string ( data ) , ` "storm" ` ) || ! strings . Contains ( string ( data ) , ` "weather.storm_report" ` ) {
t . Fatalf ( "data package output missing storm content:\n%s" , string ( data ) )
2026-05-29 16:58:18 +00:00
}
}
2026-05-29 18:00:27 +00:00
func TestRunGenerateTomorrowWritesMarkdownReport ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
outPath := filepath . Join ( tempDir , "tomorrow.md" )
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "tomorrow" ,
"--config" , configPath ,
"--out" , outPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
report , err := os . ReadFile ( outPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
if ! strings . Contains ( string ( report ) , "# Daily Report" ) {
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-30" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
data , err := os . ReadFile ( dataPackageMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
}
if ! strings . Contains ( string ( data ) , ` "daily_tomorrow" ` ) || ! strings . Contains ( string ( data ) , ` "planning" ` ) {
t . Fatalf ( "data package output missing tomorrow content:\n%s" , string ( data ) )
}
reportMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "reports" , "daily" , "*.md" ) )
if err != nil {
t . Fatalf ( "glob managed report: %v" , err )
}
if len ( reportMatches ) != 1 || ! strings . Contains ( filepath . Base ( reportMatches [ 0 ] ) , "daily_tomorrow" ) {
t . Fatalf ( "managed reports = %#v, want tomorrow report" , reportMatches )
}
}
func TestRunEveningGeneratesTomorrowReport ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "evening" ,
"--config" , configPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-30" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
reportMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "reports" , "daily" , "*.md" ) )
if err != nil {
t . Fatalf ( "glob managed report: %v" , err )
}
if len ( reportMatches ) != 1 || ! strings . Contains ( filepath . Base ( reportMatches [ 0 ] ) , "daily_tomorrow" ) {
t . Fatalf ( "managed reports = %#v, want only tomorrow report" , reportMatches )
}
}
2026-05-29 18:08:50 +00:00
func TestRunGenerateThreeDayWritesMarkdownReport ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
outPath := filepath . Join ( tempDir , "three-day.md" )
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "three-day" ,
"--config" , configPath ,
"--out" , outPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
report , err := os . ReadFile ( outPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
if ! strings . Contains ( string ( report ) , "# Daily Report" ) {
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "three-day" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
data , err := os . ReadFile ( dataPackageMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
}
if ! strings . Contains ( string ( data ) , ` "three_day" ` ) || ! strings . Contains ( string ( data ) , ` "threeDay" ` ) {
t . Fatalf ( "data package output missing 3-day content:\n%s" , string ( data ) )
}
}
2026-05-29 18:16:10 +00:00
func TestRunGenerateWeekendWritesMarkdownReport ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
outPath := filepath . Join ( tempDir , "weekend.md" )
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "weekend" ,
"--config" , configPath ,
"--out" , outPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
report , err := os . ReadFile ( outPath )
if err != nil {
t . Fatalf ( "read report: %v" , err )
}
if ! strings . Contains ( string ( report ) , "# Daily Report" ) {
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "weekend" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
data , err := os . ReadFile ( dataPackageMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
}
if ! strings . Contains ( string ( data ) , ` "weekend" ` ) || ! strings . Contains ( string ( data ) , ` "planning" ` ) {
t . Fatalf ( "data package output missing weekend content:\n%s" , string ( data ) )
}
}
func TestRunMorningIncludesWeekendExceptSunday ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "morning" ,
"--config" , configPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
weekendPackages , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "weekend" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob weekend packages: %v" , err )
}
if len ( weekendPackages ) != 1 {
t . Fatalf ( "weekend packages = %#v, want one" , weekendPackages )
}
}
2026-05-29 18:20:39 +00:00
func TestRunMorningReportsPartialFailureAndContinues ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFailingScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "morning" ,
"--config" , configPath ,
} , & stdout , & stderr )
if err == nil {
t . Fatal ( "Run() error = nil, want aggregate failure" )
}
if ! strings . Contains ( err . Error ( ) , "1 of 3 reports failed" ) {
t . Fatalf ( "Run() error = %q, want aggregate failure" , err . Error ( ) )
}
var summary app . BatchResult
if decodeErr := json . Unmarshal ( stdout . Bytes ( ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , stdout . String ( ) )
}
if summary . Total != 3 || summary . Succeeded != 2 || summary . Failed != 1 {
t . Fatalf ( "summary total/succeeded/failed = %d/%d/%d, want 3/2/1" , summary . Total , summary . Succeeded , summary . Failed )
}
if ! strings . Contains ( stderr . String ( ) , "status=failed" ) || ! strings . Contains ( stderr . String ( ) , "status=succeeded" ) {
t . Fatalf ( "stderr missing structured report logs:\n%s" , stderr . String ( ) )
}
dailyPackages , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob daily packages: %v" , err )
}
weekendPackages , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "weekend" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob weekend packages: %v" , err )
}
if len ( dailyPackages ) != 1 || len ( weekendPackages ) != 1 {
t . Fatalf ( "daily packages = %#v, weekend packages = %#v; want successful reports to continue" , dailyPackages , weekendPackages )
}
}
func TestRunEveningUsesOutputDirectoryAndSummary ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
outputDir := filepath . Join ( tempDir , "copies" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "evening" ,
"--config" , configPath ,
"--out-dir" , outputDir ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
var summary app . BatchResult
if decodeErr := json . Unmarshal ( stdout . Bytes ( ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , stdout . String ( ) )
}
if summary . Total != 1 || summary . Failed != 0 {
t . Fatalf ( "summary total/failed = %d/%d, want 1/0" , summary . Total , summary . Failed )
}
if _ , err := os . Stat ( filepath . Join ( outputDir , "tomorrow.md" ) ) ; err != nil {
t . Fatalf ( "expected copied report: %v" , err )
}
if len ( summary . Reports ) != 1 || summary . Reports [ 0 ] . OutputPath != filepath . Join ( outputDir , "tomorrow.md" ) {
t . Fatalf ( "summary reports = %#v, want output path" , summary . Reports )
}
}
2026-05-29 18:08:50 +00:00
func TestRunMorningGeneratesDailyAndThreeDayOnSunday ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : timeutil . FixedClock { Time : time . Date ( 2026 , 5 , 31 , 12 , 0 , 0 , 0 , time . UTC ) } }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "morning" ,
"--config" , configPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
dailyPackages , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-31" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob daily packages: %v" , err )
}
threeDayPackages , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "three-day" , "2026-05-31" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob 3-day packages: %v" , err )
}
if len ( dailyPackages ) != 1 || len ( threeDayPackages ) != 1 {
t . Fatalf ( "daily packages = %#v, 3-day packages = %#v; want one each" , dailyPackages , threeDayPackages )
}
}
2026-05-29 17:47:42 +00:00
func TestRunGenerateDailyWritesMarkdownReport ( t * testing . T ) {
2026-05-29 17:23:49 +00:00
server := dailyServer ( t )
2026-05-29 17:32:57 +00:00
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
2026-05-29 17:23:49 +00:00
t . Fatalf ( "write config: %v" , err )
}
2026-05-29 17:47:42 +00:00
outPath := filepath . Join ( tempDir , "daily.md" )
2026-05-29 17:23:49 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "daily" ,
"--config" , configPath ,
"--date" , "2026-05-29" ,
"--out" , outPath ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-05-29 17:47:42 +00:00
report , err := os . ReadFile ( outPath )
2026-05-29 17:23:49 +00:00
if err != nil {
2026-05-29 17:47:42 +00:00
t . Fatalf ( "read report: %v" , err )
}
if ! strings . Contains ( string ( report ) , "# Daily Report" ) {
t . Fatalf ( "report output missing markdown:\n%s" , string ( report ) )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
data , err := os . ReadFile ( dataPackageMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
2026-05-29 17:23:49 +00:00
}
2026-05-29 17:32:57 +00:00
if ! strings . Contains ( string ( data ) , ` data_package.v1 ` ) || ! strings . Contains ( string ( data ) , ` "daily_today" ` ) {
t . Fatalf ( "data package output missing expected content:\n%s" , string ( data ) )
}
preflightMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "preflight" , "daily" , "2026-05-29" , "*.render.json" ) )
if err != nil {
t . Fatalf ( "glob preflight: %v" , err )
}
if len ( preflightMatches ) != 1 {
t . Fatalf ( "preflight files = %#v, want one render output" , preflightMatches )
}
preflight , err := os . ReadFile ( preflightMatches [ 0 ] )
if err != nil {
t . Fatalf ( "read preflight: %v" , err )
}
if ! strings . Contains ( string ( preflight ) , ` ok ` ) {
t . Fatalf ( "preflight missing fake render output:\n%s" , string ( preflight ) )
2026-05-29 17:23:49 +00:00
}
2026-05-29 17:47:42 +00:00
reportMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "reports" , "daily" , "*.md" ) )
if err != nil {
t . Fatalf ( "glob managed report: %v" , err )
}
if len ( reportMatches ) != 1 {
t . Fatalf ( "managed reports = %#v, want one" , reportMatches )
}
2026-05-29 17:23:49 +00:00
}
2026-05-29 18:33:24 +00:00
func TestRunInspectGeneratedArtifacts ( t * testing . T ) {
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
configPath := filepath . Join ( tempDir , "config.yml" )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
runner := Runner { Clock : fixedClock ( ) }
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "daily" ,
"--config" , configPath ,
"--date" , "2026-05-29" ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run(generate) error = %v" , err )
}
dataPackageMatches , err := filepath . Glob ( filepath . Join ( workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.json" ) )
if err != nil {
t . Fatalf ( "glob data package: %v" , err )
}
if len ( dataPackageMatches ) != 1 {
t . Fatalf ( "data package files = %#v, want one" , dataPackageMatches )
}
runID := strings . TrimSuffix ( filepath . Base ( dataPackageMatches [ 0 ] ) , ".data_package.json" )
stdout . Reset ( )
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , "reports" , "--config" , configPath , "--limit" , "1" } , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run(inspect reports) error = %v" , err )
}
if ! strings . Contains ( stdout . String ( ) , runID ) || ! strings . Contains ( stdout . String ( ) , ` "metadataPath" ` ) {
t . Fatalf ( "inspect reports output missing run:\n%s" , stdout . String ( ) )
}
for _ , command := range [ ] string { "metadata" , "briefing" , "data-package" , "sources" } {
stdout . Reset ( )
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , command , "--config" , configPath , runID } , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run(inspect %s) error = %v" , command , err )
}
if ! strings . Contains ( stdout . String ( ) , runID ) {
t . Fatalf ( "inspect %s output missing run id:\n%s" , command , stdout . String ( ) )
}
}
if ! strings . Contains ( stdout . String ( ) , ` "warnings" ` ) {
t . Fatalf ( "inspect sources output missing warnings:\n%s" , stdout . String ( ) )
}
}
func TestRunInspectMissingMetadata ( t * testing . T ) {
tempDir := t . TempDir ( )
configPath := filepath . Join ( tempDir , "config.yml" )
configBody := "workspace:\n root: " + filepath . Join ( tempDir , "workspace" ) + "\n"
if err := os . WriteFile ( configPath , [ ] byte ( configBody ) , 0 o600 ) ; err != nil {
t . Fatalf ( "write config: %v" , err )
}
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string { "inspect" , "metadata" , "--config" , configPath , "missing" } , & stdout , & stderr )
if err == nil {
t . Fatal ( "Run(inspect metadata) 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 16:58:18 +00:00
func TestResolveGenerateCommands ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
tests := [ ] struct {
name string
args [ ] string
want app . ReportKind
} {
{ name : "daily" , args : [ ] string { "daily" , "--date" , "2026-05-29" } , want : app . ReportDaily } ,
{ name : "tomorrow" , args : [ ] string { "tomorrow" } , want : app . ReportTomorrow } ,
{ name : "three-day" , args : [ ] string { "three-day" } , want : app . ReportThreeDay } ,
{ name : "weekend" , args : [ ] string { "weekend" } , want : app . ReportWeekend } ,
{ name : "storm" , args : [ ] string { "storm" , "--start" , "2026-05-29T18:00" , "--end" , "2026-05-30T06:00" } , want : app . ReportStorm } ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
req , err := runner . resolveGenerate ( tt . args )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
if req . Report != tt . want {
t . Fatalf ( "Report = %q, want %q" , req . Report , tt . want )
}
} )
}
}
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
req , err := runner . resolveGenerate ( [ ] string { "daily" } )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
if got := req . Date . Format ( timeutil . DateLayout ) ; got != "2026-05-29" {
t . Fatalf ( "Date = %s, want 2026-05-29" , got )
}
}
2026-05-29 16:52:02 +00:00
2026-05-29 16:58:18 +00:00
func TestResolveGenerateAppliesSharedFlags ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
req , err := runner . resolveGenerate ( [ ] string { "daily" , "--units" , "metric" , "--tz" , "UTC" , "--out" , "./daily.md" } )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
if req . Config . WeatherAPI . Units != "metric" {
t . Fatalf ( "Units = %q, want metric" , req . Config . WeatherAPI . Units )
}
if req . Config . WeatherAPI . Timezone != "UTC" {
t . Fatalf ( "Timezone = %q, want UTC" , req . Config . WeatherAPI . Timezone )
}
if req . OutputPath != "./daily.md" {
t . Fatalf ( "OutputPath = %q, want ./daily.md" , req . OutputPath )
}
}
func TestResolveGenerateStormRequiresStartAndEnd ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
_ , err := runner . resolveGenerate ( [ ] string { "storm" , "--start" , "2026-05-29T18:00" } )
2026-05-29 16:52:02 +00:00
if err == nil {
2026-05-29 16:58:18 +00:00
t . Fatal ( "resolveGenerate() error = nil, want missing end error" )
}
if ! strings . Contains ( err . Error ( ) , "requires --end" ) {
t . Fatalf ( "error = %q, want missing end" , err . Error ( ) )
2026-05-29 16:52:02 +00:00
}
2026-05-29 16:58:18 +00:00
}
func TestResolveGenerateStormParsesRFC3339 ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
2026-05-29 16:52:02 +00:00
2026-05-29 16:58:18 +00:00
req , err := runner . resolveGenerate ( [ ] string {
"storm" ,
"--start" , "2026-05-29T18:00:00-05:00" ,
"--end" , "2026-05-30T06:00:00-05:00" ,
} )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
2026-05-29 16:52:02 +00:00
}
2026-05-29 16:58:18 +00:00
if ! req . StormEnd . After ( req . StormStart ) {
t . Fatalf ( "StormEnd = %s, want after %s" , req . StormEnd , req . StormStart )
}
}
func TestResolveRunCommands ( t * testing . T ) {
tests := [ ] struct {
name string
args [ ] string
want app . BatchKind
} {
{ name : "morning" , args : [ ] string { "morning" } , want : app . BatchMorning } ,
{ name : "evening" , args : [ ] string { "evening" , "--tz" , "UTC" } , want : app . BatchEvening } ,
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
req , err := resolveRun ( tt . args )
if err != nil {
t . Fatalf ( "resolveRun() error = %v" , err )
}
if req . Batch != tt . want {
t . Fatalf ( "Batch = %q, want %q" , req . Batch , tt . want )
}
} )
}
}
func TestResolveRunRejectsOutputFlag ( t * testing . T ) {
_ , err := resolveRun ( [ ] string { "morning" , "--out" , "./report.md" } )
if err == nil {
t . Fatal ( "resolveRun() error = nil, want flag error" )
}
if ! strings . Contains ( err . Error ( ) , "flag provided but not defined" ) {
t . Fatalf ( "error = %q, want undefined flag error" , err . Error ( ) )
}
}
2026-05-29 18:20:39 +00:00
func TestResolveRunAppliesOutputDirectory ( t * testing . T ) {
req , err := resolveRun ( [ ] string { "evening" , "--out-dir" , "./reports" } )
if err != nil {
t . Fatalf ( "resolveRun() error = %v" , err )
}
if req . OutputDir != "./reports" {
t . Fatalf ( "OutputDir = %q, want ./reports" , req . OutputDir )
}
}
2026-05-29 16:58:18 +00:00
func fixedClock ( ) timeutil . Clock {
return timeutil . FixedClock { Time : time . Date ( 2026 , 5 , 29 , 12 , 0 , 0 , 0 , time . UTC ) }
2026-05-29 16:52:02 +00:00
}
2026-05-29 17:23:49 +00:00
func dailyServer ( t * testing . T ) * httptest . Server {
t . Helper ( )
server := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
switch r . URL . Path {
case "/observations" :
_ , _ = w . Write ( [ ] byte ( ` { "data": { "timestamp":"2026-05-29T14:00:00Z","conditionCode":3}} ` ) )
case "/conditions/current" :
_ , _ = w . Write ( [ ] byte ( ` { "data": { "conditionText":"Clear"}} ` ) )
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":[]}} ` ) )
case "/discussion" :
_ , _ = w . Write ( [ ] byte ( ` { "data": { "product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}} ` ) )
default :
http . NotFound ( w , r )
}
} ) )
t . Cleanup ( server . Close )
return server
}
2026-05-29 17:32:57 +00:00
func writeFakeScriptorium ( t * testing . T , dir string ) string {
t . Helper ( )
path := filepath . Join ( dir , "scriptorium" )
2026-05-29 17:47:42 +00:00
body := ` # ! / bin / sh
if [ "$1" = "render" ] ; then
printf ' { "ok" : true , "argv" : "%s" } ' "$*"
exit 0
fi
if [ "$1" = "run" ] ; then
out = ""
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--out" ] ; then
shift
out = "$1"
fi
shift
done
printf ' # Daily Report \ n \ nGenerated by fake scriptorium . \ n ' > "$out"
printf ' wrote report \ n ' > & 2
exit 0
fi
printf ' unexpected command \ n ' > & 2
exit 1
`
2026-05-29 17:32:57 +00:00
if err := os . WriteFile ( path , [ ] byte ( body ) , 0 o700 ) ; err != nil {
t . Fatalf ( "write fake scriptorium: %v" , err )
}
return path
}
2026-05-29 18:20:39 +00:00
func writeFailingScriptorium ( t * testing . T , dir string ) string {
t . Helper ( )
path := filepath . Join ( dir , "scriptorium" )
body := ` # ! / bin / sh
if [ "$1" = "render" ] ; then
prompt = ""
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--prompt" ] ; then
shift
prompt = "$1"
fi
shift
done
if [ "$prompt" = "weather.three_day_outlook" ] ; then
printf ' render failed \ n ' > & 2
exit 1
fi
printf ' { "ok" : true , "prompt" : "%s" } ' "$prompt"
exit 0
fi
if [ "$1" = "run" ] ; then
out = ""
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--out" ] ; then
shift
out = "$1"
fi
shift
done
printf ' # Batch Report \ n \ nGenerated by fake scriptorium . \ n ' > "$out"
exit 0
fi
printf ' unexpected command \ n ' > & 2
exit 1
`
if err := os . WriteFile ( path , [ ] byte ( body ) , 0 o700 ) ; err != nil {
t . Fatalf ( "write fake scriptorium: %v" , err )
}
return path
}