9 Commits

26 changed files with 1691 additions and 1232 deletions

View File

@@ -9,8 +9,7 @@ This document describes structured generated-text handling in
generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Daily render context, Today Report,
Tomorrow Report, and Hourly Report.
assets.
## Inputs And Outputs
@@ -35,9 +34,9 @@ Outputs:
- generated-text catalog handlers for report definitions that use
`generated_text_template`
The Daily generated text JSON accepts the same public fields and validation
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
template ID `daily`, with prompt ID `weather.daily_generated_text`.
## JSON Contracts
Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
```json
{
@@ -48,7 +47,21 @@ template ID `daily`, with prompt ID `weather.daily_generated_text`.
}
```
The hourly generated text JSON accepts:
The day-style contract requires `summary` after trimming whitespace.
`forecast_discussion` must contain at least one nonblank paragraph after
trimming blank items. `precipitation_timing` and `confidence` are optional and
omitted from normalized JSON when blank. Unknown fields are rejected.
The report-specific Go API is:
| Report | Type | Validator | Schema ID | Template ID | Prompt ID |
| --- | --- | --- | --- | --- | --- |
| Daily Report | `Daily` | `ValidateDaily` | `daily` | `daily` | `weather.daily_generated_text` |
| Today Report | `Today` | `ValidateToday` | `today` | `today` | `weather.today_generated_text` |
| Tomorrow Report | `Tomorrow` | `ValidateTomorrow` | `tomorrow` | `tomorrow` | `weather.tomorrow_generated_text` |
Hourly generated text uses the same top-level field names, but
`forecast_discussion` is a single string:
```json
{
@@ -59,39 +72,35 @@ The hourly generated text JSON accepts:
}
```
`summary` and `forecast_discussion` are required after trimming whitespace.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
Hourly `summary` and `forecast_discussion` are required after trimming
whitespace. `precipitation_timing` and `confidence` are optional and omitted
from normalized JSON when blank. Unknown fields are rejected. The Hourly catalog
entry uses type `Hourly`, validator `ValidateHourly`, schema ID `hourly`,
template ID `hourly`, and prompt ID `weather.hourly_generated_text`.
The Today generated text JSON accepts the same public fields and validation
rules as Tomorrow. It is selected by the active Today report definition through
schema ID `today`, template ID `today`, and prompt ID
`weather.today_generated_text`:
## Render Contexts
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
Daily, Today, Tomorrow, and Hourly render contexts all include:
The Tomorrow generated text JSON accepts:
- display metadata derived from report metadata;
- validated generated text;
- typed module outputs decoded from the module snapshot;
- collected facts;
- derived facts.
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
Daily, Today, and Tomorrow share common civil-day render-context fields such as
forecast date labels, valid period, generated-at labels, current conditions,
hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
discussion, weather story, daily summary, and ordered daypart summaries.
`summary` is required after trimming whitespace. `forecast_discussion` must
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
Each civil-day report keeps its report-specific planning module:
- Daily exposes `DailyPlanning`.
- Today exposes `TodayPlanning`.
- Tomorrow exposes `TomorrowPlanning`.
Today's ordered daypart context omits unavailable or elapsed dayparts according
to Today report rules. Daily and Tomorrow use fallback daypart behavior.
## Boundaries

View File

@@ -44,8 +44,8 @@ render-context reconstruction keep the full deterministic template surface.
The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value.
Modules that need cleanup own typed prompt export structs near the module
builder. Current custom prompt exports are:
Modules with custom prompt export policy own typed prompt export structs near
the module builder. Custom prompt exports are:
- `current_conditions`
- `hourly_forecast`

View File

@@ -12,6 +12,7 @@ Tomorrow, and Hourly.
The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/templates/partials/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
Generated-text prompt source files live under
@@ -73,6 +74,12 @@ template and renders from `generatedtext.DailyRenderContext`.
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls
`today_daypart_forecast` so it can omit elapsed or missing dayparts. Daily,
Today, and Tomorrow call the shared `precipitation_timing` partial. Partial
files are parsed with each top-level template at render time and receive the
same typed render context as the caller.
## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected
@@ -92,6 +99,7 @@ slots consumed by the template.
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Partial read or parse errors include the partial path.
- Template execution errors include the template ID and usually identify the
missing context field.
@@ -107,6 +115,7 @@ Inspect:
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Shared Markdown partials live under `templates/partials/`.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather

View File

@@ -6,13 +6,20 @@ This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports.
Templates are Go `text/template` files. The current implemented templates are:
Templates are Go `text/template` files. The implemented top-level templates
are:
- `internal/reporttemplate/templates/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
Shared named partials live under `internal/reporttemplate/templates/partials/`:
- `daypart_forecast.md.tmpl`, used by Daily and Tomorrow
- `today_daypart_forecast.md.tmpl`, used by Today
- `precipitation_timing.md.tmpl`, used by Daily, Today, and Tomorrow
Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text
@@ -26,7 +33,10 @@ validation, and artifact paths are handled before template rendering.
- Do not put weather derivation, source selection, or path construction logic in
templates.
- Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are currently registered.
- No custom template functions are registered.
- Named partials are invoked with `{{ template "name" . }}`. Pass the current
render context (`.`) unless the partial is intentionally designed for a
narrower value.
- Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
@@ -103,6 +113,9 @@ Tomorrow generated text uses the same `.GeneratedText.Summary`,
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow uses the shared `daypart_forecast` and `precipitation_timing`
partials.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
@@ -131,6 +144,8 @@ Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Daily uses the shared `daypart_forecast` and `precipitation_timing` partials.
Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`.
@@ -162,6 +177,10 @@ Today generated text uses `.GeneratedText.Summary`,
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`.
Today uses the `today_daypart_forecast` partial so elapsed or missing dayparts
can be omitted while Daily and Tomorrow keep their fallback row. It also uses
the shared `precipitation_timing` partial.
Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`.
@@ -282,6 +301,10 @@ Daily, Today, and Tomorrow templates should use `.Modules.Dayparts` for
ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`.
The shared daypart partials render from these same `.Modules.Dayparts` values.
Edit `daypart_forecast.md.tmpl` for common Daily/Tomorrow wording, and edit
`today_daypart_forecast.md.tmpl` for Today-specific omission behavior.
Common rich daypart fields:
| Variable | Type | Description |

View File

@@ -140,61 +140,57 @@ func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, e
}
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}, "run scriptorium", "scriptorium run")
if err != nil {
if result == nil {
return nil, err
}
return result.runResult(), err
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
return result.runResult(), nil
}
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
result, err := r.executeRun(ctx, outputRunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}, "run scriptorium structured output", "scriptorium structured run")
if err != nil {
if result == nil {
return nil, err
}
return result.structuredRunResult(), err
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
return result.structuredRunResult(), nil
}
func (result outputRunResult) runResult() *RunResult {
return &RunResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}
}
func (result outputRunResult) structuredRunResult() *StructuredRunResult {
return &StructuredRunResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}
return result, nil
}
type execution struct {
@@ -203,6 +199,55 @@ type execution struct {
result CommandResult
}
type outputRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type outputRunResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
}))
if err != nil {
return nil, fmt.Errorf("%s: %w", executeContext, err)
}
result := &outputRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("%s exited with code %d: %s", exitContext, execution.result.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary
if binary == "" {
@@ -257,14 +302,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args
}
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct {
data []byte
limit int

View File

@@ -2,6 +2,7 @@ package scriptorium
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
@@ -236,53 +237,281 @@ func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
}
}
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
func TestOutputRunsPreserveCapturedResultFields(t *testing.T) {
type commonResult struct {
Command []string
Stdout string
Stderr string
StdoutTruncated bool
StderrTruncated bool
ExitCode int
OutputPath string
}
tests := []struct {
name string
req StructuredRunRequest
want string
run func(Runner) (*commonResult, error)
}{
{
name: "prompt id",
req: StructuredRunRequest{
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
want: "prompt id is required",
},
{
name: "data package path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
OutputPath: "/tmp/hourly.generated_text.raw.json",
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Command: result.Command,
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{}
runner := Runner{Commands: commands}
result, err := runner.StructuredRun(context.Background(), test.req)
commands := &fakeCommands{result: CommandResult{
Stdout: []byte("captured stdout"),
Stderr: []byte("captured stderr"),
StdoutTruncated: true,
StderrTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 15 * time.Second,
Commands: commands,
}
result, err := test.run(runner)
if err != nil {
t.Fatalf("%s error = %v", test.name, err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.markdown_report",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/report.md",
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 15*time.Second {
t.Fatalf("timeout = %s, want 15s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("Command = %#v, want full argv", result.Command)
}
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" {
t.Fatalf("captured output = %q/%q, want stdout/stderr", result.Stdout, result.Stderr)
}
if !result.StdoutTruncated || !result.StderrTruncated {
t.Fatalf("truncation flags = %t/%t, want both true", result.StdoutTruncated, result.StderrTruncated)
}
if result.ExitCode != 0 || result.OutputPath != "/tmp/report.md" {
t.Fatalf("result = %#v, want exit 0 and output path", result)
}
})
}
}
func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) {
type commonResult struct {
Stdout string
Stderr string
StderrTruncated bool
ExitCode int
OutputPath string
}
tests := []struct {
name string
run func(Runner) (*commonResult, error)
wantErr string
}{
{
name: "Run",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
wantErr: "scriptorium run exited with code 7: captured stderr",
},
{
name: "StructuredRun",
run: func(runner Runner) (*commonResult, error) {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.markdown_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/report.md",
})
if result == nil {
return nil, err
}
return &commonResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
OutputPath: result.OutputPath,
}, err
},
wantErr: "scriptorium structured run exited with code 7: captured stderr",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{result: CommandResult{
Stdout: []byte("captured stdout"),
Stderr: []byte("captured stderr"),
StderrTruncated: true,
ExitCode: 7,
}},
}
result, err := test.run(runner)
if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error")
t.Fatalf("%s error = nil, want nonzero exit error", test.name)
}
if result != nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result)
if result == nil {
t.Fatalf("%s result = nil, want captured result", test.name)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
if err.Error() != test.wantErr {
t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated {
t.Fatalf("captured result = %#v, want stdout/stderr/truncation", result)
}
if result.ExitCode != 7 || result.OutputPath != "/tmp/report.md" {
t.Fatalf("result = %#v, want exit 7 and output path", result)
}
})
}
}
func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
run func(Runner, string, string, string) error
}{
{
name: "Run",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
result, err := runner.Run(context.Background(), RunRequest{
PromptID: promptID,
DataPackagePath: dataPackagePath,
OutputPath: outputPath,
})
if result != nil {
return fmt.Errorf("result = %#v, want nil", result)
}
return err
},
},
{
name: "StructuredRun",
run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error {
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: promptID,
DataPackagePath: dataPackagePath,
OutputPath: outputPath,
})
if result != nil {
return fmt.Errorf("result = %#v, want nil", result)
}
return err
},
},
}
cases := []struct {
name string
promptID string
dataPackagePath string
outputPath string
want string
}{
{
name: "prompt id",
dataPackagePath: "/tmp/data_package.yaml",
outputPath: "/tmp/report.md",
want: "prompt id is required",
},
{
name: "data package path",
promptID: "weather.markdown_report",
outputPath: "/tmp/report.md",
want: "data package path is required",
},
{
name: "output path",
promptID: "weather.markdown_report",
dataPackagePath: "/tmp/data_package.yaml",
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
commands := &fakeCommands{}
err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath)
if err == nil {
t.Fatalf("%s error = nil, want validation error", test.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s error = %v, want %q", test.name, err, tc.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
})
}

View File

@@ -83,16 +83,11 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
@@ -113,11 +108,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
runBody: "# Daily Report\n\nRain this morning.\n",
}
outputPath := filepath.Join(t.TempDir(), "daily.md")
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := &recordingStore{Store: filesystemStore}
store := recordingFilesystemStore(t, cfg)
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -342,20 +333,12 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: tt.kind,
Date: tt.date,
}, tt.now)
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
}
}, tt.now.Format(time.RFC3339))
renderer := successfulGeneratedTextRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -477,28 +460,12 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
server := hourlyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportHourly,
}, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
cfg := hourlyTestConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00")
if resolved.Definition.GenerationMode != report.GenerationModeGeneratedTextTemplate {
t.Fatalf("GenerationMode = %q, want generated text template", resolved.Definition.GenerationMode)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := &recordingStore{Store: filesystemStore}
store := recordingFilesystemStore(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
@@ -642,18 +609,8 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
notifier.result = &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
notifier.result = successfulNotificationResult()
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -725,38 +682,21 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
outputPath := filepath.Join(t.TempDir(), "today-copy.md")
notifier := &recordingNotifier{
result: &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
},
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTodayGeneratedTextJSON(),
result: successfulNotificationResult(),
}
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -941,28 +881,17 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}"
cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}"
cfg.Notify.Distributor.ReportPathTemplates = []string{"{valid_start_date}/{artifact_group}/{batch_output_name}"}
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, mustParse("2026-05-29T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T18:00:00-05:00")
notifier := &recordingNotifier{}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTomorrowGeneratedTextJSON(),
}
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1080,15 +1009,11 @@ func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: `{
renderer := successfulGeneratedTextRenderer(`{
"summary": "Storm chances increase through late morning.",
"forecast_discussion": "A front will keep the region unsettled.",
"details": "not allowed"
}`,
}
}`)
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1116,11 +1041,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
resolved.Definition.TemplateID = "missing-template"
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON())
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1150,19 +1071,14 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *te
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err = GenerateReport(context.Background(), ReportRequest{
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
@@ -1178,26 +1094,13 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{
result: &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
},
result: successfulNotificationResult(),
}
outputPath := filepath.Join(t.TempDir(), "daily-copy.md")
@@ -1266,25 +1169,15 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{err: errors.New("upload rejected")}
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
_, err = GenerateReport(context.Background(), ReportRequest{
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
@@ -1342,21 +1235,14 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyNotificationConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err = GenerateReport(context.Background(), ReportRequest{
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: tt.renderer,
@@ -1379,17 +1265,13 @@ func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{}
_, err = GenerateReport(context.Background(), ReportRequest{
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
@@ -1405,18 +1287,11 @@ func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{
Command: []string{"scriptorium", "render"},
@@ -1426,7 +1301,7 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
err: errors.New("scriptorium render exited with code 1: render failed"),
}
_, err = GenerateReport(context.Background(), ReportRequest{
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
@@ -1434,10 +1309,7 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
if err == nil {
t.Fatal("GenerateReport() error = nil, want render error")
}
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
@@ -1460,18 +1332,11 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{
@@ -1481,7 +1346,7 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"),
}
_, err = GenerateReport(context.Background(), ReportRequest{
_, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
@@ -1492,10 +1357,7 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 {
t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls)
}
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
store := recordingFilesystemStore(t, cfg)
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
@@ -1513,32 +1375,18 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -1568,37 +1416,19 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportToday,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTodayGeneratedTextJSON(),
}
}, "2026-05-29T05:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1655,22 +1485,11 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, mustParse("2026-05-29T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTomorrowGeneratedTextJSON(),
}
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1732,35 +1551,17 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, mustParse("2026-05-29T17:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-29T17:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, mustParse("2026-05-29T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validTomorrowGeneratedTextJSON(),
}
}, "2026-05-29T18:00:00-05:00")
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
@@ -1781,31 +1582,17 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportTomorrow,
}, mustParse("2026-05-28T18:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-28T18:00:00-05:00")
savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: currentResolved,
@@ -1825,29 +1612,15 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportThreeDay,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -1884,29 +1657,15 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
}, "2026-05-29T04:00:00-05:00")
savePriorRun(t, store, priorResolved, priorOutlookModuleSnapshot(t, "2026-05-30"))
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportWeekend,
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -1943,19 +1702,12 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
func TestGenerateStormReportWritesReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportStorm,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T10:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -1993,18 +1745,11 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
func TestInspectGeneratedReportArtifacts(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -2061,30 +1806,16 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
func TestInspectPriorSnapshot(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
store, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
priorResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
cfg := dailyWorkspaceConfig(t, server)
store := recordingFilesystemStore(t, cfg)
priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T04:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(prior) error = %v", err)
}
currentResolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
}, "2026-05-29T04:00:00-05:00")
currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate(current) error = %v", err)
}
}, "2026-05-29T05:00:00-05:00")
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
@@ -2381,10 +2112,7 @@ func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg := dailyWorkspaceConfig(t, server)
renderer := &selectiveRenderer{
failRenderPrompt: "weather.three_day_outlook",
runBody: "# Batch Report\n",
@@ -2422,12 +2150,7 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg := dailyNotificationConfig(t, server)
notifier := &recordingNotifier{
errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"),
@@ -2507,10 +2230,7 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
func TestRunBatchUsesOutputDirectory(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg := dailyWorkspaceConfig(t, server)
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
@@ -2548,10 +2268,7 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg := dailyWorkspaceConfig(t, server)
outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{
@@ -2617,6 +2334,70 @@ func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config {
return cfg
}
func dailyWorkspaceConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
return cfg
}
func dailyNotificationConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := dailyWorkspaceConfig(t, server)
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
return cfg
}
func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved {
t.Helper()
req.Config = cfg
resolved, err := ResolveGenerate(req, mustParse(now))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
return resolved
}
func recordingFilesystemStore(t *testing.T, cfg config.Config) *recordingStore {
t.Helper()
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return &recordingStore{Store: filesystemStore}
}
func successfulNotificationResult() *NotificationResult {
return &NotificationResult{
RunID: "distributor-run",
Status: "succeeded",
UploadStatus: "accepted",
PipelineID: "reports",
Report: []byte(`{"actions":[{"action":"replace_older"}]}`),
}
}
func successfulGeneratedTextRenderer(body string) *recordingRenderer {
return &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: body,
}
}
func hourlyTestConfig(t *testing.T, server *httptest.Server) config.Config {
t.Helper()
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
return cfg
}
func hourlyGeneratedTextFixture(t *testing.T) (config.Config, report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
server := hourlyBundleServer(t)
@@ -2653,12 +2434,10 @@ func hourlyGeneratedTextConfigWithModules(t *testing.T, server *httptest.Server,
func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *httptest.Server) {
t.Helper()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
cfg.Location.ID = "home"
cfg.Location.Name = "Brentwood"
cfg.Location.Region = "MO"
base := hourlyTestConfig(t, server)
cfg.WeatherAPI = base.WeatherAPI
cfg.Workspace = base.Workspace
cfg.Location = base.Location
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}"
cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}"
@@ -2668,18 +2447,8 @@ func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *
func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) {
t.Helper()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportHourly,
}, mustParse("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
filesystemStore, err := state.NewFilesystemStore(cfg.Workspace)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return resolved, &recordingStore{Store: filesystemStore}, &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00")
return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md")
}
func validHourlyGeneratedTextJSON() string {

View File

@@ -65,17 +65,13 @@ func TestRunUnknownCommand(t *testing.T) {
}
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")
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("storm.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "storm",
"--config", configPath,
"--config", fixture.configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--out", outPath,
@@ -84,143 +80,113 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.yaml")
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) {
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")
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("tomorrow.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "tomorrow",
"--config", configPath,
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Saturday's Weather")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
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, workspaceRoot, "reports", "tomorrow", "*.md")
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) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"run", "evening",
"--config", configPath,
"--config", fixture.configPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
reportPath := oneArtifact(t, workspaceRoot, "reports", "tomorrow", "*.md")
_ = 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) {
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, "three-day.md")
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("three-day.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "three-day",
"--config", configPath,
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
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) {
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, "weekend.md")
fixture := newCLIFixture(t, writeFakeScriptorium)
outPath := fixture.path("weekend.md")
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"generate", "weekend",
"--config", configPath,
"--config", fixture.configPath,
"--out", outPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
assertFileContains(t, outPath, "# Daily Report")
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
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 TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner,
"run", "morning",
"--config", configPath,
"--config", fixture.configPath,
)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
_ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
if len(dailyPackages) != 0 {
t.Fatalf("daily packages = %#v, want morning batch to use Today only", dailyPackages)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(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) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
fixture := newCLIFixture(t, writeFailingScriptorium)
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "morning",
"--config", configPath,
"--config", fixture.configPath,
)
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
@@ -239,8 +205,8 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
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, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
}
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
@@ -326,17 +292,13 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
fixture := newCLIFixture(t, writeFakeScriptorium)
outputDir := fixture.path("copies")
runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner,
"run", "evening",
"--config", configPath,
"--config", fixture.configPath,
"--out-dir", outputDir,
)
if err != nil {
@@ -375,12 +337,8 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
t.Cleanup(distributorServer.Close)
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 + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -421,12 +379,8 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
t.Cleanup(distributorServer.Close)
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 + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorServer.URL + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -460,61 +414,33 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
}
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(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)
}
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", configPath,
"--config", fixture.configPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
todayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob today packages: %v", err)
}
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob 3-day packages: %v", err)
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
if len(todayPackages) != 1 || len(threeDayPackages) != 1 || len(dailyPackages) != 0 {
t.Fatalf("today packages = %#v, 3-day packages = %#v, daily packages = %#v; want Today and 3-day only", todayPackages, threeDayPackages, dailyPackages)
}
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
}
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")
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", configPath,
"--config", fixture.configPath,
"--date", "2026-05-29",
"--tz", "UTC",
"--out", outPath,
@@ -529,14 +455,8 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Friday's Weather") {
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.yaml"))
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])
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)
}
@@ -550,31 +470,19 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
!strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data))
}
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil {
t.Fatalf("glob preflight: %v", err)
}
if len(preflightMatches) != 1 {
t.Fatalf("preflight files = %#v, want one render output", preflightMatches)
}
preflight, err := os.ReadFile(preflightMatches[0])
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))
}
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)
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.render_context.json")
metadataPath := oneArtifact(t, workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json")
_ = 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"`)
@@ -582,19 +490,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
}
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "today.md")
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", configPath,
"--config", fixture.configPath,
"--date", "2026-05-29",
"--out", outPath,
}, &stdout, &stderr)
@@ -614,7 +518,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
t.Fatalf("today report output missing %q:\n%s", want, string(report))
}
}
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
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)
@@ -624,17 +528,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "today_planning:") {
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage))
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
if len(dailyPackages) != 0 {
t.Fatalf("daily packages = %#v, want generate today to stay separate", dailyPackages)
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "today", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "today", "*.md")
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"`)
@@ -642,19 +540,15 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
}
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "hourly.md")
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", configPath,
"--config", fixture.configPath,
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
@@ -674,7 +568,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
t.Fatalf("report output missing %q:\n%s", want, string(report))
}
}
dataPackagePath := oneArtifact(t, workspaceRoot, "data-packages", "hourly", "2026-05-29", "*.data_package.yaml")
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)
@@ -684,10 +578,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage))
}
rawGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
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": {`)
@@ -699,38 +593,24 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
}
func TestRunInspectTodayArtifacts(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)
}
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", configPath,
"--config", fixture.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", "today", "2026-05-29", "*.data_package.yaml"))
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.yaml")
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", configPath, "--limit", "1"}, &stdout, &stderr)
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)
}
@@ -741,7 +621,7 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
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)
}
@@ -758,38 +638,24 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
}
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)
}
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", configPath,
"--config", fixture.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.yaml"))
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.yaml")
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", configPath, "--limit", "1"}, &stdout, &stderr)
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)
}
@@ -800,7 +666,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
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)
}
@@ -818,11 +684,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
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)
}
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
@@ -838,11 +700,7 @@ func TestRunInspectMissingMetadata(t *testing.T) {
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)
}
configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
@@ -936,10 +794,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
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 {
@@ -1259,16 +1114,56 @@ func dailyServer(t *testing.T) *httptest.Server {
return server
}
func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string {
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")
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 {
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...)...))
@@ -1281,6 +1176,17 @@ func oneArtifact(t *testing.T, root string, parts ...string) string {
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)

View File

@@ -313,6 +313,37 @@ func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
}
}
func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(t *testing.T) {
cfg := Defaults()
rawOptions := map[string]any{
"sections": []any{"short_term"},
}
cfg.Reports = map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.AreaForecastDiscussion, Options: rawOptions},
},
deterministicModulesSet: true,
},
}
overrides, err := cfg.ReportModuleOverrides()
if err != nil {
t.Fatalf("ReportModuleOverrides() error = %v", err)
}
options, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
}
if strings.Join(options.Sections, ",") != "short_term" {
t.Fatalf("override sections = %#v, want short_term", options.Sections)
}
if got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any); !ok || !reflect.DeepEqual(got, rawOptions) {
t.Fatalf("config options after ReportModuleOverrides = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
}
}
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct {
@@ -510,6 +541,157 @@ reports:
}
}
func TestReportModuleValidationConsistentForLoadedAndConstructedConfig(t *testing.T) {
tests := []struct {
name string
yaml string
reports map[string]ReportConfig
wantErr string
}{
{
name: "UnknownReport",
yaml: `
reports:
moon:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"moon": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "reports.moon",
},
{
name: "DuplicateReportAlias",
yaml: `
reports:
three-day:
deterministic_modules:
- metadata
three_day:
deterministic_modules:
- metadata
`,
reports: map[string]ReportConfig{
"three-day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
"three_day": {
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
deterministicModulesSet: true,
},
},
wantErr: "duplicates report override",
},
{
name: "UnknownModule",
yaml: `
reports:
daily:
deterministic_modules:
- missing_module
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.ID("missing_module")}},
deterministicModulesSet: true,
},
},
wantErr: `unknown module "missing_module"`,
},
{
name: "DuplicateModule",
yaml: `
reports:
daily:
deterministic_modules:
- metadata
- metadata
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
},
deterministicModulesSet: true,
},
},
wantErr: `duplicate module "metadata"`,
},
{
name: "IncompatibleModule",
yaml: `
reports:
daily:
deterministic_modules:
- tomorrow_planning
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{{ID: module.TomorrowPlanning}},
deterministicModulesSet: true,
},
},
wantErr: `not compatible with report "daily"`,
},
{
name: "InvalidOptions",
yaml: `
reports:
daily:
deterministic_modules:
- id: metadata
options:
sections:
- short_term
`,
reports: map[string]ReportConfig{
"daily": {
DeterministicModules: []ModuleConfigItem{
{
ID: module.Metadata,
Options: map[string]any{
"sections": []any{"short_term"},
},
},
},
deterministicModulesSet: true,
},
},
wantErr: "options are invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, loadErr := LoadFile(writeConfig(t, tt.yaml))
assertReportModuleError(t, "LoadFile", loadErr, tt.wantErr)
cfg := Defaults()
cfg.Reports = tt.reports
assertReportModuleError(t, "Validate", Validate(cfg), tt.wantErr)
_, overrideErr := cfg.ReportModuleOverrides()
assertReportModuleError(t, "ReportModuleOverrides", overrideErr, tt.wantErr)
})
}
}
func assertReportModuleError(t *testing.T, operation string, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("%s error = nil, want %q", operation, want)
}
if !strings.Contains(err.Error(), want) {
t.Fatalf("%s error = %q, want %q", operation, err.Error(), want)
}
}
func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_")
}

View File

@@ -12,116 +12,92 @@ import (
)
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{}
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
id, err := report.IDForConfigKey(key)
if err != nil {
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[id]; ok {
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[id] = key
if !reportCfg.deterministicModulesSet {
continue
}
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
}
return overrides, nil
return traverseReportModules(&cfg, reportModuleTraversalOptions{
normalizeOptions: true,
})
}
func normalizeReportModules(cfg *Config) error {
if cfg.Reports == nil {
cfg.Reports = map[string]ReportConfig{}
}
moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil {
return fmt.Errorf("initialize module registry: %w", err)
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
for i, rawItem := range reportCfg.DeterministicModules {
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
reportCfg.DeterministicModules[i].Options = options
}
items := moduleItemsFromConfig(reportCfg.DeterministicModules)
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
cfg.Reports[key] = reportCfg
}
return nil
_, err := traverseReportModules(cfg, reportModuleTraversalOptions{
normalizeOptions: true,
updateConfig: true,
})
return err
}
func validateReportModules(cfg Config) error {
_, err := traverseReportModules(&cfg, reportModuleTraversalOptions{
normalizeOptions: true,
})
return err
}
type reportModuleTraversalOptions struct {
normalizeOptions bool
updateConfig bool
}
func traverseReportModules(cfg *Config, opts reportModuleTraversalOptions) (map[report.ID][]module.ConfigItem, error) {
overrides := map[report.ID][]module.ConfigItem{}
if cfg.Reports == nil {
return nil
return overrides, nil
}
moduleRegistry, err := briefing.DefaultModuleRegistry()
if err != nil {
return fmt.Errorf("initialize module registry: %w", err)
return nil, fmt.Errorf("initialize module registry: %w", err)
}
reportRegistry := report.DefaultRegistry()
seenReports := map[report.ID]string{}
for key, reportCfg := range cfg.Reports {
reportID, err := report.IDForConfigKey(key)
if err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if previous, ok := seenReports[reportID]; ok {
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
}
seenReports[reportID] = key
if _, err := reportRegistry.Lookup(reportID); err != nil {
return fmt.Errorf("reports.%s: %w", key, err)
return nil, fmt.Errorf("reports.%s: %w", key, err)
}
if !reportCfg.deterministicModulesSet {
continue
}
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
for i, rawItem := range reportCfg.DeterministicModules {
options := rawItem.Options
if options != nil {
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
if err != nil {
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
}
options = normalized
}
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
items, normalized, err := moduleItemsFromConfig(moduleRegistry, key, reportCfg.DeterministicModules, opts.normalizeOptions)
if err != nil {
return nil, err
}
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
return nil, fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
}
overrides[reportID] = items
if opts.updateConfig {
reportCfg.DeterministicModules = normalized
cfg.Reports[key] = reportCfg
}
}
return nil
return overrides, nil
}
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem {
func moduleItemsFromConfig(registry briefing.ModuleRegistry, reportKey string, items []ModuleConfigItem, normalizeOptions bool) ([]module.ConfigItem, []ModuleConfigItem, error) {
out := make([]module.ConfigItem, 0, len(items))
for _, item := range items {
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options})
normalizedItems := append([]ModuleConfigItem(nil), items...)
for i, item := range items {
options := item.Options
if normalizeOptions {
var err error
options, err = normalizeModuleOptions(registry, item.ID, item.Options)
if err != nil {
return nil, nil, fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", reportKey, i, err)
}
normalizedItems[i].Options = options
}
out = append(out, module.ConfigItem{ID: item.ID, Options: options})
}
return out
return out, normalizedItems, nil
}
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {

View File

@@ -1,10 +1,5 @@
package generatedtext
import (
"fmt"
"strings"
)
type Daily struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,25 +8,21 @@ type Daily struct {
}
func ValidateDaily(data []byte) (Daily, []byte, error) {
value, err := decodeGeneratedText[Daily](data, "daily")
if err != nil {
return Daily{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Daily{}, nil, fmt.Errorf("daily generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "daily")
if err != nil {
return Daily{}, nil, err
}
return value, normalized, nil
return validateDayStyleGeneratedText[Daily, *Daily](data, "daily")
}
func (d *Daily) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: d.Summary,
ForecastDiscussion: d.ForecastDiscussion,
PrecipitationTiming: d.PrecipitationTiming,
Confidence: d.Confidence,
}
}
func (d *Daily) setDayStyleFields(fields dayStyleFields) {
d.Summary = fields.Summary
d.ForecastDiscussion = fields.ForecastDiscussion
d.PrecipitationTiming = fields.PrecipitationTiming
d.Confidence = fields.Confidence
}

View File

@@ -0,0 +1,63 @@
package generatedtext
import (
"fmt"
"strings"
)
type dayStyleFields struct {
Summary string
ForecastDiscussion []string
PrecipitationTiming string
Confidence string
}
type dayStyleGeneratedText interface {
dayStyleFields() dayStyleFields
setDayStyleFields(dayStyleFields)
}
func validateDayStyleGeneratedText[T any, PT interface {
*T
dayStyleGeneratedText
}](data []byte, name string) (T, []byte, error) {
value, err := decodeGeneratedText[T](data, name)
if err != nil {
var zero T
return zero, nil, err
}
pointer := PT(&value)
fields := pointer.dayStyleFields()
fields.Summary = strings.TrimSpace(fields.Summary)
fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming)
fields.Confidence = strings.TrimSpace(fields.Confidence)
fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion)
if fields.Summary == "" {
var zero T
return zero, nil, fmt.Errorf("%s generated text summary is required", name)
}
if len(fields.ForecastDiscussion) == 0 {
var zero T
return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name)
}
pointer.setDayStyleFields(fields)
normalized, err := normalizeGeneratedText(value, name)
if err != nil {
var zero T
return zero, nil, err
}
return value, normalized, nil
}
func trimNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}

View File

@@ -0,0 +1,131 @@
package generatedtext
import (
"fmt"
"strings"
"testing"
)
func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) {
reports := []struct {
name string
validate func([]byte) (any, []byte, error)
}{
{
name: "daily",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateDaily(data)
return value, normalized, err
},
},
{
name: "today",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateToday(data)
return value, normalized, err
},
},
{
name: "tomorrow",
validate: func(data []byte) (any, []byte, error) {
value, normalized, err := ValidateTomorrow(data)
return value, normalized, err
},
},
}
for _, report := range reports {
t.Run(report.name, func(t *testing.T) {
t.Run("requires summary", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`))
want := fmt.Sprintf("%s generated text summary is required", report.name)
if err == nil || err.Error() != want {
t.Fatalf("validate() error = %v, want %q", err, want)
}
})
t.Run("requires non-empty forecast discussion", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`))
want := fmt.Sprintf("%s generated text forecast discussion is required", report.name)
if err == nil || err.Error() != want {
t.Fatalf("validate() error = %v, want %q", err, want)
}
})
t.Run("trims and normalizes", func(t *testing.T) {
value, normalized, err := report.validate([]byte(`{
"summary": " Shared summary. ",
"forecast_discussion": [
" First paragraph. ",
"",
" Second paragraph. "
],
"precipitation_timing": " Afternoon. ",
"confidence": " Medium "
}`))
if err != nil {
t.Fatalf("validate() error = %v", err)
}
fields := dayStyleFieldsForTest(t, value)
if fields.Summary != "Shared summary." {
t.Fatalf("Summary = %q, want trimmed summary", fields.Summary)
}
if strings.Join(fields.ForecastDiscussion, "|") != "First paragraph.|Second paragraph." {
t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", fields.ForecastDiscussion)
}
if fields.PrecipitationTiming != "Afternoon." {
t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming)
}
if fields.Confidence != "Medium" {
t.Fatalf("Confidence = %q, want trimmed confidence", fields.Confidence)
}
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon.","confidence":"Medium"}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
})
t.Run("omits empty optional fields", func(t *testing.T) {
_, normalized, err := report.validate([]byte(`{
"summary": "Shared summary.",
"forecast_discussion": ["First paragraph."],
"precipitation_timing": " ",
"confidence": " "
}`))
if err != nil {
t.Fatalf("validate() error = %v", err)
}
want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}`
if string(normalized) != want {
t.Fatalf("normalized = %s, want %s", normalized, want)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
_, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`))
if err == nil {
t.Fatal("validate() error = nil, want error")
}
if !strings.Contains(err.Error(), `unknown field "extra"`) {
t.Fatalf("validate() error = %v, want unknown field error", err)
}
})
})
}
}
func dayStyleFieldsForTest(t *testing.T, value any) dayStyleFields {
t.Helper()
switch value := value.(type) {
case Daily:
return (&value).dayStyleFields()
case Today:
return (&value).dayStyleFields()
case Tomorrow:
return (&value).dayStyleFields()
default:
t.Fatalf("value type = %T, want day-style generated text", value)
return dayStyleFields{}
}
}

View File

@@ -165,6 +165,31 @@ type DailyDaypartContext struct {
Summary briefing.DerivedDaypartSummaryModule
}
type dayStyleReportContext struct {
Title string
ForecastDate time.Time
ForecastDateLabel string
ForecastDayName string
GeneratedAt time.Time
GeneratedAtLabel string
ValidPeriod timeutil.Period
Timezone string
}
type dayStyleTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
AreaForecastDiscussion *briefing.AreaForecastDiscussionModule
SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule
WeatherStory *briefing.WeatherStoryModule
}
func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
@@ -198,33 +223,18 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
}
func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil {
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required")
return DailyRenderContext{}, err
}
modules, err := dailyTemplateModules(snapshot, derived)
if err != nil {
return DailyRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return DailyRenderContext{
Report: DailyReportContext{
Title: forecastDayName + "'s Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
Report: reportContext.dailyReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
@@ -233,32 +243,18 @@ func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
}
func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string {
return "Today's Weather"
})
if err != nil {
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required")
return TodayRenderContext{}, err
}
modules, err := todayTemplateModules(snapshot, derived)
if err != nil {
return TodayRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
return TodayRenderContext{
Report: TodayReportContext{
Title: "Today's Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDate.Format("Monday"),
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
Report: reportContext.todayReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
@@ -267,33 +263,18 @@ func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapsho
}
func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string {
return dayName + "'s Weather"
})
if err != nil {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err)
}
if metadata.GeneratedAt.IsZero() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required")
}
if !metadata.ValidPeriod.IsValid() {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required")
return TomorrowRenderContext{}, err
}
modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil {
return TomorrowRenderContext{}, err
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return TomorrowRenderContext{
Report: TomorrowReportContext{
Title: forecastDayName + "'s Weather",
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
},
Report: reportContext.tomorrowReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
@@ -301,49 +282,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil
}
func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err)
}
if metadata.GeneratedAt.IsZero() {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: generatedAt is required", name)
}
if !metadata.ValidPeriod.IsValid() {
return dayStyleReportContext{}, fmt.Errorf("build %s render context: valid period is required", name)
}
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return dayStyleReportContext{
Title: title(forecastDayName),
ForecastDate: forecastDate,
ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"),
ForecastDayName: forecastDayName,
GeneratedAt: metadata.GeneratedAt,
GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location),
ValidPeriod: metadata.ValidPeriod,
Timezone: metadata.Timezone,
}, nil
}
func (ctx dayStyleReportContext) dailyReportContext() DailyReportContext {
return DailyReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func (ctx dayStyleReportContext) todayReportContext() TodayReportContext {
return TodayReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func (ctx dayStyleReportContext) tomorrowReportContext() TomorrowReportContext {
return TomorrowReportContext{
Title: ctx.Title,
ForecastDate: ctx.ForecastDate,
ForecastDateLabel: ctx.ForecastDateLabel,
ForecastDayName: ctx.ForecastDayName,
GeneratedAt: ctx.GeneratedAt,
GeneratedAtLabel: ctx.GeneratedAtLabel,
ValidPeriod: ctx.ValidPeriod,
Timezone: ctx.Timezone,
}
}
func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return DailyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return DailyTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return DailyTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return DailyTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return DailyTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return DailyTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return DailyTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return DailyTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return DailyTemplateModules{}, err
}
story, err := lookup.weatherStory()
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return DailyTemplateModules{}, err
}
@@ -356,66 +361,25 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return DailyTemplateModules{}, err
}
return DailyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
OutdoorWindows: outdoor,
DailyPlanning: planning,
}, nil
}
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TodayTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TodayTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TodayTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TodayTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TodayTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TodayTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TodayTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TodayTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TodayTemplateModules{}, err
}
story, err := lookup.weatherStory()
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return TodayTemplateModules{}, err
}
@@ -424,18 +388,18 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return TodayTemplateModules{}, err
}
return TodayTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
TodayPlanning: planning,
}, nil
}
@@ -492,48 +456,7 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
}
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return TomorrowTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return TomorrowTemplateModules{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return TomorrowTemplateModules{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return TomorrowTemplateModules{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return TomorrowTemplateModules{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return TomorrowTemplateModules{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return TomorrowTemplateModules{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return TomorrowTemplateModules{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return TomorrowTemplateModules{}, err
}
story, err := lookup.weatherStory()
common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot)
if err != nil {
return TomorrowTemplateModules{}, err
}
@@ -542,20 +465,81 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
return TomorrowTemplateModules{}, err
}
return TomorrowTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTomorrowDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: common.WeatherStory,
TomorrowPlanning: planning,
}, nil
}
func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
hourly, err := lookup.hourlyForecast()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
daily, err := lookup.derivedDailySummary()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
dayparts, err := lookup.derivedDaypartSummaries()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
precip, err := lookup.precipTiming()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
alerts, err := lookup.alertDigest()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
outlooks, err := lookup.spcConvectiveOutlooks()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
discussion, err := lookup.areaForecastDiscussion()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
spcDiscussion, err := lookup.spcConvectiveDiscussion()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
story, err := lookup.weatherStory()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
return dayStyleTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts,
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip,
AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story,
TomorrowPlanning: planning,
}, nil
}, lookup, nil
}
type moduleSnapshotLookup struct {

View File

@@ -272,6 +272,192 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
}
}
func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *testing.T) {
type result struct {
title string
forecastDateLabel string
generatedAtLabel string
timezone string
validPeriod timeutil.Period
metadata bool
current bool
hourly bool
dailySummary bool
daypartSummaries bool
dayparts int
precip bool
alerts bool
outlooks bool
discussion bool
spcDiscussion bool
story bool
planning bool
collected bool
derived bool
}
tests := []struct {
name string
wantTitle string
wantGeneratedLabel string
wantDayparts int
build func(t *testing.T) result
}{
{
name: "daily",
wantTitle: "Monday's Weather",
wantGeneratedLabel: "Saturday, June 13, 2026 at 9:14 AM",
wantDayparts: 3,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{
Summary: "Daily summary.",
ForecastDiscussion: []string{"Daily discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.DailyPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
{
name: "today",
wantTitle: "Today's Weather",
wantGeneratedLabel: "Monday, June 15, 2026 at 7:14 AM",
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{
Summary: "Today summary.",
ForecastDiscussion: []string{"Today discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TodayPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
{
name: "tomorrow",
wantTitle: "Monday's Weather",
wantGeneratedLabel: "Sunday, June 14, 2026 at 9:14 AM",
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{
Summary: "Tomorrow summary.",
ForecastDiscussion: []string{"Tomorrow discussion."},
}, collected, derived)
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
return result{
title: ctx.Report.Title,
forecastDateLabel: ctx.Report.ForecastDateLabel,
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil,
dayparts: len(ctx.Modules.Dayparts),
precip: ctx.Modules.PrecipTiming != nil,
alerts: ctx.Modules.AlertDigest != nil,
outlooks: ctx.Modules.SPCConvectiveOutlooks != nil,
discussion: ctx.Modules.AreaForecastDiscussion != nil,
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TomorrowPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := test.build(t)
if result.title != test.wantTitle {
t.Fatalf("title = %q, want %q", result.title, test.wantTitle)
}
if result.forecastDateLabel != "Monday, June 15, 2026" {
t.Fatalf("forecastDateLabel = %q, want Monday, June 15, 2026", result.forecastDateLabel)
}
if result.generatedAtLabel != test.wantGeneratedLabel {
t.Fatalf("generatedAtLabel = %q, want %q", result.generatedAtLabel, test.wantGeneratedLabel)
}
if result.timezone != "America/Chicago" {
t.Fatalf("timezone = %q, want America/Chicago", result.timezone)
}
if !result.validPeriod.IsValid() {
t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod)
}
if !result.metadata || !result.current || !result.hourly || !result.dailySummary || !result.daypartSummaries || !result.precip || !result.alerts || !result.outlooks || !result.discussion || !result.spcDiscussion || !result.story {
t.Fatalf("common modules = %#v, want all shared modules populated", result)
}
if result.dayparts != test.wantDayparts {
t.Fatalf("dayparts = %d, want %d", result.dayparts, test.wantDayparts)
}
if !result.planning {
t.Fatalf("planning = false, want %s planning module populated", test.name)
}
if !result.collected || !result.derived {
t.Fatalf("facts passthrough = collected:%t derived:%t, want both true", result.collected, result.derived)
}
})
}
}
func TestBuildDailyRenderContext(t *testing.T) {
metadata := testDailyMetadata()
snapshot := testDailySnapshot(t)

View File

@@ -1,10 +1,5 @@
package generatedtext
import (
"fmt"
"strings"
)
type Today struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,25 +8,21 @@ type Today struct {
}
func ValidateToday(data []byte) (Today, []byte, error) {
value, err := decodeGeneratedText[Today](data, "today")
if err != nil {
return Today{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Today{}, nil, fmt.Errorf("today generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "today")
if err != nil {
return Today{}, nil, err
}
return value, normalized, nil
return validateDayStyleGeneratedText[Today, *Today](data, "today")
}
func (t *Today) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
}
}
func (t *Today) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
}

View File

@@ -1,10 +1,5 @@
package generatedtext
import (
"fmt"
"strings"
)
type Tomorrow struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,36 +8,21 @@ type Tomorrow struct {
}
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
value, err := decodeGeneratedText[Tomorrow](data, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
value.Summary = strings.TrimSpace(value.Summary)
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
value.Confidence = strings.TrimSpace(value.Confidence)
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
if value.Summary == "" {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
}
if len(value.ForecastDiscussion) == 0 {
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
}
normalized, err := normalizeGeneratedText(value, "tomorrow")
if err != nil {
return Tomorrow{}, nil, err
}
return value, normalized, nil
return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow")
}
func trimNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
func (t *Tomorrow) dayStyleFields() dayStyleFields {
return dayStyleFields{
Summary: t.Summary,
ForecastDiscussion: t.ForecastDiscussion,
PrecipitationTiming: t.PrecipitationTiming,
Confidence: t.Confidence,
}
return out
}
func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
t.Summary = fields.Summary
t.ForecastDiscussion = fields.ForecastDiscussion
t.PrecipitationTiming = fields.PrecipitationTiming
t.Confidence = fields.Confidence
}

View File

@@ -8,7 +8,7 @@ import (
"text/template"
)
//go:embed templates/*.md.tmpl schemas/*.schema.json
//go:embed templates/*.md.tmpl templates/partials/*.md.tmpl schemas/*.schema.json
var assets embed.FS
var templates = map[string]string{
@@ -18,6 +18,12 @@ var templates = map[string]string{
"tomorrow": "templates/tomorrow.md.tmpl",
}
var templatePartials = []string{
"templates/partials/daypart_forecast.md.tmpl",
"templates/partials/precipitation_timing.md.tmpl",
"templates/partials/today_daypart_forecast.md.tmpl",
}
var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.generated_text.schema.json",
@@ -58,6 +64,16 @@ func Render(id string, data any) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err)
}
for _, path := range templatePartials {
partial, err := assets.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read report template partial %q: %w", path, err)
}
tmpl, err = tmpl.Parse(string(partial))
if err != nil {
return nil, fmt.Errorf("parse report template partial %q: %w", path, err)
}
}
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err)

View File

@@ -24,7 +24,7 @@ func TestTomorrowTemplateLookup(t *testing.T) {
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
@@ -36,7 +36,7 @@ func TestDailyTemplateLookup(t *testing.T) {
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} {
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}
@@ -48,7 +48,7 @@ func TestTodayTemplateLookup(t *testing.T) {
if err != nil {
t.Fatalf("Template() error = %v", err)
}
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", "## Daypart Forecast", "## Forecast Discussion"} {
for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", `{{ template "today_daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} {
if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source)
}

View File

@@ -5,19 +5,9 @@
{{ .GeneratedText.Summary }}
## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ template "daypart_forecast" . }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
{{ template "precipitation_timing" . }}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}

View File

@@ -0,0 +1,5 @@
{{ define "daypart_forecast" }}## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ end }}

View File

@@ -0,0 +1,8 @@
{{ define "precipitation_timing" }}{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}{{ end }}

View File

@@ -0,0 +1,5 @@
{{ define "today_daypart_forecast" }}## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ end }}

View File

@@ -10,19 +10,9 @@
{{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }}
{{ end }}
## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ template "today_daypart_forecast" . }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
{{ template "precipitation_timing" . }}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}

View File

@@ -5,19 +5,9 @@
{{ .GeneratedText.Summary }}
## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}
{{ template "daypart_forecast" . }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}
## Precipitation Timing
{{ range . }}{{ $window := . }}
- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
{{ . }}
{{ end }}
{{ end }}{{ end }}
{{ template "precipitation_timing" . }}
## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}

View File

@@ -107,17 +107,12 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error)
}
func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := snapshot.Validate(); err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.ModuleSnapshot, snapshot); err != nil {
return "", err
}
return paths.ModuleSnapshot, nil
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.ModuleSnapshot
}, snapshot)
}
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
@@ -132,72 +127,66 @@ func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Res
}
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err
}
return paths.Preflight, nil
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.Preflight
}, artifact)
}
func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if artifact.SchemaVersion == "" {
artifact.SchemaVersion = DistributorNotificationSchemaVersion
}
if err := fileutil.WriteJSONAtomic(paths.Notification, artifact); err != nil {
return "", err
}
return paths.Notification, nil
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.Notification
}, artifact)
}
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
return "", err
}
return paths.GeneratedTextRaw, nil
return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
return paths.GeneratedTextRaw
}, data)
}
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
return "", err
}
return paths.GeneratedTextResult, nil
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.GeneratedTextResult
}, value)
}
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
return "", err
}
return paths.GeneratedText, nil
return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
return paths.GeneratedText
}, data)
}
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) {
return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return paths.RenderContext
}, value)
}
func (s *FilesystemStore) saveResolvedJSON(resolved report.Resolved, selectPath func(ArtifactPaths) string, value any) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil {
path := selectPath(paths)
if err := fileutil.WriteJSONAtomic(path, value); err != nil {
return "", err
}
return paths.RenderContext, nil
return path, nil
}
func (s *FilesystemStore) saveResolvedBytes(resolved report.Resolved, selectPath func(ArtifactPaths) string, data []byte) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
path := selectPath(paths)
if err := fileutil.WriteFileAtomic(path, data); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {