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"
2026-06-15 12:33:55 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
2026-05-29 16:58:18 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
2026-05-29 16:52:02 +00:00
)
func TestRunHelpLongFlag ( t * testing . T ) {
2026-06-15 12:48:36 +00:00
output , err := runRootCommand ( t , "--help" )
2026-05-29 16:52:02 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 16:46:10 +00:00
if ! strings . Contains ( output . stdout , "weatherreporter generate daily --date YYYY-MM-DD" ) {
2026-06-15 12:48:36 +00:00
t . Fatalf ( "help output missing generate command:\n%s" , output . stdout )
2026-05-29 16:52:02 +00:00
}
2026-06-15 14:58:36 +00:00
if ! strings . Contains ( output . stdout , "generate today" ) {
t . Fatalf ( "help output missing today generate command:\n%s" , output . stdout )
}
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( output . stdout , "weatherreporter generate hourly" ) {
t . Fatalf ( "help output missing hourly generate command:\n%s" , output . stdout )
2026-06-12 17:36:58 +00:00
}
2026-06-14 05:24:25 +00:00
removedGenerateCommand := "generate " + strings . Join ( [ ] string { "near" , "term" } , "-" )
2026-06-15 12:48:36 +00:00
if strings . Contains ( output . stdout , removedGenerateCommand ) {
t . Fatalf ( "help output includes retired generate command:\n%s" , output . stdout )
2026-06-14 05:24:25 +00:00
}
2026-06-09 20:58:35 +00:00
removedInspectCommand := "inspect " + "briefing"
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( output . stdout , "inspect modules" ) || strings . Contains ( output . stdout , removedInspectCommand ) {
t . Fatalf ( "help output has wrong inspect commands:\n%s" , output . stdout )
2026-06-09 20:58:35 +00:00
}
2026-05-29 16:52:02 +00:00
}
func TestRunHelpShortFlag ( t * testing . T ) {
2026-06-15 12:48:36 +00:00
output , err := runRootCommand ( t , "-h" )
2026-05-29 16:52:02 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( output . stdout , "weatherreporter run evening" ) {
t . Fatalf ( "help output missing run command:\n%s" , output . stdout )
2026-05-29 16:58:18 +00:00
}
}
func TestRunUnknownCommand ( t * testing . T ) {
2026-06-15 12:48:36 +00:00
_ , err := runRootCommand ( t , "unknown" )
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 ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outPath := fixture . path ( "storm.md" )
2026-05-29 16:58:18 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:28:25 +00:00
"generate" , "storm" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:28:25 +00:00
"--start" , "2026-05-29T06:00" ,
"--end" , "2026-05-29T10:00" ,
"--out" , outPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:28:25 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 12:48:36 +00:00
assertFileContains ( t , outPath , "# Daily Report" )
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "storm" , "2026-05-29" , "*.data_package.yaml" )
2026-06-15 12:48:36 +00:00
assertFileContains ( t , dataPackagePath , "id: storm" )
assertFileContains ( t , dataPackagePath , "prompt_id: weather.storm_report" )
2026-05-29 16:58:18 +00:00
}
2026-05-29 18:00:27 +00:00
func TestRunGenerateTomorrowWritesMarkdownReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outPath := fixture . path ( "tomorrow.md" )
2026-05-29 18:00:27 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:00:27 +00:00
"generate" , "tomorrow" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:00:27 +00:00
"--out" , outPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:00:27 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 12:48:36 +00:00
assertFileContains ( t , outPath , "# Saturday's Weather" )
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "tomorrow" , "2026-05-30" , "*.data_package.yaml" )
2026-06-15 12:48:36 +00:00
assertFileContains ( t , dataPackagePath , "id: tomorrow" )
assertFileContains ( t , dataPackagePath , "tomorrow_planning:" )
2026-06-16 15:23:03 +00:00
reportPath := oneArtifact ( t , fixture . workspaceRoot , "reports" , "tomorrow" , "*.md" )
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( filepath . Base ( reportPath ) , "tomorrow" ) {
t . Fatalf ( "managed report = %q, want tomorrow report" , reportPath )
2026-05-29 18:00:27 +00:00
}
}
func TestRunEveningGeneratesTomorrowReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
2026-05-29 18:00:27 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:00:27 +00:00
"run" , "evening" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:00:27 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-16 15:23:03 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "tomorrow" , "2026-05-30" , "*.data_package.yaml" )
reportPath := oneArtifact ( t , fixture . workspaceRoot , "reports" , "tomorrow" , "*.md" )
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( filepath . Base ( reportPath ) , "tomorrow" ) {
t . Fatalf ( "managed report = %q, want only tomorrow report" , reportPath )
2026-05-29 18:00:27 +00:00
}
}
2026-05-29 18:08:50 +00:00
func TestRunGenerateThreeDayWritesMarkdownReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outPath := fixture . path ( "three-day.md" )
2026-05-29 18:08:50 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:08:50 +00:00
"generate" , "three-day" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:08:50 +00:00
"--out" , outPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:08:50 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 12:48:36 +00:00
assertFileContains ( t , outPath , "# Daily Report" )
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "three-day" , "2026-05-29" , "*.data_package.yaml" )
2026-06-15 12:48:36 +00:00
assertFileContains ( t , dataPackagePath , "id: three_day" )
assertFileContains ( t , dataPackagePath , "derived_daypart_summaries:" )
2026-05-29 18:08:50 +00:00
}
2026-05-29 18:16:10 +00:00
func TestRunGenerateWeekendWritesMarkdownReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outPath := fixture . path ( "weekend.md" )
2026-05-29 18:16:10 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:16:10 +00:00
"generate" , "weekend" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:16:10 +00:00
"--out" , outPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:16:10 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-15 12:48:36 +00:00
assertFileContains ( t , outPath , "# Daily Report" )
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "weekend" , "2026-05-29" , "*.data_package.yaml" )
2026-06-15 12:48:36 +00:00
assertFileContains ( t , dataPackagePath , "id: weekend" )
assertFileContains ( t , dataPackagePath , "derived_daypart_summaries:" )
2026-05-29 18:16:10 +00:00
}
2026-06-17 15:59:13 +00:00
func TestRunMorningGeneratesTodayAndTomorrow ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
2026-05-29 18:16:10 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
_ , err := runTestCommand ( t , runner ,
2026-05-29 18:16:10 +00:00
"run" , "morning" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:16:10 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-16 15:23:03 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "today" , "2026-05-29" , "*.data_package.yaml" )
2026-06-17 15:59:13 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "tomorrow" , "2026-05-30" , "*.data_package.yaml" )
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "three-day" , "2026-05-29" , "*.data_package.yaml" )
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "weekend" , "2026-05-29" , "*.data_package.yaml" )
2026-06-16 15:23:03 +00:00
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.yaml" )
2026-05-29 18:16:10 +00:00
}
2026-05-29 18:20:39 +00:00
func TestRunMorningReportsPartialFailureAndContinues ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFailingScriptorium )
2026-05-29 18:20:39 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
output , err := runTestCommand ( t , runner ,
2026-05-29 18:20:39 +00:00
"run" , "morning" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:20:39 +00:00
if err == nil {
t . Fatal ( "Run() error = nil, want aggregate failure" )
}
2026-06-17 15:59:13 +00:00
if ! strings . Contains ( err . Error ( ) , "1 of 2 reports failed" ) {
2026-05-29 18:20:39 +00:00
t . Fatalf ( "Run() error = %q, want aggregate failure" , err . Error ( ) )
}
var summary app . BatchResult
2026-06-15 12:48:36 +00:00
if decodeErr := json . Unmarshal ( [ ] byte ( output . stdout ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , output . stdout )
2026-05-29 18:20:39 +00:00
}
2026-06-17 15:59:13 +00:00
if summary . Total != 2 || summary . Succeeded != 1 || summary . Failed != 1 {
t . Fatalf ( "summary total/succeeded/failed = %d/%d/%d, want 2/1/1" , summary . Total , summary . Succeeded , summary . Failed )
2026-05-29 18:20:39 +00:00
}
2026-06-15 12:48:36 +00:00
if ! strings . Contains ( output . stderr , "status=failed" ) || ! strings . Contains ( output . stderr , "status=succeeded" ) {
t . Fatalf ( "stderr missing structured report logs:\n%s" , output . stderr )
2026-05-29 18:20:39 +00:00
}
2026-06-16 15:23:03 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "today" , "2026-05-29" , "*.data_package.yaml" )
2026-06-17 15:59:13 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "tomorrow" , "2026-05-30" , "*.data_package.yaml" )
2026-05-29 18:20:39 +00:00
}
2026-06-17 21:03:14 +00:00
func TestBatchOutputIncludesTopLevelNotificationDetails ( t * testing . T ) {
2026-06-07 23:42:27 +00:00
result := & app . BatchResult {
Batch : app . BatchMorning ,
Total : 2 ,
2026-06-17 21:03:14 +00:00
Succeeded : 2 ,
Failed : 0 ,
Notification : & app . BatchNotificationResult {
Status : "succeeded" ,
RunID : "batch-distributor-run" ,
PipelineID : "weatherreporter" ,
BundleID : "weatherreporter.home.morning" ,
IdempotencyKey : "weatherreporter.home.morning.20260529T120000.000000000Z_morning" ,
Path : "/tmp/batch.distributor.json" ,
IncludedReports : [ ] app . BatchNotificationReport {
{ ReportID : "daily" , RunID : "daily-run" , SourcePath : "/tmp/daily.md" , BundlePaths : [ ] string { "daily.md" } } ,
} ,
} ,
2026-06-07 23:42:27 +00:00
Reports : [ ] app . BatchReportResult {
{
2026-06-17 21:03:14 +00:00
ReportID : "daily" ,
Status : "succeeded" ,
OutputPath : "/tmp/daily.md" ,
2026-06-07 23:42:27 +00:00
} ,
{
2026-06-17 21:03:14 +00:00
ReportID : "tomorrow" ,
Status : "succeeded" ,
OutputPath : "/tmp/tomorrow.md" ,
2026-06-07 23:42:27 +00:00
} ,
} ,
}
var stdout bytes . Buffer
var stderr bytes . Buffer
if err := writeJSON ( & stdout , result ) ; err != nil {
t . Fatalf ( "writeJSON() error = %v" , err )
}
writeRunLogs ( & stderr , result )
var decoded app . BatchResult
if err := json . Unmarshal ( stdout . Bytes ( ) , & decoded ) ; err != nil {
t . Fatalf ( "decode batch JSON: %v\n%s" , err , stdout . String ( ) )
}
2026-06-17 21:03:14 +00:00
if decoded . Notification == nil || decoded . Notification . Status != "succeeded" || decoded . Notification . RunID != "batch-distributor-run" || decoded . Notification . PipelineID != "weatherreporter" || len ( decoded . Notification . IncludedReports ) != 1 {
t . Fatalf ( "top-level notification = %#v, want succeeded batch notification" , decoded . Notification )
2026-06-07 23:42:27 +00:00
}
2026-06-17 21:03:14 +00:00
for _ , report := range decoded . Reports {
if report . NotificationStatus != "" || report . NotificationRunID != "" || report . NotificationError != "" {
t . Fatalf ( "report notification fields = %#v, want empty" , report )
}
}
if count := strings . Count ( stderr . String ( ) , "batchNotification " ) ; count != 1 {
t . Fatalf ( "stderr batch notification lines = %d, want one:\n%s" , count , stderr . String ( ) )
2026-06-07 23:42:27 +00:00
}
2026-06-17 21:03:14 +00:00
if ! strings . Contains ( stderr . String ( ) , ` batchNotification status="succeeded" ` ) || ! strings . Contains ( stderr . String ( ) , ` runId="batch-distributor-run" ` ) || ! strings . Contains ( stderr . String ( ) , ` pipelineId="weatherreporter" ` ) {
t . Fatalf ( "stderr missing batch notification details:\n%s" , stderr . String ( ) )
2026-06-07 23:42:27 +00:00
}
2026-06-17 21:03:14 +00:00
if strings . Contains ( stderr . String ( ) , "notificationStatus" ) || strings . Contains ( stderr . String ( ) , "notificationRunId" ) {
t . Fatalf ( "stderr includes per-report notification fields:\n%s" , stderr . String ( ) )
2026-06-07 23:42:27 +00:00
}
}
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors ( t * testing . T ) {
result := & app . BatchResult {
Batch : app . BatchMorning ,
Total : 1 ,
Failed : 1 ,
2026-06-17 21:03:14 +00:00
Notification : & app . BatchNotificationResult {
Status : "failed" ,
Error : "notify batch morning: upload failed: [redacted]" ,
} ,
2026-06-07 23:42:27 +00:00
Reports : [ ] app . BatchReportResult {
{
2026-06-17 21:03:14 +00:00
ReportID : "daily" ,
Status : "succeeded" ,
2026-06-07 23:42:27 +00:00
} ,
} ,
}
var stdout bytes . Buffer
var stderr bytes . Buffer
if err := writeJSON ( & stdout , result ) ; err != nil {
t . Fatalf ( "writeJSON() error = %v" , err )
}
writeRunLogs ( & stderr , result )
for _ , output := range [ ] string { stdout . String ( ) , stderr . String ( ) } {
if strings . Contains ( output , "DISTRIBUTOR_SECRET_TOKEN" ) {
t . Fatalf ( "output contains token value:\n%s" , output )
}
if ! strings . Contains ( output , "[redacted]" ) {
t . Fatalf ( "output missing redacted marker:\n%s" , output )
}
}
}
2026-06-17 21:03:14 +00:00
func TestRunLogsIncludeSkippedBatchNotification ( t * testing . T ) {
result := & app . BatchResult {
Batch : app . BatchMorning ,
Total : 2 ,
Succeeded : 1 ,
Failed : 1 ,
Notification : & app . BatchNotificationResult {
Status : "skipped" ,
Reason : "one or more reports failed" ,
} ,
Reports : [ ] app . BatchReportResult {
{ ReportID : "today" , Status : "succeeded" , OutputPath : "/tmp/today.md" } ,
{ ReportID : "tomorrow" , Status : "failed" , Error : "render failed" } ,
} ,
}
var stderr bytes . Buffer
var stdout bytes . Buffer
if err := writeJSON ( & stdout , result ) ; err != nil {
t . Fatalf ( "writeJSON() error = %v" , err )
}
writeRunLogs ( & stderr , result )
var decoded app . BatchResult
if err := json . Unmarshal ( stdout . Bytes ( ) , & decoded ) ; err != nil {
t . Fatalf ( "decode batch JSON: %v\n%s" , err , stdout . String ( ) )
}
if decoded . Notification == nil || decoded . Notification . Status != "skipped" || decoded . Notification . Reason != "one or more reports failed" {
t . Fatalf ( "top-level notification = %#v, want skipped notification" , decoded . Notification )
}
if count := strings . Count ( stderr . String ( ) , "batchNotification " ) ; count != 1 {
t . Fatalf ( "stderr batch notification lines = %d, want one:\n%s" , count , stderr . String ( ) )
}
if ! strings . Contains ( stderr . String ( ) , ` batchNotification status="skipped" reason="one or more reports failed" ` ) {
t . Fatalf ( "stderr missing skipped batch notification:\n%s" , stderr . String ( ) )
}
}
func TestRunLogsDoNotRepeatBatchNotificationErrorPerReport ( t * testing . T ) {
result := & app . BatchResult {
Batch : app . BatchEvening ,
Total : 1 ,
Succeeded : 1 ,
Failed : 1 ,
Notification : & app . BatchNotificationResult {
Status : "failed" ,
Error : "notify batch evening: upload failed" ,
} ,
Reports : [ ] app . BatchReportResult {
{ ReportID : "tomorrow" , Status : "succeeded" , OutputPath : "/tmp/tomorrow.md" } ,
} ,
}
var stderr bytes . Buffer
writeRunLogs ( & stderr , result )
if count := strings . Count ( stderr . String ( ) , "batchNotification " ) ; count != 1 {
t . Fatalf ( "stderr batch notification lines = %d, want one:\n%s" , count , stderr . String ( ) )
}
if count := strings . Count ( stderr . String ( ) , "notify batch evening: upload failed" ) ; count != 1 {
t . Fatalf ( "stderr batch notification error occurrences = %d, want one:\n%s" , count , stderr . String ( ) )
}
reportLine := firstLineWithPrefix ( stderr . String ( ) , "report=tomorrow " )
if strings . Contains ( reportLine , "notify batch evening" ) || strings . Contains ( reportLine , "notificationError" ) {
t . Fatalf ( "report line repeats batch notification error:\n%s" , reportLine )
}
}
2026-05-29 18:20:39 +00:00
func TestRunEveningUsesOutputDirectoryAndSummary ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outputDir := fixture . path ( "copies" )
2026-05-29 18:20:39 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-15 12:48:36 +00:00
output , err := runTestCommand ( t , runner ,
2026-05-29 18:20:39 +00:00
"run" , "evening" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:20:39 +00:00
"--out-dir" , outputDir ,
2026-06-15 12:48:36 +00:00
)
2026-05-29 18:20:39 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
var summary app . BatchResult
2026-06-15 12:48:36 +00:00
if decodeErr := json . Unmarshal ( [ ] byte ( output . stdout ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , output . stdout )
2026-05-29 18:20:39 +00:00
}
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-06-17 20:52:27 +00:00
func TestRunEveningReportsOmitsPerReportNotification ( t * testing . T ) {
2026-06-07 23:42:27 +00:00
server := dailyServer ( t )
2026-06-17 21:00:03 +00:00
var uploadCount int
2026-06-07 23:42:27 +00:00
distributorServer := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2026-06-17 21:00:03 +00:00
if r . URL . Path == "/runs/batch-distributor-run" {
w . Header ( ) . Set ( "Content-Type" , "application/json" )
_ , _ = w . Write ( [ ] byte ( ` { "run_id":"batch-distributor-run","pipeline_id":"weatherreporter","status":"succeeded","report": { "actions":[ { "action":"replace_older"}]}} ` ) )
return
}
if r . URL . Path != "/v1/pipelines/weatherreporter/upload" {
http . NotFound ( w , r )
return
}
uploadCount ++
w . WriteHeader ( http . StatusAccepted )
_ , _ = w . Write ( [ ] byte ( ` { "run_id":"batch-distributor-run","status":"accepted"} ` ) )
2026-06-07 23:42:27 +00:00
} ) )
t . Cleanup ( distributorServer . Close )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
2026-06-16 15:23:03 +00:00
configPath := writeTestConfigWithDistributor ( t , server , scriptoriumPath , workspaceRoot , distributorServer . URL )
2026-06-07 23:42:27 +00:00
t . Setenv ( "CLI_DISTRIBUTOR_TOKEN" , "cli-secret-token" )
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 )
}
var summary app . BatchResult
if decodeErr := json . Unmarshal ( stdout . Bytes ( ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , stdout . String ( ) )
}
if len ( summary . Reports ) != 1 {
t . Fatalf ( "reports = %#v, want one report" , summary . Reports )
}
2026-06-17 21:00:03 +00:00
if uploadCount != 1 {
t . Fatalf ( "batch upload count = %d, want 1" , uploadCount )
}
if summary . Notification == nil || summary . Notification . Status != "succeeded" || summary . Notification . RunID != "batch-distributor-run" {
t . Fatalf ( "batch notification = %#v, want succeeded batch notification" , summary . Notification )
}
2026-06-17 21:03:14 +00:00
if count := strings . Count ( stderr . String ( ) , "batchNotification " ) ; count != 1 {
t . Fatalf ( "stderr batch notification lines = %d, want one:\n%s" , count , stderr . String ( ) )
}
if ! strings . Contains ( stderr . String ( ) , ` batchNotification status="succeeded" ` ) || ! strings . Contains ( stderr . String ( ) , ` runId="batch-distributor-run" ` ) {
t . Fatalf ( "stderr missing batch notification success:\n%s" , stderr . String ( ) )
}
2026-06-17 20:52:27 +00:00
if summary . Reports [ 0 ] . NotificationStatus != "" || summary . Reports [ 0 ] . NotificationRunID != "" || summary . Reports [ 0 ] . NotificationPipelineID != "" || summary . Reports [ 0 ] . NotificationError != "" || summary . Reports [ 0 ] . NotificationPath != "" {
t . Fatalf ( "notification fields = %#v, want empty per-report notification fields" , summary . Reports [ 0 ] )
2026-06-07 23:42:27 +00:00
}
2026-06-17 20:52:27 +00:00
if strings . Contains ( stderr . String ( ) , "notificationStatus" ) || strings . Contains ( stderr . String ( ) , "notificationRunId" ) {
t . Fatalf ( "stderr includes per-report notification fields:\n%s" , stderr . String ( ) )
2026-06-07 23:42:27 +00:00
}
if strings . Contains ( stdout . String ( ) , "cli-secret-token" ) || strings . Contains ( stderr . String ( ) , "cli-secret-token" ) {
t . Fatalf ( "output contains token value\nstdout=%s\nstderr=%s" , stdout . String ( ) , stderr . String ( ) )
}
}
2026-06-17 20:52:27 +00:00
func TestRunEveningReportsDoesNotRequirePerReportDistributorToken ( t * testing . T ) {
2026-06-07 23:42:27 +00:00
server := dailyServer ( t )
distributorServer := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
2026-06-17 20:52:27 +00:00
t . Fatalf ( "unexpected distributor request %s" , r . URL . Path )
2026-06-07 23:42:27 +00:00
} ) )
t . Cleanup ( distributorServer . Close )
tempDir := t . TempDir ( )
scriptoriumPath := writeFakeScriptorium ( t , tempDir )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
2026-06-17 21:00:03 +00:00
configPath := writeTestConfigWithDisabledBatchDistributor ( t , server , scriptoriumPath , workspaceRoot , distributorServer . URL )
2026-06-07 23:42:27 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"run" , "evening" ,
"--config" , configPath ,
} , & stdout , & stderr )
2026-06-17 20:52:27 +00:00
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
2026-06-07 23:42:27 +00:00
}
var summary app . BatchResult
if decodeErr := json . Unmarshal ( stdout . Bytes ( ) , & summary ) ; decodeErr != nil {
t . Fatalf ( "decode summary: %v\n%s" , decodeErr , stdout . String ( ) )
}
2026-06-17 20:52:27 +00:00
if len ( summary . Reports ) != 1 {
t . Fatalf ( "summary reports = %#v, want one report" , summary . Reports )
2026-06-07 23:42:27 +00:00
}
2026-06-17 21:00:03 +00:00
if summary . Notification != nil {
t . Fatalf ( "batch notification = %#v, want omitted when disabled" , summary . Notification )
}
2026-06-17 20:52:27 +00:00
if summary . Reports [ 0 ] . NotificationStatus != "" || summary . Reports [ 0 ] . NotificationError != "" {
t . Fatalf ( "notification fields = %#v, want empty per-report notification fields" , summary . Reports [ 0 ] )
2026-06-07 23:42:27 +00:00
}
}
2026-06-17 15:59:13 +00:00
func TestRunMorningGeneratesTodayAndTomorrowOnSunday ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
2026-05-29 18:08:50 +00:00
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" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:08:50 +00:00
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run() error = %v" , err )
}
2026-06-16 15:23:03 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "today" , "2026-05-31" , "*.data_package.yaml" )
2026-06-17 15:59:13 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "tomorrow" , "2026-06-01" , "*.data_package.yaml" )
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "three-day" , "2026-05-31" , "*.data_package.yaml" )
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "weekend" , "2026-05-31" , "*.data_package.yaml" )
2026-06-16 15:23:03 +00:00
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "daily" , "2026-05-31" , "*.data_package.yaml" )
2026-05-29 18:08:50 +00:00
}
2026-05-29 17:47:42 +00:00
func TestRunGenerateDailyWritesMarkdownReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
outPath := fixture . path ( "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" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 17:23:49 +00:00
"--date" , "2026-05-29" ,
2026-05-29 19:57:08 -05:00
"--tz" , "UTC" ,
2026-05-29 17:23:49 +00:00
"--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 )
}
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-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.yaml" )
data , err := os . ReadFile ( dataPackagePath )
2026-05-29 17:47:42 +00:00
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
2026-05-29 17:23:49 +00:00
}
2026-06-15 20:27:28 +00:00
if ! strings . Contains ( string ( data ) , "schema_version: weatherreporter.data_package.v3" ) || ! strings . Contains ( string ( data ) , "id: daily" ) {
2026-05-29 17:32:57 +00:00
t . Fatalf ( "data package output missing expected content:\n%s" , string ( data ) )
}
2026-06-09 21:08:17 +00:00
if ! strings . Contains ( string ( data ) , "location:" ) ||
! strings . Contains ( string ( data ) , "id: home" ) ||
! strings . Contains ( string ( data ) , "name: Brentwood" ) ||
! strings . Contains ( string ( data ) , "region: St. Louis Metro" ) ||
! strings . Contains ( string ( data ) , "timezone: UTC" ) {
t . Fatalf ( "data package missing configured location with overridden timezone:\n%s" , string ( data ) )
2026-05-29 19:57:08 -05:00
}
2026-06-16 15:23:03 +00:00
preflightPath := oneArtifact ( t , fixture . workspaceRoot , "preflight" , "daily" , "2026-05-29" , "*.render.json" )
preflight , err := os . ReadFile ( preflightPath )
2026-05-29 17:32:57 +00:00
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-06-16 15:23:03 +00:00
_ = oneArtifact ( t , fixture . workspaceRoot , "reports" , "daily" , "*.md" )
rawGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "daily" , "2026-05-29" , "*.generated_text.raw.json" )
validatedGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "daily" , "2026-05-29" , "*.generated_text.json" )
renderContextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "daily" , "2026-05-29" , "*.render_context.json" )
metadataPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "daily" , "2026-05-29" , "*.metadata.json" )
2026-06-15 16:46:10 +00:00
assertFileContains ( t , rawGeneratedTextPath , ` "summary": "Showers are possible during the selected day." ` )
assertFileContains ( t , validatedGeneratedTextPath , ` "summary":"Showers are possible during the selected day." ` )
assertFileContains ( t , renderContextPath , ` "Title": "Friday's Weather" ` )
assertFileContains ( t , metadataPath , ` "generatedTextSchemaId": "daily" ` )
2026-05-29 17:23:49 +00:00
}
2026-06-15 14:58:36 +00:00
func TestRunGenerateTodayWritesGeneratedTextReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeStructuredOutputScriptorium )
outPath := fixture . path ( "today.md" )
2026-06-15 14:58:36 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : fixedClock ( ) }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "today" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-15 14:58:36 +00:00
"--date" , "2026-05-29" ,
"--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 )
}
for _ , want := range [ ] string {
"# Today's Weather" ,
"Today starts with showers before improving." ,
"Morning showers should taper as drier air arrives." ,
} {
if ! strings . Contains ( string ( report ) , want ) {
t . Fatalf ( "today report output missing %q:\n%s" , want , string ( report ) )
}
}
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "today" , "2026-05-29" , "*.data_package.yaml" )
2026-06-15 14:58:36 +00:00
dataPackage , err := os . ReadFile ( dataPackagePath )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
}
if ! strings . Contains ( string ( dataPackage ) , "id: today" ) ||
! strings . Contains ( string ( dataPackage ) , "prompt_id: weather.today_generated_text" ) ||
! strings . Contains ( string ( dataPackage ) , "today_planning:" ) {
t . Fatalf ( "data package output missing Today content:\n%s" , string ( dataPackage ) )
}
2026-06-16 15:23:03 +00:00
noArtifacts ( t , fixture . workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.yaml" )
rawGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "today" , "2026-05-29" , "*.generated_text.raw.json" )
validatedGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "today" , "2026-05-29" , "*.generated_text.json" )
renderContextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "today" , "2026-05-29" , "*.render_context.json" )
managedReportPath := oneArtifact ( t , fixture . workspaceRoot , "reports" , "today" , "*.md" )
2026-06-15 14:58:36 +00:00
assertFileContains ( t , rawGeneratedTextPath , ` "summary": "Today starts with showers before improving." ` )
assertFileContains ( t , validatedGeneratedTextPath , ` "summary":"Today starts with showers before improving." ` )
assertFileContains ( t , renderContextPath , ` "Title": "Today's Weather" ` )
assertFileContains ( t , managedReportPath , "# Today's Weather" )
}
2026-06-14 05:17:05 +00:00
func TestRunGenerateHourlyWritesGeneratedTextReport ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeStructuredOutputScriptorium )
outPath := fixture . path ( "hourly.md" )
2026-06-14 05:17:05 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
runner := Runner { Clock : timeutil . FixedClock { Time : time . Date ( 2026 , 5 , 29 , 11 , 0 , 0 , 0 , time . UTC ) } }
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "hourly" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-14 05:17:05 +00: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 )
}
for _ , want := range [ ] string {
"# Hourly Report" ,
"Storm chances increase through late morning." ,
2026-06-14 12:54:32 -05:00
"A cold front is moving into the region." ,
"A front will keep the region unsettled." ,
2026-06-14 05:17:05 +00:00
} {
if ! strings . Contains ( string ( report ) , want ) {
t . Fatalf ( "report output missing %q:\n%s" , want , string ( report ) )
}
}
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "hourly" , "2026-05-29" , "*.data_package.yaml" )
2026-06-14 05:17:05 +00:00
dataPackage , err := os . ReadFile ( dataPackagePath )
if err != nil {
t . Fatalf ( "read managed data package: %v" , err )
}
if ! strings . Contains ( string ( dataPackage ) , "id: hourly" ) ||
! strings . Contains ( string ( dataPackage ) , "prompt_id: weather.hourly_generated_text" ) ||
! strings . Contains ( string ( dataPackage ) , "hourly_forecast:" ) {
t . Fatalf ( "data package output missing hourly content:\n%s" , string ( dataPackage ) )
}
2026-06-16 15:23:03 +00:00
rawGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "hourly" , "2026-05-29" , "*.generated_text.raw.json" )
validatedGeneratedTextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "hourly" , "2026-05-29" , "*.generated_text.json" )
renderContextPath := oneArtifact ( t , fixture . workspaceRoot , "snapshots" , "hourly" , "2026-05-29" , "*.render_context.json" )
managedReportPath := oneArtifact ( t , fixture . workspaceRoot , "reports" , "hourly" , "*.md" )
2026-06-14 05:17:05 +00:00
assertFileContains ( t , rawGeneratedTextPath , ` "summary": " Storm chances increase through late morning. " ` )
assertFileContains ( t , validatedGeneratedTextPath , ` "summary":"Storm chances increase through late morning." ` )
2026-06-14 08:57:53 -05:00
assertFileContains ( t , renderContextPath , ` "Report": { ` )
assertFileContains ( t , renderContextPath , ` "Title": "Hourly Report" ` )
assertFileContains ( t , renderContextPath , ` "Modules": { ` )
assertFileContains ( t , renderContextPath , ` "Collected": { ` )
assertFileContains ( t , renderContextPath , ` "Derived": { ` )
2026-06-14 05:17:05 +00:00
assertFileContains ( t , managedReportPath , "# Hourly Report" )
}
2026-06-15 14:58:36 +00:00
func TestRunInspectTodayArtifacts ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
2026-06-15 14:58:36 +00:00
runner := Runner { Clock : fixedClock ( ) }
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "today" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-06-15 14:58:36 +00:00
"--date" , "2026-05-29" ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run(generate) error = %v" , err )
}
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "today" , "2026-05-29" , "*.data_package.yaml" )
runID := strings . TrimSuffix ( filepath . Base ( dataPackagePath ) , ".data_package.yaml" )
2026-06-15 14:58:36 +00:00
stdout . Reset ( )
2026-06-16 15:23:03 +00:00
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , "reports" , "--config" , fixture . configPath , "--limit" , "1" } , & stdout , & stderr )
2026-06-15 14:58:36 +00:00
if err != nil {
t . Fatalf ( "Run(inspect reports) error = %v" , err )
}
if ! strings . Contains ( stdout . String ( ) , runID ) || ! strings . Contains ( stdout . String ( ) , ` "reportId": "today" ` ) {
t . Fatalf ( "inspect reports output missing Today run:\n%s" , stdout . String ( ) )
}
var sourcesOutput string
for _ , command := range [ ] string { "metadata" , "modules" , "data-package" , "sources" } {
stdout . Reset ( )
2026-06-16 15:23:03 +00:00
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , command , "--config" , fixture . configPath , runID } , & stdout , & stderr )
2026-06-15 14:58:36 +00:00
if err != nil {
t . Fatalf ( "Run(inspect %s) error = %v" , command , err )
}
if ! strings . Contains ( stdout . String ( ) , runID ) || ! strings . Contains ( stdout . String ( ) , "today" ) {
t . Fatalf ( "inspect %s output missing Today run id:\n%s" , command , stdout . String ( ) )
}
if command == "sources" {
sourcesOutput = stdout . String ( )
}
}
if ! strings . Contains ( sourcesOutput , ` "name": "narrative" ` ) || strings . Contains ( sourcesOutput , ` "name": "daily" ` ) {
t . Fatalf ( "inspect sources output missing narrative source or has unexpected daily source:\n%s" , sourcesOutput )
}
}
2026-05-29 18:33:24 +00:00
func TestRunInspectGeneratedArtifacts ( t * testing . T ) {
2026-06-16 15:23:03 +00:00
fixture := newCLIFixture ( t , writeFakeScriptorium )
2026-05-29 18:33:24 +00:00
runner := Runner { Clock : fixedClock ( ) }
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , [ ] string {
"generate" , "daily" ,
2026-06-16 15:23:03 +00:00
"--config" , fixture . configPath ,
2026-05-29 18:33:24 +00:00
"--date" , "2026-05-29" ,
} , & stdout , & stderr )
if err != nil {
t . Fatalf ( "Run(generate) error = %v" , err )
}
2026-06-16 15:23:03 +00:00
dataPackagePath := oneArtifact ( t , fixture . workspaceRoot , "data-packages" , "daily" , "2026-05-29" , "*.data_package.yaml" )
runID := strings . TrimSuffix ( filepath . Base ( dataPackagePath ) , ".data_package.yaml" )
2026-05-29 18:33:24 +00:00
stdout . Reset ( )
2026-06-16 15:23:03 +00:00
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , "reports" , "--config" , fixture . configPath , "--limit" , "1" } , & stdout , & stderr )
2026-05-29 18:33:24 +00:00
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 ( ) )
}
2026-06-09 20:58:35 +00:00
var sourcesOutput string
for _ , command := range [ ] string { "metadata" , "modules" , "data-package" , "sources" } {
2026-05-29 18:33:24 +00:00
stdout . Reset ( )
2026-06-16 15:23:03 +00:00
err = runner . Run ( context . Background ( ) , [ ] string { "inspect" , command , "--config" , fixture . configPath , runID } , & stdout , & stderr )
2026-05-29 18:33:24 +00:00
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 ( ) )
}
2026-06-09 20:58:35 +00:00
if command == "sources" {
sourcesOutput = stdout . String ( )
}
2026-05-29 18:33:24 +00:00
}
2026-06-10 08:24:50 -05:00
if ! strings . Contains ( sourcesOutput , ` "name": "narrative" ` ) || strings . Contains ( sourcesOutput , ` "name": "daily" ` ) {
t . Fatalf ( "inspect sources output missing narrative source or has unexpected daily source:\n%s" , sourcesOutput )
2026-05-29 18:33:24 +00:00
}
}
func TestRunInspectMissingMetadata ( t * testing . T ) {
tempDir := t . TempDir ( )
2026-06-16 15:23:03 +00:00
configPath := writeWorkspaceConfig ( t , filepath . Join ( tempDir , "workspace" ) )
2026-05-29 18:33:24 +00:00
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 20:39:17 +00:00
func TestRunInspectRunCommandsParseRunIDAndConfig ( t * testing . T ) {
tempDir := t . TempDir ( )
2026-06-16 15:23:03 +00:00
configPath := writeWorkspaceConfig ( t , filepath . Join ( tempDir , "workspace" ) )
2026-05-29 20:39:17 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-09 20:58:35 +00:00
commands := [ ] string { "metadata" , "modules" , "data-package" , "prior" , "sources" }
2026-05-29 20:39:17 +00:00
for _ , command := range commands {
t . Run ( command + " requires run id" , func ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , [ ] string { "inspect" , command , "--config" , configPath } , & stdout , & stderr )
if err == nil {
t . Fatal ( "Run() error = nil, want missing run id error" )
}
if ! strings . Contains ( err . Error ( ) , "requires a run id" ) {
t . Fatalf ( "error = %q, want missing run id context" , err . Error ( ) )
}
} )
t . Run ( command + " accepts config" , func ( t * testing . T ) {
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , [ ] string { "inspect" , command , "--config" , configPath , "missing" } , & stdout , & stderr )
if err == nil {
t . Fatal ( "Run() error = nil, want missing metadata error" )
}
if ! strings . Contains ( err . Error ( ) , "metadata for run id" ) {
t . Fatalf ( "error = %q, want missing metadata 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 } ,
2026-06-15 14:58:36 +00:00
{ name : "today" , args : [ ] string { "today" , "--date" , "2026-05-29" } , want : app . ReportToday } ,
2026-05-29 16:58:18 +00:00
{ name : "tomorrow" , args : [ ] string { "tomorrow" } , want : app . ReportTomorrow } ,
2026-06-14 04:26:05 +00:00
{ name : "hourly" , args : [ ] string { "hourly" } , want : app . ReportHourly } ,
2026-05-29 16:58:18 +00:00
{ 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 )
}
} )
}
}
2026-06-15 12:33:55 +00:00
func TestResolveGenerateSupportsEveryReportCommandName ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
for _ , name := range report . CommandNames ( ) {
t . Run ( name , func ( t * testing . T ) {
args := [ ] string { name }
2026-06-15 14:58:36 +00:00
if name == report . CommandNameDaily || name == report . CommandNameToday {
2026-06-15 12:33:55 +00:00
args = append ( args , "--date" , "2026-05-29" )
}
if name == report . CommandNameStorm {
args = append ( args , "--start" , "2026-05-29T18:00" , "--end" , "2026-05-30T06:00" )
}
req , err := runner . resolveGenerate ( args )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
want , err := report . IDForCommandName ( name )
if err != nil {
t . Fatalf ( "IDForCommandName() error = %v" , err )
}
resolved , err := app . ResolveGenerate ( req , req . Now )
if err != nil {
t . Fatalf ( "ResolveGenerate() error = %v" , err )
}
if resolved . Definition . ID != want {
t . Fatalf ( "resolved ID = %q, want %q" , resolved . Definition . ID , want )
}
} )
}
}
2026-06-14 04:26:05 +00:00
func TestResolveGenerateHourlyAppliesSharedFlags ( t * testing . T ) {
2026-06-12 17:36:58 +00:00
runner := Runner { Clock : fixedClock ( ) }
2026-06-16 15:23:03 +00:00
configPath := writeConfigFile ( t , "weather_api:\n units: metric\n timezone: UTC\n" )
2026-06-12 17:36:58 +00:00
2026-06-14 04:26:05 +00:00
req , err := runner . resolveGenerate ( [ ] string { "hourly" , "--config" , configPath , "--units" , "us" , "--tz" , "America/Chicago" , "--out" , "./hourly.md" } )
2026-06-12 17:36:58 +00:00
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
2026-06-14 04:26:05 +00:00
if req . Report != app . ReportHourly {
t . Fatalf ( "Report = %q, want hourly" , req . Report )
2026-06-12 17:36:58 +00:00
}
if req . Config . WeatherAPI . Units != "us" {
t . Fatalf ( "Units = %q, want us" , req . Config . WeatherAPI . Units )
}
if req . Config . WeatherAPI . Timezone != "America/Chicago" {
t . Fatalf ( "Timezone = %q, want America/Chicago" , req . Config . WeatherAPI . Timezone )
}
2026-06-14 04:26:05 +00:00
if req . OutputPath != "./hourly.md" {
t . Fatalf ( "OutputPath = %q, want ./hourly.md" , req . OutputPath )
2026-06-12 17:36:58 +00:00
}
if ! req . Date . IsZero ( ) || ! req . StormStart . IsZero ( ) || ! req . StormEnd . IsZero ( ) {
2026-06-14 04:26:05 +00:00
t . Fatalf ( "date/storm bounds = %s/%s/%s, want unset for hourly" , req . Date , req . StormStart , req . StormEnd )
2026-06-12 17:36:58 +00:00
}
}
2026-06-14 04:26:05 +00:00
func TestResolveGenerateHourlyRejectsDateAndStormBounds ( t * testing . T ) {
2026-06-12 17:36:58 +00:00
runner := Runner { Clock : fixedClock ( ) }
for _ , args := range [ ] [ ] string {
2026-06-14 04:26:05 +00:00
{ "hourly" , "--date" , "2026-05-29" } ,
{ "hourly" , "--start" , "2026-05-29T18:00" } ,
{ "hourly" , "--end" , "2026-05-29T20:00" } ,
2026-06-14 05:17:05 +00:00
{ "hourly" , "--hours" , "6" } ,
{ "hourly" , "--duration" , "6h" } ,
2026-06-12 17:36:58 +00:00
} {
_ , err := runner . resolveGenerate ( args )
if err == nil {
t . Fatalf ( "resolveGenerate(%v) error = nil, want flag error" , args )
}
if ! strings . Contains ( err . Error ( ) , "flag provided but not defined" ) {
t . Fatalf ( "resolveGenerate(%v) error = %q, want undefined flag error" , args , err . Error ( ) )
}
}
}
2026-06-15 16:46:10 +00:00
func TestResolveGenerateDailyRequiresDate ( t * testing . T ) {
2026-05-29 16:58:18 +00:00
runner := Runner { Clock : fixedClock ( ) }
req , err := runner . resolveGenerate ( [ ] string { "daily" } )
2026-06-15 16:46:10 +00:00
if err == nil {
t . Fatal ( "resolveGenerate() error = nil, want required date error" )
}
if ! strings . Contains ( err . Error ( ) , "generate daily requires --date YYYY-MM-DD" ) {
t . Fatalf ( "resolveGenerate() error = %q, want required date context" , err . Error ( ) )
}
if ! req . Date . IsZero ( ) {
t . Fatalf ( "Date = %s, want unset on error" , req . Date )
}
}
func TestResolveGenerateDailyRejectsMalformedDate ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
_ , err := runner . resolveGenerate ( [ ] string { "daily" , "--date" , "bad-date" } )
if err == nil {
t . Fatal ( "resolveGenerate() error = nil, want date parse error" )
}
if ! strings . Contains ( err . Error ( ) , ` parse date "bad-date" as YYYY-MM-DD ` ) {
t . Fatalf ( "resolveGenerate() error = %q, want date parse context" , err . Error ( ) )
}
}
func TestResolveGenerateDailyParsesDate ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
req , err := runner . resolveGenerate ( [ ] string { "daily" , "--date" , "2026-05-29" } )
2026-05-29 16:58:18 +00:00
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
2026-06-15 16:46:10 +00:00
if req . Report != app . ReportDaily {
t . Fatalf ( "Report = %q, want daily" , req . Report )
}
2026-05-29 16:58:18 +00:00
if got := req . Date . Format ( timeutil . DateLayout ) ; got != "2026-05-29" {
t . Fatalf ( "Date = %s, want 2026-05-29" , got )
}
}
2026-06-15 14:58:36 +00:00
func TestResolveGenerateTodayDate ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
defaultReq , err := runner . resolveGenerate ( [ ] string { "today" } )
if err != nil {
t . Fatalf ( "resolveGenerate(default) error = %v" , err )
}
if defaultReq . Report != app . ReportToday {
t . Fatalf ( "Report = %q, want today" , defaultReq . Report )
}
if got := defaultReq . Date . Format ( timeutil . DateLayout ) ; got != "2026-05-29" {
t . Fatalf ( "default Date = %s, want 2026-05-29" , got )
}
explicitReq , err := runner . resolveGenerate ( [ ] string { "today" , "--date" , "2026-05-30" , "--tz" , "UTC" } )
if err != nil {
t . Fatalf ( "resolveGenerate(explicit) error = %v" , err )
}
if got := explicitReq . Date . Format ( timeutil . DateLayout ) ; got != "2026-05-30" {
t . Fatalf ( "explicit Date = %s, want 2026-05-30" , got )
}
resolved , err := app . ResolveGenerate ( explicitReq , explicitReq . Now )
if err != nil {
t . Fatalf ( "ResolveGenerate() error = %v" , err )
}
if resolved . Definition . ID != report . Today {
t . Fatalf ( "resolved ID = %q, want today" , resolved . Definition . ID )
}
if got := resolved . ValidPeriod . Start . Format ( time . RFC3339 ) ; got != "2026-05-30T00:00:00Z" {
t . Fatalf ( "valid period start = %s, want explicit UTC date" , 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 ( ) }
2026-06-15 16:46:10 +00:00
req , err := runner . resolveGenerate ( [ ] string { "daily" , "--date" , "2026-05-29" , "--units" , "metric" , "--tz" , "UTC" , "--out" , "./daily.md" } )
2026-05-29 16:58:18 +00:00
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 )
}
}
2026-06-14 04:35:52 +00:00
func TestResolveGenerateRejectsRetiredHourlyCommand ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
retired := strings . Join ( [ ] string { "near" , "term" } , "-" )
_ , err := runner . resolveGenerate ( [ ] string { retired } )
if err == nil {
t . Fatal ( "resolveGenerate() error = nil, want unknown report" )
}
if ! strings . Contains ( err . Error ( ) , "unknown generate report" ) {
t . Fatalf ( "error = %q, want unknown generate report" , err . Error ( ) )
}
}
2026-05-29 16:58:18 +00:00
func TestResolveGenerateStormRequiresStartAndEnd ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
2026-05-29 20:41:47 +00:00
_ , err := runner . resolveGenerate ( [ ] string { "storm" , "--end" , "2026-05-29T18:00" } )
if err == nil {
t . Fatal ( "resolveGenerate() error = nil, want missing start error" )
}
if ! strings . Contains ( err . Error ( ) , "requires --start" ) {
t . Fatalf ( "error = %q, want missing start" , err . Error ( ) )
}
_ , 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
}
2026-05-29 20:41:47 +00:00
func TestResolveGenerateStormParsesLocalTimestamps ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
req , err := runner . resolveGenerate ( [ ] string {
"storm" ,
"--tz" , "America/Chicago" ,
"--start" , "2026-05-29T18:00" ,
"--end" , "2026-05-30T06:00" ,
} )
if err != nil {
t . Fatalf ( "resolveGenerate() error = %v" , err )
}
if got := req . StormStart . Format ( time . RFC3339 ) ; got != "2026-05-29T18:00:00-05:00" {
t . Fatalf ( "StormStart = %q, want local Chicago time" , got )
}
if got := req . StormEnd . Format ( time . RFC3339 ) ; got != "2026-05-30T06:00:00-05:00" {
t . Fatalf ( "StormEnd = %q, want local Chicago time" , got )
}
}
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 )
}
}
2026-05-29 20:41:47 +00:00
func TestResolveGenerateStormRejectsInvalidBounds ( t * testing . T ) {
runner := Runner { Clock : fixedClock ( ) }
_ , err := runner . resolveGenerate ( [ ] string {
"storm" ,
"--start" , "2026-05-30T06:00" ,
"--end" , "2026-05-29T18:00" ,
} )
if err == nil {
t . Fatal ( "resolveGenerate() error = nil, want invalid bounds error" )
}
if ! strings . Contains ( err . Error ( ) , "end time after start time" ) {
t . Fatalf ( "error = %q, want invalid bounds context" , err . Error ( ) )
}
}
2026-05-29 16:58:18 +00:00
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
2026-06-15 12:48:36 +00:00
type commandOutput struct {
stdout string
stderr string
}
func runRootCommand ( t * testing . T , args ... string ) ( commandOutput , error ) {
t . Helper ( )
return runTestCommand ( t , Runner { } , args ... )
}
func runTestCommand ( t * testing . T , runner Runner , args ... string ) ( commandOutput , error ) {
t . Helper ( )
var stdout bytes . Buffer
var stderr bytes . Buffer
err := runner . Run ( context . Background ( ) , args , & stdout , & stderr )
return commandOutput {
stdout : stdout . String ( ) ,
stderr : stderr . String ( ) ,
} , err
}
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."]}} ` ) )
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:23:49 +00:00
default :
http . NotFound ( w , r )
}
} ) )
t . Cleanup ( server . Close )
return server
}
2026-05-29 17:32:57 +00:00
2026-06-16 15:23:03 +00:00
type cliFixture struct {
tempDir string
workspaceRoot string
configPath string
}
func newCLIFixture ( t * testing . T , writeScriptorium func ( * testing . T , string ) string ) cliFixture {
t . Helper ( )
server := dailyServer ( t )
tempDir := t . TempDir ( )
scriptoriumPath := writeScriptorium ( t , tempDir )
workspaceRoot := filepath . Join ( tempDir , "workspace" )
return cliFixture {
tempDir : tempDir ,
workspaceRoot : workspaceRoot ,
configPath : writeTestConfig ( t , server , scriptoriumPath , workspaceRoot ) ,
}
}
func ( f cliFixture ) path ( name string ) string {
return filepath . Join ( f . tempDir , name )
}
func writeConfigFile ( t * testing . T , body string ) string {
2026-05-29 20:44:25 +00:00
t . Helper ( )
configPath := filepath . Join ( t . TempDir ( ) , "config.yml" )
2026-06-16 15:23:03 +00:00
if err := os . WriteFile ( configPath , [ ] byte ( body ) , 0 o600 ) ; err != nil {
2026-05-29 20:44:25 +00:00
t . Fatalf ( "write config: %v" , err )
}
return configPath
}
2026-06-16 15:23:03 +00:00
func writeTestConfig ( t * testing . T , server * httptest . Server , scriptoriumPath string , workspaceRoot string ) string {
t . Helper ( )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
return writeConfigFile ( t , configBody )
}
func writeTestConfigWithDistributor ( t * testing . T , server * httptest . Server , scriptoriumPath string , workspaceRoot string , distributorEndpoint string ) string {
t . Helper ( )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
return writeConfigFile ( t , configBody )
}
2026-06-17 21:00:03 +00:00
func writeTestConfigWithDisabledBatchDistributor ( t * testing . T , server * httptest . Server , scriptoriumPath string , workspaceRoot string , distributorEndpoint string ) string {
t . Helper ( )
configBody := "weather_api:\n base_url: " + server . URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n batch:\n enabled: false\n"
return writeConfigFile ( t , configBody )
}
2026-06-16 15:23:03 +00:00
func writeWorkspaceConfig ( t * testing . T , workspaceRoot string ) string {
t . Helper ( )
return writeConfigFile ( t , "workspace:\n root: " + workspaceRoot + "\n" )
}
2026-05-29 20:44:25 +00:00
func oneArtifact ( t * testing . T , root string , parts ... string ) string {
t . Helper ( )
matches , err := filepath . Glob ( filepath . Join ( append ( [ ] string { root } , parts ... ) ... ) )
if err != nil {
t . Fatalf ( "glob artifact: %v" , err )
}
if len ( matches ) != 1 {
t . Fatalf ( "artifact matches = %#v, want one" , matches )
}
return matches [ 0 ]
}
2026-06-16 15:23:03 +00:00
func noArtifacts ( t * testing . T , root string , parts ... string ) {
t . Helper ( )
matches , err := filepath . Glob ( filepath . Join ( append ( [ ] string { root } , parts ... ) ... ) )
if err != nil {
t . Fatalf ( "glob artifact: %v" , err )
}
if len ( matches ) != 0 {
t . Fatalf ( "artifact matches = %#v, want none" , matches )
}
}
2026-06-17 21:03:14 +00:00
func firstLineWithPrefix ( text string , prefix string ) string {
for _ , line := range strings . Split ( text , "\n" ) {
if strings . HasPrefix ( line , prefix ) {
return line
}
}
return ""
}
2026-06-14 05:17:05 +00:00
func assertFileContains ( t * testing . T , path string , want string ) {
t . Helper ( )
data , err := os . ReadFile ( path )
if err != nil {
t . Fatalf ( "read %s: %v" , path , err )
}
if ! strings . Contains ( string ( data ) , want ) {
t . Fatalf ( "%s missing %q:\n%s" , path , want , string ( data ) )
}
}
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 = ""
2026-06-14 23:44:31 +00:00
prompt = ""
2026-05-29 17:47:42 +00:00
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--out" ] ; then
shift
out = "$1"
2026-06-14 23:44:31 +00:00
elif [ "$1" = "--prompt" ] ; then
shift
prompt = "$1"
2026-05-29 17:47:42 +00:00
fi
shift
done
2026-06-15 14:54:37 +00:00
if [ "$prompt" = "weather.today_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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."
}
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
2026-06-14 23:44:31 +00:00
if [ "$prompt" = "weather.tomorrow_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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 16:43:06 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
if [ "$prompt" = "weather.daily_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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-14 23:44:31 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
2026-05-29 17:47:42 +00:00
printf ' # Daily Report \ n \ nGenerated by fake scriptorium . \ n ' > "$out"
printf ' wrote report \ n ' > & 2
exit 0
fi
printf ' unexpected command \ n ' > & 2
2026-06-14 05:17:05 +00:00
exit 1
`
if err := os . WriteFile ( path , [ ] byte ( body ) , 0 o700 ) ; err != nil {
t . Fatalf ( "write fake scriptorium: %v" , err )
}
return path
}
func writeStructuredOutputScriptorium ( t * testing . T , dir string ) string {
t . Helper ( )
path := filepath . Join ( dir , "scriptorium" )
body := ` # ! / bin / sh
if [ "$1" = "render" ] ; then
printf ' { "ok" : true , "argv" : "%s" } ' "$*"
exit 0
fi
if [ "$1" = "run" ] ; then
out = ""
prompt = ""
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--out" ] ; then
shift
out = "$1"
elif [ "$1" = "--prompt" ] ; then
shift
prompt = "$1"
fi
shift
done
if [ "$prompt" = "weather.hourly_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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:17:05 +00:00
"confidence" : "Medium"
}
2026-06-15 16:43:06 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
if [ "$prompt" = "weather.daily_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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-15 14:54:37 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
if [ "$prompt" = "weather.today_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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-14 23:44:31 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
if [ "$prompt" = "weather.tomorrow_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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-14 05:17:05 +00:00
JSON
printf ' wrote generated text \ n ' > & 2
exit 0
fi
printf ' # Daily Report \ n \ nGenerated by fake scriptorium . \ n ' > "$out"
printf ' wrote report \ n ' > & 2
exit 0
fi
printf ' unexpected command \ n ' > & 2
2026-05-29 17:47:42 +00:00
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
2026-06-17 15:59:13 +00:00
if [ "$prompt" = "weather.tomorrow_generated_text" ] ; then
2026-05-29 18:20:39 +00:00
printf ' render failed \ n ' > & 2
exit 1
fi
printf ' { "ok" : true , "prompt" : "%s" } ' "$prompt"
exit 0
fi
if [ "$1" = "run" ] ; then
out = ""
2026-06-15 14:54:37 +00:00
prompt = ""
2026-05-29 18:20:39 +00:00
while [ "$#" - gt 0 ] ; do
if [ "$1" = "--out" ] ; then
shift
out = "$1"
2026-06-15 14:54:37 +00:00
elif [ "$1" = "--prompt" ] ; then
shift
prompt = "$1"
2026-05-29 18:20:39 +00:00
fi
shift
done
2026-06-15 14:54:37 +00:00
if [ "$prompt" = "weather.today_generated_text" ] ; then
cat > "$out" << ' JSON '
{
"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."
}
JSON
exit 0
fi
2026-05-29 18:20:39 +00:00
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
}