1428 lines
51 KiB
Go
1428 lines
51 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
|
)
|
|
|
|
func TestRunHelpLongFlag(t *testing.T) {
|
|
output, err := runRootCommand(t, "--help")
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") {
|
|
t.Fatalf("help output missing generate command:\n%s", output.stdout)
|
|
}
|
|
if !strings.Contains(output.stdout, "generate today") {
|
|
t.Fatalf("help output missing today generate command:\n%s", output.stdout)
|
|
}
|
|
if !strings.Contains(output.stdout, "weatherreporter generate hourly") {
|
|
t.Fatalf("help output missing hourly generate command:\n%s", output.stdout)
|
|
}
|
|
removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-")
|
|
if strings.Contains(output.stdout, removedGenerateCommand) {
|
|
t.Fatalf("help output includes retired generate command:\n%s", output.stdout)
|
|
}
|
|
removedInspectCommand := "inspect " + "briefing"
|
|
if !strings.Contains(output.stdout, "inspect modules") || strings.Contains(output.stdout, removedInspectCommand) {
|
|
t.Fatalf("help output has wrong inspect commands:\n%s", output.stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunHelpShortFlag(t *testing.T) {
|
|
output, err := runRootCommand(t, "-h")
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(output.stdout, "weatherreporter run evening") {
|
|
t.Fatalf("help output missing run command:\n%s", output.stdout)
|
|
}
|
|
}
|
|
|
|
func TestRunUnknownCommand(t *testing.T) {
|
|
_, err := runRootCommand(t, "unknown")
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want unknown command error")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), `unknown command "unknown"`) {
|
|
t.Fatalf("Run() error = %q, want unknown command message", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outPath := fixture.path("storm.md")
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"generate", "storm",
|
|
"--config", fixture.configPath,
|
|
"--start", "2026-05-29T06:00",
|
|
"--end", "2026-05-29T10:00",
|
|
"--out", outPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFileContains(t, outPath, "# Daily Report")
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
|
|
assertFileContains(t, dataPackagePath, "id: storm")
|
|
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
|
|
}
|
|
|
|
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outPath := fixture.path("tomorrow.md")
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"generate", "tomorrow",
|
|
"--config", fixture.configPath,
|
|
"--out", outPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFileContains(t, outPath, "# Saturday's Weather")
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
|
assertFileContains(t, dataPackagePath, "id: tomorrow")
|
|
assertFileContains(t, dataPackagePath, "tomorrow_planning:")
|
|
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
|
|
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
|
t.Fatalf("managed report = %q, want tomorrow report", reportPath)
|
|
}
|
|
}
|
|
|
|
func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"run", "evening",
|
|
"--config", fixture.configPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
|
reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
|
|
if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
|
|
t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outPath := fixture.path("three-day.md")
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"generate", "three-day",
|
|
"--config", fixture.configPath,
|
|
"--out", outPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFileContains(t, outPath, "# Daily Report")
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
|
|
assertFileContains(t, dataPackagePath, "id: three_day")
|
|
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
|
}
|
|
|
|
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outPath := fixture.path("weekend.md")
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"generate", "weekend",
|
|
"--config", fixture.configPath,
|
|
"--out", outPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
assertFileContains(t, outPath, "# Daily Report")
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
|
assertFileContains(t, dataPackagePath, "id: weekend")
|
|
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
|
}
|
|
|
|
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runTestCommand(t, runner,
|
|
"run", "morning",
|
|
"--config", fixture.configPath,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
|
_ = 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")
|
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
|
}
|
|
|
|
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFailingScriptorium)
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
output, err := runTestCommand(t, runner,
|
|
"run", "morning",
|
|
"--config", fixture.configPath,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want aggregate failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "1 of 2 reports failed") {
|
|
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
|
}
|
|
|
|
var summary app.BatchResult
|
|
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
|
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
|
}
|
|
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)
|
|
}
|
|
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
|
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
|
}
|
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
|
}
|
|
|
|
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
|
result := &app.BatchResult{
|
|
Batch: app.BatchMorning,
|
|
Total: 2,
|
|
Succeeded: 1,
|
|
Failed: 1,
|
|
Reports: []app.BatchReportResult{
|
|
{
|
|
ReportID: "daily",
|
|
Status: "succeeded",
|
|
OutputPath: "/tmp/daily.md",
|
|
NotificationStatus: "accepted",
|
|
NotificationRunID: "distributor-run-1",
|
|
},
|
|
{
|
|
ReportID: "three_day",
|
|
Status: "failed",
|
|
Error: "notify report three_day: upload failed",
|
|
NotificationStatus: "failed",
|
|
NotificationError: "notify report three_day: upload failed",
|
|
},
|
|
},
|
|
}
|
|
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())
|
|
}
|
|
if decoded.Reports[0].NotificationStatus != "accepted" || decoded.Reports[0].NotificationRunID != "distributor-run-1" {
|
|
t.Fatalf("success notification fields = %#v", decoded.Reports[0])
|
|
}
|
|
if decoded.Reports[1].NotificationStatus != "failed" || !strings.Contains(decoded.Reports[1].NotificationError, "upload failed") {
|
|
t.Fatalf("failure notification fields = %#v", decoded.Reports[1])
|
|
}
|
|
if !strings.Contains(stderr.String(), `notificationStatus="accepted"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
|
t.Fatalf("stderr missing success notification fields:\n%s", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), `notificationStatus="failed"`) || !strings.Contains(stderr.String(), `notificationError="notify report three_day: upload failed"`) {
|
|
t.Fatalf("stderr missing failure notification fields:\n%s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
|
|
result := &app.BatchResult{
|
|
Batch: app.BatchMorning,
|
|
Total: 1,
|
|
Failed: 1,
|
|
Reports: []app.BatchReportResult{
|
|
{
|
|
ReportID: "daily",
|
|
Status: "failed",
|
|
Error: "notify report daily: upload failed: [redacted]",
|
|
NotificationStatus: "failed",
|
|
NotificationError: "notify report daily: upload failed: [redacted]",
|
|
},
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outputDir := fixture.path("copies")
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
output, err := runTestCommand(t, runner,
|
|
"run", "evening",
|
|
"--config", fixture.configPath,
|
|
"--out-dir", outputDir,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
var summary app.BatchResult
|
|
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
|
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunEveningReportsNotificationSuccess(t *testing.T) {
|
|
server := dailyServer(t)
|
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/runs/distributor-run-1" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","pipeline_id":"weatherreporter.tomorrow","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`))
|
|
return
|
|
}
|
|
if r.URL.Path != "/v1/pipelines/weatherreporter.tomorrow/upload" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte(`{"run_id":"distributor-run-1","status":"accepted"}`))
|
|
}))
|
|
t.Cleanup(distributorServer.Close)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
|
|
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)
|
|
}
|
|
if summary.Reports[0].NotificationStatus != "succeeded" || summary.Reports[0].NotificationRunID != "distributor-run-1" || summary.Reports[0].NotificationPipelineID != "weatherreporter.tomorrow" {
|
|
t.Fatalf("notification fields = %#v", summary.Reports[0])
|
|
}
|
|
if !strings.Contains(stderr.String(), `notificationStatus="succeeded"`) || !strings.Contains(stderr.String(), `notificationRunId="distributor-run-1"`) {
|
|
t.Fatalf("stderr missing notification fields:\n%s", stderr.String())
|
|
}
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
|
server := dailyServer(t)
|
|
distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"rejected cli-secret-token","retryable":false}`))
|
|
}))
|
|
t.Cleanup(distributorServer.Close)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
|
|
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.Fatal("Run() error = nil, want notification failure")
|
|
}
|
|
|
|
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 || summary.Reports[0].NotificationStatus != "failed" {
|
|
t.Fatalf("summary reports = %#v, want failed notification", summary.Reports)
|
|
}
|
|
for _, output := range []string{stdout.String(), stderr.String(), err.Error()} {
|
|
if strings.Contains(output, "cli-secret-token") {
|
|
t.Fatalf("output contains token value:\n%s", output)
|
|
}
|
|
}
|
|
for _, output := range []string{stdout.String(), stderr.String()} {
|
|
if !strings.Contains(output, "[redacted]") {
|
|
t.Fatalf("output missing redaction marker:\n%s", output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
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", fixture.configPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
|
|
_ = 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")
|
|
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
|
|
}
|
|
|
|
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
outPath := fixture.path("daily.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "daily",
|
|
"--config", fixture.configPath,
|
|
"--date", "2026-05-29",
|
|
"--tz", "UTC",
|
|
"--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), "# Friday's Weather") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
|
data, err := os.ReadFile(dataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || !strings.Contains(string(data), "id: daily") {
|
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
|
}
|
|
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))
|
|
}
|
|
preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")
|
|
preflight, err := os.ReadFile(preflightPath)
|
|
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))
|
|
}
|
|
_ = 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")
|
|
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"`)
|
|
}
|
|
|
|
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
|
|
outPath := fixture.path("today.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "today",
|
|
"--config", fixture.configPath,
|
|
"--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))
|
|
}
|
|
}
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
|
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))
|
|
}
|
|
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")
|
|
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")
|
|
}
|
|
|
|
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
|
|
outPath := fixture.path("hourly.md")
|
|
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",
|
|
"--config", fixture.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)
|
|
}
|
|
for _, want := range []string{
|
|
"# Hourly Report",
|
|
"Storm chances increase through late morning.",
|
|
"A cold front is moving into the region.",
|
|
"A front will keep the region unsettled.",
|
|
} {
|
|
if !strings.Contains(string(report), want) {
|
|
t.Fatalf("report output missing %q:\n%s", want, string(report))
|
|
}
|
|
}
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
|
|
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))
|
|
}
|
|
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")
|
|
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
|
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
|
assertFileContains(t, renderContextPath, `"Report": {`)
|
|
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
|
|
assertFileContains(t, renderContextPath, `"Modules": {`)
|
|
assertFileContains(t, renderContextPath, `"Collected": {`)
|
|
assertFileContains(t, renderContextPath, `"Derived": {`)
|
|
assertFileContains(t, managedReportPath, "# Hourly Report")
|
|
}
|
|
|
|
func TestRunInspectTodayArtifacts(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
runner := Runner{Clock: fixedClock()}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "today",
|
|
"--config", fixture.configPath,
|
|
"--date", "2026-05-29",
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run(generate) error = %v", err)
|
|
}
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
|
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
|
|
|
stdout.Reset()
|
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.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(), `"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()
|
|
err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr)
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|
fixture := newCLIFixture(t, writeFakeScriptorium)
|
|
runner := Runner{Clock: fixedClock()}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "daily",
|
|
"--config", fixture.configPath,
|
|
"--date", "2026-05-29",
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run(generate) error = %v", err)
|
|
}
|
|
dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
|
runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
|
|
|
|
stdout.Reset()
|
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.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())
|
|
}
|
|
|
|
var sourcesOutput string
|
|
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
|
|
stdout.Reset()
|
|
err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.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 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)
|
|
}
|
|
}
|
|
|
|
func TestRunInspectMissingMetadata(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
|
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())
|
|
}
|
|
}
|
|
|
|
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
|
|
runner := Runner{Clock: fixedClock()}
|
|
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
|
|
|
|
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())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday},
|
|
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
|
{name: "hourly", args: []string{"hourly"}, want: app.ReportHourly},
|
|
{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 TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
for _, name := range report.CommandNames() {
|
|
t.Run(name, func(t *testing.T) {
|
|
args := []string{name}
|
|
if name == report.CommandNameDaily || name == report.CommandNameToday {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n")
|
|
|
|
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGenerate() error = %v", err)
|
|
}
|
|
|
|
if req.Report != app.ReportHourly {
|
|
t.Fatalf("Report = %q, want hourly", req.Report)
|
|
}
|
|
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)
|
|
}
|
|
if req.OutputPath != "./hourly.md" {
|
|
t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath)
|
|
}
|
|
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
|
|
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for hourly", req.Date, req.StormStart, req.StormEnd)
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
for _, args := range [][]string{
|
|
{"hourly", "--date", "2026-05-29"},
|
|
{"hourly", "--start", "2026-05-29T18:00"},
|
|
{"hourly", "--end", "2026-05-29T20:00"},
|
|
{"hourly", "--hours", "6"},
|
|
{"hourly", "--duration", "6h"},
|
|
} {
|
|
_, 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())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateDailyRequiresDate(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
req, err := runner.resolveGenerate([]string{"daily"})
|
|
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"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGenerate() error = %v", err)
|
|
}
|
|
|
|
if req.Report != app.ReportDaily {
|
|
t.Fatalf("Report = %q, want daily", req.Report)
|
|
}
|
|
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
|
|
t.Fatalf("Date = %s, want 2026-05-29", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--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 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())
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, 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"})
|
|
if err == nil {
|
|
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())
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
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)
|
|
}
|
|
if !req.StormEnd.After(req.StormStart) {
|
|
t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func fixedClock() timeutil.Clock {
|
|
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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":
|
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
|
case "/forecast/narrative":
|
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`))
|
|
case "/alerts/active":
|
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
|
case "/discussion":
|
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
|
case "/weatherstories/latest":
|
|
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
|
case "/outlooks/convective":
|
|
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
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 {
|
|
t.Helper()
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return configPath
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string {
|
|
t.Helper()
|
|
return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n")
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
func writeFakeScriptorium(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.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
|
|
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."
|
|
}
|
|
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"
|
|
}
|
|
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
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o700); 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. ",
|
|
"forecast_discussion": "A front will keep the region unsettled.",
|
|
"precipitation_timing": "A cold front is moving into the region.",
|
|
"confidence": "Medium"
|
|
}
|
|
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"
|
|
}
|
|
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."
|
|
}
|
|
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."
|
|
}
|
|
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
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
|
t.Fatalf("write fake scriptorium: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
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.tomorrow_generated_text" ]; then
|
|
printf 'render failed\n' >&2
|
|
exit 1
|
|
fi
|
|
printf '{"ok":true,"prompt":"%s"}' "$prompt"
|
|
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.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
|
|
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), 0o700); err != nil {
|
|
t.Fatalf("write fake scriptorium: %v", err)
|
|
}
|
|
return path
|
|
}
|