Compare commits
9 Commits
0884eb0ce5
...
d9ab1e47ec
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ab1e47ec | |||
| 4f755704d9 | |||
| a13f04fce5 | |||
| 1f5b347cd2 | |||
| 3639636813 | |||
| ca27d81163 | |||
| d74ba0f259 | |||
| 121f28fd29 | |||
| e3bcecc5c1 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}, "_")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value.Summary = strings.TrimSpace(value.Summary)
|
func (d *Daily) dayStyleFields() dayStyleFields {
|
||||||
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
return dayStyleFields{
|
||||||
value.Confidence = strings.TrimSpace(value.Confidence)
|
Summary: d.Summary,
|
||||||
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
ForecastDiscussion: d.ForecastDiscussion,
|
||||||
if value.Summary == "" {
|
PrecipitationTiming: d.PrecipitationTiming,
|
||||||
return Daily{}, nil, fmt.Errorf("daily generated text summary is required")
|
Confidence: d.Confidence,
|
||||||
}
|
}
|
||||||
if len(value.ForecastDiscussion) == 0 {
|
|
||||||
return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized, err := normalizeGeneratedText(value, "daily")
|
func (d *Daily) setDayStyleFields(fields dayStyleFields) {
|
||||||
if err != nil {
|
d.Summary = fields.Summary
|
||||||
return Daily{}, nil, err
|
d.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
}
|
d.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
return value, normalized, nil
|
d.Confidence = fields.Confidence
|
||||||
}
|
}
|
||||||
|
|||||||
63
internal/generatedtext/day_style.go
Normal file
63
internal/generatedtext/day_style.go
Normal 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
|
||||||
|
}
|
||||||
131
internal/generatedtext/day_style_test.go
Normal file
131
internal/generatedtext/day_style_test.go
Normal 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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value.Summary = strings.TrimSpace(value.Summary)
|
func (t *Today) dayStyleFields() dayStyleFields {
|
||||||
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
return dayStyleFields{
|
||||||
value.Confidence = strings.TrimSpace(value.Confidence)
|
Summary: t.Summary,
|
||||||
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
ForecastDiscussion: t.ForecastDiscussion,
|
||||||
if value.Summary == "" {
|
PrecipitationTiming: t.PrecipitationTiming,
|
||||||
return Today{}, nil, fmt.Errorf("today generated text summary is required")
|
Confidence: t.Confidence,
|
||||||
}
|
}
|
||||||
if len(value.ForecastDiscussion) == 0 {
|
|
||||||
return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized, err := normalizeGeneratedText(value, "today")
|
func (t *Today) setDayStyleFields(fields dayStyleFields) {
|
||||||
if err != nil {
|
t.Summary = fields.Summary
|
||||||
return Today{}, nil, err
|
t.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
}
|
t.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
return value, normalized, nil
|
t.Confidence = fields.Confidence
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
func (t *Tomorrow) dayStyleFields() dayStyleFields {
|
||||||
value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming)
|
return dayStyleFields{
|
||||||
value.Confidence = strings.TrimSpace(value.Confidence)
|
Summary: t.Summary,
|
||||||
value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion)
|
ForecastDiscussion: t.ForecastDiscussion,
|
||||||
if value.Summary == "" {
|
PrecipitationTiming: t.PrecipitationTiming,
|
||||||
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required")
|
Confidence: t.Confidence,
|
||||||
}
|
}
|
||||||
if len(value.ForecastDiscussion) == 0 {
|
|
||||||
return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized, err := normalizeGeneratedText(value, "tomorrow")
|
func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) {
|
||||||
if err != nil {
|
t.Summary = fields.Summary
|
||||||
return Tomorrow{}, nil, err
|
t.ForecastDiscussion = fields.ForecastDiscussion
|
||||||
}
|
t.PrecipitationTiming = fields.PrecipitationTiming
|
||||||
return value, normalized, nil
|
t.Confidence = fields.Confidence
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
{{ . }}
|
{{ . }}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user