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 generated-text-template reports and builds curated render contexts for
templates. It also owns the generated-text catalog that connects report templates. It also owns the generated-text catalog that connects report
definitions to validators, render-context builders, schema assets, and template definitions to validators, render-context builders, schema assets, and template
assets. The implemented contracts are Daily render context, Today Report, assets.
Tomorrow Report, and Hourly Report.
## Inputs And Outputs ## Inputs And Outputs
@@ -35,9 +34,9 @@ Outputs:
- generated-text catalog handlers for report definitions that use - generated-text catalog handlers for report definitions that use
`generated_text_template` `generated_text_template`
The Daily generated text JSON accepts the same public fields and validation ## JSON Contracts
rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and
template ID `daily`, with prompt ID `weather.daily_generated_text`. Daily, Today, and Tomorrow use the same day-style generated-text JSON shape:
```json ```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 ```json
{ {
@@ -59,39 +72,35 @@ The hourly generated text JSON accepts:
} }
``` ```
`summary` and `forecast_discussion` are required after trimming whitespace. Hourly `summary` and `forecast_discussion` are required after trimming
`precipitation_timing` and `confidence` are optional and omitted from normalized whitespace. `precipitation_timing` and `confidence` are optional and omitted
JSON when blank. 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 ## Render Contexts
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`:
```json Daily, Today, Tomorrow, and Hourly render contexts all include:
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
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 Daily, Today, and Tomorrow share common civil-day render-context fields such as
{ forecast date labels, valid period, generated-at labels, current conditions,
"summary": "string", hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD, SPC
"forecast_discussion": ["string"], discussion, weather story, daily summary, and ordered daypart summaries.
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` is required after trimming whitespace. `forecast_discussion` must Each civil-day report keeps its report-specific planning module:
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized - Daily exposes `DailyPlanning`.
JSON when blank. - 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 ## 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 The `internal/briefing` module registry attaches prompt export values when it
builds module outputs. Modules without a custom exporter use default builds module outputs. Modules without a custom exporter use default
pass-through behavior, so their prompt value is the same as their rich value. 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 Modules with custom prompt export policy own typed prompt export structs near
builder. Current custom prompt exports are: the module builder. Custom prompt exports are:
- `current_conditions` - `current_conditions`
- `hourly_forecast` - `hourly_forecast`

View File

@@ -12,6 +12,7 @@ Tomorrow, and Hourly.
The package embeds assets from: The package embeds assets from:
- `internal/reporttemplate/templates/*.md.tmpl` - `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/templates/partials/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json` - `internal/reporttemplate/schemas/*.schema.json`
Generated-text prompt source files live under 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 Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown. 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 ## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected 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 template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors. - Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID. - 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 - Template execution errors include the template ID and usually identify the
missing context field. missing context field.
@@ -107,6 +115,7 @@ Inspect:
## Invariants ## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings. - 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. - Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages. - Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather - 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, `weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports. 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/daily.md.tmpl`
- `internal/reporttemplate/templates/today.md.tmpl` - `internal/reporttemplate/templates/today.md.tmpl`
- `internal/reporttemplate/templates/tomorrow.md.tmpl` - `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.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`, Templates are rendered from structured contexts such as `DailyRenderContext`,
`TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`. `TodayRenderContext`, `TomorrowRenderContext`, and `HourlyRenderContext`.
Weather data collection, derivation, module execution, generated text 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 - Do not put weather derivation, source selection, or path construction logic in
templates. templates.
- Missing template keys are errors. A misspelled variable will fail rendering. - 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 - Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`. `{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ 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 Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`. be rendered with `range`.
Tomorrow uses the shared `daypart_forecast` and `precipitation_timing`
partials.
Tomorrow modules include the Hourly module fields plus: Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description | | Variable | Type | Description |
@@ -131,6 +144,8 @@ Daily generated text uses `.GeneratedText.Summary`,
`.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and `.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`. 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 Daily uses template ID `daily`, generated-text schema ID `daily`, and prompt
source `internal/reporttemplate/prompts/daily.generated_text.md`. 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 `.GeneratedText.Confidence`. Forecast discussion is a slice of paragraphs and
should be rendered with `range`. 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 Today uses template ID `today`, generated-text schema ID `today`, and prompt
source `internal/reporttemplate/prompts/today.generated_text.md`. 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 ordered daypart rendering. Each item has `Key` and `Summary`; `Summary` is a
rich `briefing.DerivedDaypartSummaryModule`. 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: Common rich daypart fields:
| Variable | Type | Description | | 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) { func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.PromptID == "" { result, err := r.executeRun(ctx, outputRunRequest{
return nil, fmt.Errorf("prompt id is required") PromptID: req.PromptID,
} DataPackagePath: req.DataPackagePath,
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,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
}, "run scriptorium", "scriptorium run")
if err != nil {
if result == nil {
return nil, err
} }
if execution.result.ExitCode != 0 { return result.runResult(), err
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) { func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" { result, err := r.executeRun(ctx, outputRunRequest{
return nil, fmt.Errorf("prompt id is required") PromptID: req.PromptID,
} DataPackagePath: req.DataPackagePath,
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,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
}, "run scriptorium structured output", "scriptorium structured run")
if err != nil {
if result == nil {
return nil, err
} }
if execution.result.ExitCode != 0 { return result.structuredRunResult(), err
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 { type execution struct {
@@ -203,6 +199,55 @@ type execution struct {
result CommandResult 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) { func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary binary := r.Binary
if binary == "" { if binary == "" {
@@ -257,14 +302,6 @@ func (r Runner) runArgs(req RunRequest) []string {
return args 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 { type limitedBuffer struct {
data []byte data []byte
limit int limit int

View File

@@ -2,6 +2,7 @@ package scriptorium
import ( import (
"context" "context"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -236,56 +237,284 @@ 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 { tests := []struct {
name string name string
req StructuredRunRequest run func(Runner) (*commonResult, error)
}{
{
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
},
},
{
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
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
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.Fatalf("%s error = nil, want nonzero exit error", test.name)
}
if result == nil {
t.Fatalf("%s result = nil, want captured result", test.name)
}
if err.Error() != test.wantErr {
t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr)
}
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 want string
}{ }{
{ {
name: "prompt id", name: "prompt id",
req: StructuredRunRequest{ dataPackagePath: "/tmp/data_package.yaml",
DataPackagePath: "/tmp/hourly.data_package.yaml", outputPath: "/tmp/report.md",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "prompt id is required", want: "prompt id is required",
}, },
{ {
name: "data package path", name: "data package path",
req: StructuredRunRequest{ promptID: "weather.markdown_report",
PromptID: "weather.hourly_generated_text", outputPath: "/tmp/report.md",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "data package path is required", want: "data package path is required",
}, },
{ {
name: "output path", name: "output path",
req: StructuredRunRequest{ promptID: "weather.markdown_report",
PromptID: "weather.hourly_generated_text", dataPackagePath: "/tmp/data_package.yaml",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required", want: "output path is required",
}, },
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
commands := &fakeCommands{} commands := &fakeCommands{}
runner := Runner{Commands: commands} err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath)
result, err := runner.StructuredRun(context.Background(), test.req)
if err == nil { if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error") t.Fatalf("%s error = nil, want validation error", test.name)
} }
if result != nil { if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("StructuredRun() result = %#v, want nil", result) t.Fatalf("%s error = %v, want %q", test.name, err, tc.want)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
} }
if commands.calls != 0 { if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls) t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
} }
}) })
} }
})
}
} }
type fakeCommands struct { type fakeCommands struct {

View File

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

View File

@@ -65,17 +65,13 @@ func TestRunUnknownCommand(t *testing.T) {
} }
func TestRunGenerateStormWritesMarkdownReport(t *testing.T) { func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outPath := fixture.path("storm.md")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "storm.md")
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"generate", "storm", "generate", "storm",
"--config", configPath, "--config", fixture.configPath,
"--start", "2026-05-29T06:00", "--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00", "--end", "2026-05-29T10:00",
"--out", outPath, "--out", outPath,
@@ -84,143 +80,113 @@ func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") 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, "id: storm")
assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report") assertFileContains(t, dataPackagePath, "prompt_id: weather.storm_report")
} }
func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outPath := fixture.path("tomorrow.md")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "tomorrow.md")
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"generate", "tomorrow", "generate", "tomorrow",
"--config", configPath, "--config", fixture.configPath,
"--out", outPath, "--out", outPath,
) )
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Saturday's Weather") 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, "id: tomorrow")
assertFileContains(t, dataPackagePath, "tomorrow_planning:") 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") { if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want tomorrow report", reportPath) t.Fatalf("managed report = %q, want tomorrow report", reportPath)
} }
} }
func TestRunEveningGeneratesTomorrowReport(t *testing.T) { func TestRunEveningGeneratesTomorrowReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"run", "evening", "run", "evening",
"--config", configPath, "--config", fixture.configPath,
) )
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
_ = oneArtifact(t, workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml") _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
reportPath := oneArtifact(t, workspaceRoot, "reports", "tomorrow", "*.md") reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "*.md")
if !strings.Contains(filepath.Base(reportPath), "tomorrow") { if !strings.Contains(filepath.Base(reportPath), "tomorrow") {
t.Fatalf("managed report = %q, want only tomorrow report", reportPath) t.Fatalf("managed report = %q, want only tomorrow report", reportPath)
} }
} }
func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outPath := fixture.path("three-day.md")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "three-day.md")
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"generate", "three-day", "generate", "three-day",
"--config", configPath, "--config", fixture.configPath,
"--out", outPath, "--out", outPath,
) )
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") 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, "id: three_day")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
} }
func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) { func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outPath := fixture.path("weekend.md")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "weekend.md")
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"generate", "weekend", "generate", "weekend",
"--config", configPath, "--config", fixture.configPath,
"--out", outPath, "--out", outPath,
) )
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
assertFileContains(t, outPath, "# Daily Report") 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, "id: weekend")
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
} }
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
_, err := runTestCommand(t, runner, _, err := runTestCommand(t, runner,
"run", "morning", "run", "morning",
"--config", configPath, "--config", fixture.configPath,
) )
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
_ = oneArtifact(t, workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml") _ = oneArtifact(t, fixture.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", "weekend", "2026-05-29", "*.data_package.yaml")
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) noArtifacts(t, fixture.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)
}
} }
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFailingScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"run", "morning", "run", "morning",
"--config", configPath, "--config", fixture.configPath,
) )
if err == nil { if err == nil {
t.Fatal("Run() error = nil, want aggregate failure") 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") { if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) 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, fixture.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", "weekend", "2026-05-29", "*.data_package.yaml")
} }
func TestBatchOutputIncludesNotificationDetails(t *testing.T) { func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
@@ -326,17 +292,13 @@ func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) {
} }
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outputDir := fixture.path("copies")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
output, err := runTestCommand(t, runner, output, err := runTestCommand(t, runner,
"run", "evening", "run", "evening",
"--config", configPath, "--config", fixture.configPath,
"--out-dir", outputDir, "--out-dir", outputDir,
) )
if err != nil { if err != nil {
@@ -375,12 +337,8 @@ func TestRunEveningReportsNotificationSuccess(t *testing.T) {
t.Cleanup(distributorServer.Close) t.Cleanup(distributorServer.Close)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") 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" configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -421,12 +379,8 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
t.Cleanup(distributorServer.Close) t.Cleanup(distributorServer.Close)
tempDir := t.TempDir() tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir) scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace") 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" configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL)
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -460,61 +414,33 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
} }
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) { func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}} runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"run", "morning", "run", "morning",
"--config", configPath, "--config", fixture.configPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
todayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")) _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
if err != nil { _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
t.Fatalf("glob today packages: %v", err) noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
}
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)
}
} }
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir() outPath := fixture.path("daily.md")
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "daily.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "daily", "generate", "daily",
"--config", configPath, "--config", fixture.configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
"--tz", "UTC", "--tz", "UTC",
"--out", outPath, "--out", outPath,
@@ -529,14 +455,8 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
if !strings.Contains(string(report), "# Friday's Weather") { if !strings.Contains(string(report), "# Friday's Weather") {
t.Fatalf("report output missing markdown:\n%s", string(report)) t.Fatalf("report output missing markdown:\n%s", string(report))
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
if err != nil { data, err := os.ReadFile(dataPackagePath)
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
} }
@@ -550,31 +470,19 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
!strings.Contains(string(data), "timezone: UTC") { !strings.Contains(string(data), "timezone: UTC") {
t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) 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")) preflightPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json")
if err != nil { preflight, err := os.ReadFile(preflightPath)
t.Fatalf("glob preflight: %v", err)
}
if len(preflightMatches) != 1 {
t.Fatalf("preflight files = %#v, want one render output", preflightMatches)
}
preflight, err := os.ReadFile(preflightMatches[0])
if err != nil { if err != nil {
t.Fatalf("read preflight: %v", err) t.Fatalf("read preflight: %v", err)
} }
if !strings.Contains(string(preflight), `ok`) { if !strings.Contains(string(preflight), `ok`) {
t.Fatalf("preflight missing fake render output:\n%s", string(preflight)) t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
} }
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md")) _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "*.md")
if err != nil { rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.generated_text.raw.json")
t.Fatalf("glob managed report: %v", err) 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")
if len(reportMatches) != 1 { metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "*.metadata.json")
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")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`) assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`)
assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`) assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`)
@@ -582,19 +490,15 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
} }
func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
tempDir := t.TempDir() outPath := fixture.path("today.md")
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "today.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "today", "generate", "today",
"--config", configPath, "--config", fixture.configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
"--out", outPath, "--out", outPath,
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -614,7 +518,7 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
t.Fatalf("today report output missing %q:\n%s", want, string(report)) 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) dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
@@ -624,17 +528,11 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "today_planning:") { !strings.Contains(string(dataPackage), "today_planning:") {
t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) 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")) noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
if err != nil { rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "*.generated_text.raw.json")
t.Fatalf("glob daily packages: %v", err) 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")
if len(dailyPackages) != 0 { managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "*.md")
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")
assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`) assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`)
assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`)
@@ -642,19 +540,15 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) {
} }
func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeStructuredOutputScriptorium)
tempDir := t.TempDir() outPath := fixture.path("hourly.md")
scriptoriumPath := writeStructuredOutputScriptorium(t, tempDir)
workspaceRoot := filepath.Join(tempDir, "workspace")
configPath := writeTestConfig(t, server, scriptoriumPath, workspaceRoot)
outPath := filepath.Join(tempDir, "hourly.md")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}} runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "hourly", "generate", "hourly",
"--config", configPath, "--config", fixture.configPath,
"--out", outPath, "--out", outPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
@@ -674,7 +568,7 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
t.Fatalf("report output missing %q:\n%s", want, string(report)) 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) dataPackage, err := os.ReadFile(dataPackagePath)
if err != nil { if err != nil {
t.Fatalf("read managed data package: %v", err) t.Fatalf("read managed data package: %v", err)
@@ -684,10 +578,10 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
!strings.Contains(string(dataPackage), "hourly_forecast:") { !strings.Contains(string(dataPackage), "hourly_forecast:") {
t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage)) 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") rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.raw.json")
validatedGeneratedTextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json") validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.generated_text.json")
renderContextPath := oneArtifact(t, workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json") renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "*.render_context.json")
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md") managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "*.md")
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`) assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
assertFileContains(t, renderContextPath, `"Report": {`) assertFileContains(t, renderContextPath, `"Report": {`)
@@ -699,38 +593,24 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
} }
func TestRunInspectTodayArtifacts(t *testing.T) { func TestRunInspectTodayArtifacts(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "today", "generate", "today",
"--config", configPath, "--config", fixture.configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")) dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
if err != nil { runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
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")
stdout.Reset() 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 { if err != nil {
t.Fatalf("Run(inspect reports) error = %v", err) t.Fatalf("Run(inspect reports) error = %v", err)
} }
@@ -741,7 +621,7 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
var sourcesOutput string var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} { for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset() 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 { if err != nil {
t.Fatalf("Run(inspect %s) error = %v", command, err) t.Fatalf("Run(inspect %s) error = %v", command, err)
} }
@@ -758,38 +638,24 @@ func TestRunInspectTodayArtifacts(t *testing.T) {
} }
func TestRunInspectGeneratedArtifacts(t *testing.T) { func TestRunInspectGeneratedArtifacts(t *testing.T) {
server := dailyServer(t) fixture := newCLIFixture(t, writeFakeScriptorium)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{ err := runner.Run(context.Background(), []string{
"generate", "daily", "generate", "daily",
"--config", configPath, "--config", fixture.configPath,
"--date", "2026-05-29", "--date", "2026-05-29",
}, &stdout, &stderr) }, &stdout, &stderr)
if err != nil { if err != nil {
t.Fatalf("Run(generate) error = %v", err) t.Fatalf("Run(generate) error = %v", err)
} }
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")) dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
if err != nil { runID := strings.TrimSuffix(filepath.Base(dataPackagePath), ".data_package.yaml")
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")
stdout.Reset() 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 { if err != nil {
t.Fatalf("Run(inspect reports) error = %v", err) t.Fatalf("Run(inspect reports) error = %v", err)
} }
@@ -800,7 +666,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
var sourcesOutput string var sourcesOutput string
for _, command := range []string{"metadata", "modules", "data-package", "sources"} { for _, command := range []string{"metadata", "modules", "data-package", "sources"} {
stdout.Reset() 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 { if err != nil {
t.Fatalf("Run(inspect %s) error = %v", command, err) t.Fatalf("Run(inspect %s) error = %v", command, err)
} }
@@ -818,11 +684,7 @@ func TestRunInspectGeneratedArtifacts(t *testing.T) {
func TestRunInspectMissingMetadata(t *testing.T) { func TestRunInspectMissingMetadata(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml") configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -838,11 +700,7 @@ func TestRunInspectMissingMetadata(t *testing.T) {
func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml") configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace"))
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
commands := []string{"metadata", "modules", "data-package", "prior", "sources"} commands := []string{"metadata", "modules", "data-package", "prior", "sources"}
@@ -936,10 +794,7 @@ func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) {
func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) { func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
configPath := filepath.Join(t.TempDir(), "config.yml") configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n")
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"}) req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"})
if err != nil { if err != nil {
@@ -1259,16 +1114,56 @@ func dailyServer(t *testing.T) *httptest.Server {
return 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() t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml") 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(body), 0o600); err != nil {
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
return configPath 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 { func oneArtifact(t *testing.T, root string, parts ...string) string {
t.Helper() t.Helper()
matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) 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] 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) { func assertFileContains(t *testing.T, path string, want string) {
t.Helper() t.Helper()
data, err := os.ReadFile(path) 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) { func TestValidateReportModuleAliasesDirectly(t *testing.T) {
retiredDailyKey := retiredDailyReportKeyForTest() retiredDailyKey := retiredDailyReportKeyForTest()
tests := []struct { 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 { func retiredDailyReportKeyForTest() string {
return strings.Join([]string{"daily", "today"}, "_") return strings.Join([]string{"daily", "today"}, "_")
} }

View File

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

View File

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

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 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) { func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) {
location, err := timeutil.LoadLocation(metadata.Timezone) location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil { 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) { 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 { if err != nil {
return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err) return DailyRenderContext{}, 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")
} }
modules, err := dailyTemplateModules(snapshot, derived) modules, err := dailyTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return DailyRenderContext{}, err return DailyRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return DailyRenderContext{ return DailyRenderContext{
Report: DailyReportContext{ Report: reportContext.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,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, 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) { 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 { if err != nil {
return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err) return TodayRenderContext{}, 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")
} }
modules, err := todayTemplateModules(snapshot, derived) modules, err := todayTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return TodayRenderContext{}, err return TodayRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
return TodayRenderContext{ return TodayRenderContext{
Report: TodayReportContext{ Report: reportContext.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,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, 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) { 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 { if err != nil {
return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err) return TomorrowRenderContext{}, 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")
} }
modules, err := tomorrowTemplateModules(snapshot, derived) modules, err := tomorrowTemplateModules(snapshot, derived)
if err != nil { if err != nil {
return TomorrowRenderContext{}, err return TomorrowRenderContext{}, err
} }
forecastDate := metadata.ValidPeriod.Start.In(location)
forecastDayName := forecastDate.Format("Monday")
return TomorrowRenderContext{ return TomorrowRenderContext{
Report: TomorrowReportContext{ Report: reportContext.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,
},
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected, Collected: collected,
@@ -301,49 +282,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap
}, nil }, 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) { func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(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()
if err != nil { if err != nil {
return DailyTemplateModules{}, err return DailyTemplateModules{}, err
} }
@@ -356,66 +361,25 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return DailyTemplateModules{}, err return DailyTemplateModules{}, err
} }
return DailyTemplateModules{ return DailyTemplateModules{
Metadata: metadata, Metadata: common.Metadata,
CurrentConditions: current, CurrentConditions: common.CurrentConditions,
HourlyForecast: hourly, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: daily, DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries), Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: common.PrecipTiming,
AlertDigest: alerts, AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: story, WeatherStory: common.WeatherStory,
OutdoorWindows: outdoor, OutdoorWindows: outdoor,
DailyPlanning: planning, DailyPlanning: planning,
}, nil }, nil
} }
func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(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()
if err != nil { if err != nil {
return TodayTemplateModules{}, err return TodayTemplateModules{}, err
} }
@@ -424,18 +388,18 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return TodayTemplateModules{}, err return TodayTemplateModules{}, err
} }
return TodayTemplateModules{ return TodayTemplateModules{
Metadata: metadata, Metadata: common.Metadata,
CurrentConditions: current, CurrentConditions: common.CurrentConditions,
HourlyForecast: hourly, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: daily, DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries), Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: common.PrecipTiming,
AlertDigest: alerts, AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: common.AreaForecastDiscussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: common.SPCConvectiveDiscussion,
WeatherStory: story, WeatherStory: common.WeatherStory,
TodayPlanning: planning, TodayPlanning: planning,
}, nil }, nil
} }
@@ -492,48 +456,7 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
} }
func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) common, lookup, err := dayStyleTemplateModuleSnapshot(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()
if err != nil { if err != nil {
return TomorrowTemplateModules{}, err return TomorrowTemplateModules{}, err
} }
@@ -542,20 +465,81 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
return TomorrowTemplateModules{}, err return TomorrowTemplateModules{}, err
} }
return TomorrowTemplateModules{ 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, Metadata: metadata,
CurrentConditions: current, CurrentConditions: current,
HourlyForecast: hourly, HourlyForecast: hourly,
DerivedDailySummary: daily, DerivedDailySummary: daily,
DerivedDaypartSummaries: dayparts, DerivedDaypartSummaries: dayparts,
Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries),
PrecipTiming: precip, PrecipTiming: precip,
AlertDigest: alerts, AlertDigest: alerts,
SPCConvectiveOutlooks: outlooks, SPCConvectiveOutlooks: outlooks,
AreaForecastDiscussion: discussion, AreaForecastDiscussion: discussion,
SPCConvectiveDiscussion: spcDiscussion, SPCConvectiveDiscussion: spcDiscussion,
WeatherStory: story, WeatherStory: story,
TomorrowPlanning: planning, }, lookup, nil
}, nil
} }
type moduleSnapshotLookup struct { 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) { func TestBuildDailyRenderContext(t *testing.T) {
metadata := testDailyMetadata() metadata := testDailyMetadata()
snapshot := testDailySnapshot(t) snapshot := testDailySnapshot(t)

View File

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

View File

@@ -1,10 +1,5 @@
package generatedtext package generatedtext
import (
"fmt"
"strings"
)
type Tomorrow struct { type Tomorrow struct {
Summary string `json:"summary"` Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"` ForecastDiscussion []string `json:"forecast_discussion"`
@@ -13,36 +8,21 @@ type Tomorrow struct {
} }
func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) {
value, err := decodeGeneratedText[Tomorrow](data, "tomorrow") return validateDayStyleGeneratedText[Tomorrow, *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
} }
func trimNonEmpty(values []string) []string { func (t *Tomorrow) dayStyleFields() dayStyleFields {
out := make([]string, 0, len(values)) return dayStyleFields{
for _, value := range values { Summary: t.Summary,
trimmed := strings.TrimSpace(value) ForecastDiscussion: t.ForecastDiscussion,
if trimmed != "" { PrecipitationTiming: t.PrecipitationTiming,
out = append(out, trimmed) 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" "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 assets embed.FS
var templates = map[string]string{ var templates = map[string]string{
@@ -18,6 +18,12 @@ var templates = map[string]string{
"tomorrow": "templates/tomorrow.md.tmpl", "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{ var schemas = map[string]string{
"daily": "schemas/daily.generated_text.schema.json", "daily": "schemas/daily.generated_text.schema.json",
"hourly": "schemas/hourly.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 { if err != nil {
return nil, fmt.Errorf("parse report template %q: %w", id, err) 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 var out bytes.Buffer
if err := tmpl.Execute(&out, data); err != nil { if err := tmpl.Execute(&out, data); err != nil {
return nil, fmt.Errorf("render report template %q: %w", id, err) 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 { if err != nil {
t.Fatalf("Template() error = %v", err) 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) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -36,7 +36,7 @@ func TestDailyTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) 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) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }
@@ -48,7 +48,7 @@ func TestTodayTemplateLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Template() error = %v", err) 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) { if !strings.Contains(source, want) {
t.Fatalf("template missing %q:\n%s", want, source) t.Fatalf("template missing %q:\n%s", want, source)
} }

View File

@@ -5,19 +5,9 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Daypart Forecast {{ template "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 }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} {{ template "precipitation_timing" . }}
## 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 }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ 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 }} {{ 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 }} {{ end }}
## Daypart Forecast {{ template "today_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 }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} {{ template "precipitation_timing" . }}
## 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 }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ range .GeneratedText.ForecastDiscussion }}
{{ . }} {{ . }}

View File

@@ -5,19 +5,9 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Daypart Forecast {{ template "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 }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} {{ template "precipitation_timing" . }}
## 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 }}
## Forecast Discussion ## Forecast Discussion
{{ range .GeneratedText.ForecastDiscussion }} {{ 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) { 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 { if err := snapshot.Validate(); err != nil {
return "", err return "", err
} }
if err := fileutil.WriteJSONAtomic(paths.ModuleSnapshot, snapshot); err != nil { return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return "", err return paths.ModuleSnapshot
} }, snapshot)
return paths.ModuleSnapshot, nil
} }
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { 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) { func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.Preflight
return "", err }, artifact)
}
if err := fileutil.WriteJSONAtomic(paths.Preflight, artifact); err != nil {
return "", err
}
return paths.Preflight, nil
} }
func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) { 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 == "" { if artifact.SchemaVersion == "" {
artifact.SchemaVersion = DistributorNotificationSchemaVersion artifact.SchemaVersion = DistributorNotificationSchemaVersion
} }
if err := fileutil.WriteJSONAtomic(paths.Notification, artifact); err != nil { return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
return "", err return paths.Notification
} }, artifact)
return paths.Notification, nil
} }
func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) { func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedTextRaw
return "", err }, data)
}
if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil {
return "", err
}
return paths.GeneratedTextRaw, nil
} }
func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) { func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedTextResult
return "", err }, value)
}
if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil {
return "", err
}
return paths.GeneratedTextResult, nil
} }
func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) { func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) {
paths, err := s.Paths(resolved) return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string {
if err != nil { return paths.GeneratedText
return "", err }, data)
}
if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil {
return "", err
}
return paths.GeneratedText, nil
} }
func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) { 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) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return "", err 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 "", 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) { func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {