916 lines
32 KiB
Go
916 lines
32 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/timeutil"
|
|
)
|
|
|
|
func TestRunHelpLongFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := Run(context.Background(), []string{"--help"}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "generate daily") {
|
|
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunHelpShortFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := Run(context.Background(), []string{"-h"}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), "weatherreporter run evening") {
|
|
t.Fatalf("help output missing run command:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUnknownCommand(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := Run(context.Background(), []string{"unknown"}, &stdout, &stderr)
|
|
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) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
|
outPath := filepath.Join(tempDir, "storm.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "storm",
|
|
"--config", configPath,
|
|
"--start", "2026-05-29T06:00",
|
|
"--end", "2026-05-29T10:00",
|
|
"--out", outPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
report, err := os.ReadFile(outPath)
|
|
if err != nil {
|
|
t.Fatalf("read report: %v", err)
|
|
}
|
|
if !strings.Contains(string(report), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json")
|
|
data, err := os.ReadFile(dataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
|
|
t.Fatalf("data package output missing storm content:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
|
|
outPath := filepath.Join(tempDir, "tomorrow.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "tomorrow",
|
|
"--config", configPath,
|
|
"--out", outPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
report, err := os.ReadFile(outPath)
|
|
if err != nil {
|
|
t.Fatalf("read report: %v", err)
|
|
}
|
|
if !strings.Contains(string(report), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json")
|
|
data, err := os.ReadFile(dataPackagePath)
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) {
|
|
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
|
}
|
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
|
if err != nil {
|
|
t.Fatalf("glob managed report: %v", err)
|
|
}
|
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
|
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
|
}
|
|
}
|
|
|
|
func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"run", "evening",
|
|
"--config", configPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob data package: %v", err)
|
|
}
|
|
if len(dataPackageMatches) != 1 {
|
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
}
|
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
|
if err != nil {
|
|
t.Fatalf("glob managed report: %v", err)
|
|
}
|
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
|
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
outPath := filepath.Join(tempDir, "three-day.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "three-day",
|
|
"--config", configPath,
|
|
"--out", outPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
report, err := os.ReadFile(outPath)
|
|
if err != nil {
|
|
t.Fatalf("read report: %v", err)
|
|
}
|
|
if !strings.Contains(string(report), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob data package: %v", err)
|
|
}
|
|
if len(dataPackageMatches) != 1 {
|
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
}
|
|
data, err := os.ReadFile(dataPackageMatches[0])
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) {
|
|
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
outPath := filepath.Join(tempDir, "weekend.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "weekend",
|
|
"--config", configPath,
|
|
"--out", outPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
report, err := os.ReadFile(outPath)
|
|
if err != nil {
|
|
t.Fatalf("read report: %v", err)
|
|
}
|
|
if !strings.Contains(string(report), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob data package: %v", err)
|
|
}
|
|
if len(dataPackageMatches) != 1 {
|
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
}
|
|
data, err := os.ReadFile(dataPackageMatches[0])
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `"weekend"`) || !strings.Contains(string(data), `"planning"`) {
|
|
t.Fatalf("data package output missing weekend content:\n%s", string(data))
|
|
}
|
|
}
|
|
|
|
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"run", "morning",
|
|
"--config", configPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob weekend packages: %v", err)
|
|
}
|
|
if len(weekendPackages) != 1 {
|
|
t.Fatalf("weekend packages = %#v, want one", weekendPackages)
|
|
}
|
|
}
|
|
|
|
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFailingScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"run", "morning",
|
|
"--config", configPath,
|
|
}, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want aggregate failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
|
|
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
|
}
|
|
|
|
var summary app.BatchResult
|
|
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
|
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
|
}
|
|
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
|
|
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
|
|
}
|
|
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
|
|
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
|
|
}
|
|
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob daily packages: %v", err)
|
|
}
|
|
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob weekend packages: %v", err)
|
|
}
|
|
if len(dailyPackages) != 1 || len(weekendPackages) != 1 {
|
|
t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages)
|
|
}
|
|
}
|
|
|
|
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
outputDir := filepath.Join(tempDir, "copies")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"run", "evening",
|
|
"--config", configPath,
|
|
"--out-dir", outputDir,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
var summary app.BatchResult
|
|
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
|
|
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
|
|
}
|
|
if summary.Total != 1 || summary.Failed != 0 {
|
|
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil {
|
|
t.Fatalf("expected copied report: %v", err)
|
|
}
|
|
if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") {
|
|
t.Fatalf("summary reports = %#v, want output path", summary.Reports)
|
|
}
|
|
}
|
|
|
|
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"run", "morning",
|
|
"--config", configPath,
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob daily packages: %v", err)
|
|
}
|
|
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob 3-day packages: %v", err)
|
|
}
|
|
if len(dailyPackages) != 1 || len(threeDayPackages) != 1 {
|
|
t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages)
|
|
}
|
|
}
|
|
|
|
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
outPath := filepath.Join(tempDir, "daily.md")
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "daily",
|
|
"--config", configPath,
|
|
"--date", "2026-05-29",
|
|
"--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), "# Daily Report") {
|
|
t.Fatalf("report output missing markdown:\n%s", string(report))
|
|
}
|
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob data package: %v", err)
|
|
}
|
|
if len(dataPackageMatches) != 1 {
|
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
}
|
|
data, err := os.ReadFile(dataPackageMatches[0])
|
|
if err != nil {
|
|
t.Fatalf("read managed data package: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
|
}
|
|
var decoded struct {
|
|
Briefing struct {
|
|
Metadata struct {
|
|
Location struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Region string `json:"region"`
|
|
Timezone string `json:"timezone"`
|
|
} `json:"location"`
|
|
} `json:"metadata"`
|
|
} `json:"briefing"`
|
|
}
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("decode data package: %v", err)
|
|
}
|
|
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
|
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
|
}
|
|
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob preflight: %v", err)
|
|
}
|
|
if len(preflightMatches) != 1 {
|
|
t.Fatalf("preflight files = %#v, want one render output", preflightMatches)
|
|
}
|
|
preflight, err := os.ReadFile(preflightMatches[0])
|
|
if err != nil {
|
|
t.Fatalf("read preflight: %v", err)
|
|
}
|
|
if !strings.Contains(string(preflight), `ok`) {
|
|
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
|
|
}
|
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
|
if err != nil {
|
|
t.Fatalf("glob managed report: %v", err)
|
|
}
|
|
if len(reportMatches) != 1 {
|
|
t.Fatalf("managed reports = %#v, want one", reportMatches)
|
|
}
|
|
}
|
|
|
|
func TestRunInspectGeneratedArtifacts(t *testing.T) {
|
|
server := dailyServer(t)
|
|
tempDir := t.TempDir()
|
|
scriptoriumPath := writeFakeScriptorium(t, tempDir)
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
workspaceRoot := filepath.Join(tempDir, "workspace")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
runner := Runner{Clock: fixedClock()}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
err := runner.Run(context.Background(), []string{
|
|
"generate", "daily",
|
|
"--config", configPath,
|
|
"--date", "2026-05-29",
|
|
}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run(generate) error = %v", err)
|
|
}
|
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
|
|
if err != nil {
|
|
t.Fatalf("glob data package: %v", err)
|
|
}
|
|
if len(dataPackageMatches) != 1 {
|
|
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
|
|
}
|
|
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json")
|
|
|
|
stdout.Reset()
|
|
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run(inspect reports) error = %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) {
|
|
t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
|
|
}
|
|
|
|
for _, command := range []string{"metadata", "briefing", "data-package", "sources"} {
|
|
stdout.Reset()
|
|
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
|
|
if err != nil {
|
|
t.Fatalf("Run(inspect %s) error = %v", command, err)
|
|
}
|
|
if !strings.Contains(stdout.String(), runID) {
|
|
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
|
|
}
|
|
}
|
|
if !strings.Contains(stdout.String(), `"warnings"`) {
|
|
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunInspectMissingMetadata(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr)
|
|
if err == nil {
|
|
t.Fatal("Run(inspect metadata) error = nil, want missing metadata error")
|
|
}
|
|
if !strings.Contains(err.Error(), "metadata for run id") {
|
|
t.Fatalf("error = %q, want missing run id context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
configPath := filepath.Join(tempDir, "config.yml")
|
|
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
runner := Runner{Clock: fixedClock()}
|
|
commands := []string{"metadata", "briefing", "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: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
|
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
|
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
|
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req, err := runner.resolveGenerate(tt.args)
|
|
if err != nil {
|
|
t.Fatalf("resolveGenerate() error = %v", err)
|
|
}
|
|
if req.Report != tt.want {
|
|
t.Fatalf("Report = %q, want %q", req.Report, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
req, err := runner.resolveGenerate([]string{"daily"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGenerate() error = %v", err)
|
|
}
|
|
|
|
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
|
|
t.Fatalf("Date = %s, want 2026-05-29", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
|
|
if err != nil {
|
|
t.Fatalf("resolveGenerate() error = %v", err)
|
|
}
|
|
|
|
if req.Config.WeatherAPI.Units != "metric" {
|
|
t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units)
|
|
}
|
|
if req.Config.WeatherAPI.Timezone != "UTC" {
|
|
t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone)
|
|
}
|
|
if req.OutputPath != "./daily.md" {
|
|
t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
|
|
runner := Runner{Clock: fixedClock()}
|
|
|
|
_, err := runner.resolveGenerate([]string{"storm", "--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)}
|
|
}
|
|
|
|
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."]}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
return server
|
|
}
|
|
|
|
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
|
|
t.Helper()
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
|
|
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
return configPath
|
|
}
|
|
|
|
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 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=""
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "--out" ]; then
|
|
shift
|
|
out="$1"
|
|
fi
|
|
shift
|
|
done
|
|
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
|
printf 'wrote report\n' >&2
|
|
exit 0
|
|
fi
|
|
printf 'unexpected command\n' >&2
|
|
exit 1
|
|
`
|
|
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.three_day_outlook" ]; then
|
|
printf 'render failed\n' >&2
|
|
exit 1
|
|
fi
|
|
printf '{"ok":true,"prompt":"%s"}' "$prompt"
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "run" ]; then
|
|
out=""
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "--out" ]; then
|
|
shift
|
|
out="$1"
|
|
fi
|
|
shift
|
|
done
|
|
printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out"
|
|
exit 0
|
|
fi
|
|
printf 'unexpected command\n' >&2
|
|
exit 1
|
|
`
|
|
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
|
|
t.Fatalf("write fake scriptorium: %v", err)
|
|
}
|
|
return path
|
|
}
|