Refactor the template variable framework
This commit is contained in:
@@ -10,38 +10,52 @@ Templates are Go `text/template` files. The current implemented template is:
|
||||
|
||||
- `internal/reporttemplate/templates/hourly.md.tmpl`
|
||||
|
||||
The hourly template is rendered from a curated `HourlyRenderContext`, not from
|
||||
the raw prompt data package. Weather data selection, derivation, module
|
||||
execution, LLM generation, and artifact paths are handled before template
|
||||
rendering.
|
||||
The hourly template is rendered from a structured `HourlyRenderContext`. Weather
|
||||
data collection, derivation, module execution, generated text validation, and
|
||||
artifact paths are handled before template rendering.
|
||||
|
||||
## Editing Rules
|
||||
|
||||
- Use Go `text/template` syntax.
|
||||
- Keep templates focused on Markdown layout, headings, ordering, and simple
|
||||
conditional display.
|
||||
- Do not put weather derivation, source selection, or path construction logic
|
||||
in templates.
|
||||
- Do not put weather derivation, source selection, or path construction logic in
|
||||
templates.
|
||||
- Missing template keys are errors. A misspelled variable will fail rendering.
|
||||
- No custom template functions are currently registered.
|
||||
- Optional strings can be guarded with `{{ with .Field }}...{{ end }}`.
|
||||
- Optional module stanzas are pointers and should be guarded with
|
||||
`{{ with .Modules.WeatherStory }}...{{ end }}`.
|
||||
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
|
||||
|
||||
## Hourly Template Variables
|
||||
## Hourly Context
|
||||
|
||||
These are the complete variables currently available to
|
||||
`internal/reporttemplate/templates/hourly.md.tmpl`.
|
||||
|
||||
### Report Metadata
|
||||
The hourly template receives five top-level values:
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.ReportTitle` | string | Display title for the report. Currently `Hourly Report`. |
|
||||
| `.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. Falls back to source location if configured location metadata is unavailable. |
|
||||
| `.ValidPeriod` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
||||
| `.GeneratedAt` | string | Friendly local generation time label. |
|
||||
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
|
||||
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
|
||||
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
|
||||
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
|
||||
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
|
||||
|
||||
### GeneratedText
|
||||
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
|
||||
available when a template needs lower-level facts, but templates should still
|
||||
avoid nontrivial derivation.
|
||||
|
||||
## Report
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
|
||||
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
|
||||
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
|
||||
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
|
||||
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
|
||||
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
|
||||
| `.Report.Timezone` | string | Effective report timezone. |
|
||||
|
||||
## GeneratedText
|
||||
|
||||
These fields are written by Scriptorium as structured JSON, validated by
|
||||
weatherreporter, and then inserted into the render context.
|
||||
@@ -67,150 +81,163 @@ Example:
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Current Conditions
|
||||
## Modules
|
||||
|
||||
`.Modules` exposes typed outputs from the same module pipeline used for the
|
||||
prompt data package. Module fields are pointers because missing-data policy may
|
||||
omit a stanza.
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.CurrentConditions` | string | Deterministic one-line current conditions summary. May include condition text, temperature, apparent temperature, humidity, and wind. |
|
||||
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
|
||||
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
|
||||
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
|
||||
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
|
||||
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
|
||||
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
|
||||
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
|
||||
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
|
||||
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
|
||||
|
||||
Example value:
|
||||
### Current Conditions
|
||||
|
||||
```text
|
||||
Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph.
|
||||
Common fields:
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
|
||||
| `.Modules.CurrentConditions.TemperatureF` | *float64 | Current temperature. |
|
||||
| `.Modules.CurrentConditions.ApparentTemperatureF` | *float64 | Apparent temperature. |
|
||||
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *float64 | Relative humidity. |
|
||||
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
|
||||
| `.Modules.CurrentConditions.WindSpeedMph` | *float64 | Wind speed. |
|
||||
|
||||
Example:
|
||||
|
||||
```gotemplate
|
||||
{{ with .Modules.CurrentConditions }}
|
||||
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
|
||||
{{ else }}
|
||||
No current conditions available.
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Hourly Forecast
|
||||
|
||||
Common period fields:
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.HourlyForecast` | []HourlyForecastRow | Ordered rows for the hourly report valid period. |
|
||||
| `.HourlyForecast[].Time` | string | Friendly local period start time, or the source period name if no start label is available. |
|
||||
| `.HourlyForecast[].Summary` | string | Hourly text description, falling back to the period name. |
|
||||
| `.HourlyForecast[].Temperature` | string | Rounded temperature label such as `75 F`, or empty. |
|
||||
| `.HourlyForecast[].Precipitation` | string | Rounded precipitation probability label such as `70% precipitation`, or empty. |
|
||||
| `.HourlyForecast[].Wind` | string | Wind label such as `wind S 10 mph, gusts 18 mph`, or empty. |
|
||||
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
|
||||
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
|
||||
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
|
||||
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
|
||||
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
|
||||
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
|
||||
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
|
||||
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
|
||||
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
|
||||
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
|
||||
|
||||
Example:
|
||||
|
||||
```gotemplate
|
||||
## Hourly Forecast
|
||||
{{ range .HourlyForecast }}
|
||||
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
|
||||
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||
- {{ .PeriodBegins }}: {{ .TextDescription }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }}
|
||||
{{ else }}
|
||||
- No hourly forecast rows available.
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}
|
||||
```
|
||||
|
||||
### Precipitation Timing
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.PrecipitationTiming` | string | Deterministic precipitation timing summary. May include peak probability, precipitation windows, and thunder mention. |
|
||||
|
||||
Example value:
|
||||
|
||||
```text
|
||||
Peak precipitation probability 70% at 10 AM; 2026-05-29 at 10:00 AM to 2026-05-29 at 12:00 PM (max 70% at 10 AM); Thunder is mentioned in the forecast.
|
||||
```
|
||||
|
||||
### Alerts
|
||||
Common fields:
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.Alerts` | []string | Alert labels for active alerts overlapping the report period. Empty when there are no relevant alert overlaps. |
|
||||
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
|
||||
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
|
||||
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
|
||||
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
|
||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
|
||||
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
|
||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
|
||||
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
|
||||
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
|
||||
|
||||
Example:
|
||||
|
||||
```gotemplate
|
||||
## Alerts
|
||||
{{ range .Alerts }}
|
||||
- {{ . }}
|
||||
{{ else }}
|
||||
- No active alert overlaps for this report period.
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### SPC Outlooks
|
||||
### Alert Digest
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.SPCOutlooks` | []string | SPC convective outlook labels overlapping the report period. Empty when there are no overlapping outlooks. |
|
||||
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
|
||||
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
|
||||
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
|
||||
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
|
||||
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
|
||||
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
|
||||
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
|
||||
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
|
||||
|
||||
Example value:
|
||||
|
||||
```text
|
||||
Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM
|
||||
```
|
||||
|
||||
### Forecast Discussion
|
||||
### SPC Outlooks And Discussion
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.ForecastDiscussion.KeyMessages` | []string | AFD key messages included for the hourly report. |
|
||||
| `.ForecastDiscussion.ShortTerm` | string | AFD short-term discussion text, or empty if unavailable. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
|
||||
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
|
||||
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
|
||||
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
|
||||
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
|
||||
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
|
||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
|
||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
|
||||
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
|
||||
|
||||
Example:
|
||||
|
||||
```gotemplate
|
||||
## Forecast Discussion
|
||||
{{ range .ForecastDiscussion.KeyMessages }}
|
||||
- {{ . }}
|
||||
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
|
||||
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### SPC Discussion
|
||||
### Area Forecast Discussion
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.SPCDiscussions` | []string | SPC discussion labels retained for overlapping qualifying outlook periods. Empty when there is no relevant SPC discussion. |
|
||||
|
||||
Example:
|
||||
|
||||
```gotemplate
|
||||
## SPC Discussion
|
||||
{{ range .SPCDiscussions }}
|
||||
- {{ . }}
|
||||
{{ else }}
|
||||
- No overlapping SPC discussion.
|
||||
{{ end }}
|
||||
```
|
||||
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
|
||||
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
|
||||
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
|
||||
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
|
||||
|
||||
### Weather Story
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `.WeatherStory` | string | Weather story title and description, or `No weather story available.` |
|
||||
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
|
||||
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
|
||||
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
|
||||
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
|
||||
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
|
||||
| `.Modules.WeatherStory.Title` | string | Story title. |
|
||||
| `.Modules.WeatherStory.Description` | string | Story description. |
|
||||
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
|
||||
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
|
||||
| `.Modules.WeatherStory.Order` | int | Source order. |
|
||||
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
|
||||
|
||||
## Common Patterns
|
||||
## Collected And Derived Facts
|
||||
|
||||
Use `with` for optional strings:
|
||||
The template also receives the full `facts.CollectedFacts` and
|
||||
`facts.DerivedFacts` structs:
|
||||
|
||||
```gotemplate
|
||||
{{ with .GeneratedText.Confidence }}
|
||||
## Confidence
|
||||
- `.Collected` contains normalized source data and provenance from upstream
|
||||
Weather API fetches.
|
||||
- `.Derived` contains shared slices and calculations used across modules, such
|
||||
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
|
||||
filtering inputs.
|
||||
|
||||
{{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Use `range` with `else` for optional lists:
|
||||
|
||||
```gotemplate
|
||||
{{ range .SPCOutlooks }}
|
||||
- {{ . }}
|
||||
{{ else }}
|
||||
- No overlapping SPC outlooks.
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Keep punctuation outside optional blocks when possible:
|
||||
|
||||
```gotemplate
|
||||
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}
|
||||
```
|
||||
These values are intentionally lower-level than `.Modules`. Use them when a
|
||||
template needs a specific field that is not exposed by a module, but keep
|
||||
calculation-heavy changes in Go.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -229,4 +256,5 @@ git diff --check
|
||||
```
|
||||
|
||||
Template render tests currently exercise the hourly template through
|
||||
`internal/generatedtext/render_context_test.go`.
|
||||
`internal/generatedtext/render_context_test.go` and
|
||||
`internal/reporttemplate/reporttemplate_test.go`.
|
||||
|
||||
@@ -559,6 +559,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
paths: paths,
|
||||
moduleSnapshot: moduleSnapshot,
|
||||
moduleSnapshotPath: moduleSnapshotPath,
|
||||
reportFacts: reportFacts,
|
||||
dataPackage: dataPackage,
|
||||
dataPackagePath: dataPackagePath,
|
||||
briefingMetadata: briefingMetadata,
|
||||
@@ -638,6 +639,7 @@ type generatedReportRequest struct {
|
||||
paths state.ArtifactPaths
|
||||
moduleSnapshot module.Snapshot
|
||||
moduleSnapshotPath string
|
||||
reportFacts ReportFacts
|
||||
dataPackage promptinput.Package
|
||||
dataPackagePath string
|
||||
briefingMetadata briefing.Metadata
|
||||
@@ -690,7 +692,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, hourlyText)
|
||||
renderContext, err := buildRenderContext(req.Resolved.Definition, req.briefingMetadata, req.moduleSnapshot, req.reportFacts, hourlyText)
|
||||
if err != nil {
|
||||
return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err)
|
||||
}
|
||||
@@ -1117,10 +1119,10 @@ func validateGeneratedText(definition report.Definition, data []byte) (generated
|
||||
}
|
||||
}
|
||||
|
||||
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, hourly generatedtext.Hourly) (any, error) {
|
||||
func buildRenderContext(definition report.Definition, metadata briefing.Metadata, snapshot module.Snapshot, reportFacts ReportFacts, hourly generatedtext.Hourly) (any, error) {
|
||||
switch definition.TemplateID {
|
||||
case "hourly":
|
||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly)
|
||||
return generatedtext.BuildHourlyRenderContext(metadata, snapshot, hourly, reportFacts.Collected, reportFacts.Derived)
|
||||
default:
|
||||
return nil, fmt.Errorf("report template %q is not supported for report %q", definition.TemplateID, definition.ID)
|
||||
}
|
||||
|
||||
@@ -450,8 +450,20 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read render context: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(renderContext), `"ReportTitle": "Hourly Report"`) || !strings.Contains(string(renderContext), `"LocationName": "Brentwood, MO"`) {
|
||||
t.Fatalf("render context missing deterministic fields:\n%s", string(renderContext))
|
||||
for _, want := range []string{
|
||||
`"Report": {`,
|
||||
`"Title": "Hourly Report"`,
|
||||
`"LocationName": "Brentwood, MO"`,
|
||||
`"GeneratedText": {`,
|
||||
`"Modules": {`,
|
||||
`"CurrentConditions": {`,
|
||||
`"HourlyForecast": {`,
|
||||
`"Collected": {`,
|
||||
`"Derived": {`,
|
||||
} {
|
||||
if !strings.Contains(string(renderContext), want) {
|
||||
t.Fatalf("render context missing %q:\n%s", want, string(renderContext))
|
||||
}
|
||||
}
|
||||
reportData, err := os.ReadFile(result.ReportPath)
|
||||
if err != nil {
|
||||
@@ -751,54 +763,6 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHourlyReportPreservesValidatedTextOnRenderContextFailure(t *testing.T) {
|
||||
server := hourlyBundleServer(t)
|
||||
cfg := hourlyGeneratedTextConfigWithModules(t, server, []string{
|
||||
"metadata",
|
||||
"hourly_forecast",
|
||||
"precip_timing",
|
||||
"alert_digest",
|
||||
"area_forecast_discussion",
|
||||
"weather_story",
|
||||
"spc_convective_outlooks",
|
||||
})
|
||||
resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg)
|
||||
renderer := &recordingRenderer{
|
||||
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
|
||||
structuredRunBody: validHourlyGeneratedTextJSON(),
|
||||
}
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Notifier: notifier,
|
||||
})
|
||||
assertGeneratedReportError(t, err, resolved, "build render context")
|
||||
assertNoGeneratedFailureSideEffects(t, notifier, outputPath)
|
||||
|
||||
paths := hourlyArtifactPaths(t, store, resolved)
|
||||
assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText)
|
||||
assertPathsMissing(t, paths.RenderContext, paths.RenderedReport)
|
||||
generatedText, readErr := os.ReadFile(paths.GeneratedText)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read validated generated text: %v", readErr)
|
||||
}
|
||||
if string(generatedText) != validHourlyGeneratedTextJSON() {
|
||||
t.Fatalf("validated generated text = %s, want normalized JSON", generatedText)
|
||||
}
|
||||
metadataData, readErr := os.ReadFile(paths.Metadata)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read metadata: %v", readErr)
|
||||
}
|
||||
if !strings.Contains(string(metadataData), paths.GeneratedText) {
|
||||
t.Fatalf("metadata missing validated generated text link:\n%s", string(metadataData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeRenderContext(t *testing.T) {
|
||||
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
|
||||
resolved.Definition.TemplateID = "missing-template"
|
||||
|
||||
@@ -738,7 +738,11 @@ func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) {
|
||||
managedReportPath := oneArtifact(t, workspaceRoot, "reports", "hourly", "*.md")
|
||||
assertFileContains(t, rawGeneratedTextPath, `"summary": " Storm chances increase through late morning. "`)
|
||||
assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`)
|
||||
assertFileContains(t, renderContextPath, `"ReportTitle": "Hourly Report"`)
|
||||
assertFileContains(t, renderContextPath, `"Report": {`)
|
||||
assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`)
|
||||
assertFileContains(t, renderContextPath, `"Modules": {`)
|
||||
assertFileContains(t, renderContextPath, `"Collected": {`)
|
||||
assertFileContains(t, renderContextPath, `"Derived": {`)
|
||||
assertFileContains(t, managedReportPath, "# Hourly Report")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,44 +2,45 @@ package generatedtext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type HourlyRenderContext struct {
|
||||
ReportTitle string
|
||||
LocationName string
|
||||
ValidPeriod string
|
||||
GeneratedAt string
|
||||
GeneratedText Hourly
|
||||
CurrentConditions string
|
||||
HourlyForecast []HourlyForecastRow
|
||||
PrecipitationTiming string
|
||||
Alerts []string
|
||||
SPCOutlooks []string
|
||||
ForecastDiscussion ForecastDiscussion
|
||||
SPCDiscussions []string
|
||||
WeatherStory string
|
||||
Report HourlyReportContext
|
||||
GeneratedText Hourly
|
||||
Modules HourlyTemplateModules
|
||||
Collected facts.CollectedFacts
|
||||
Derived facts.DerivedFacts
|
||||
}
|
||||
|
||||
type HourlyForecastRow struct {
|
||||
Time string
|
||||
Summary string
|
||||
Temperature string
|
||||
Precipitation string
|
||||
Wind string
|
||||
type HourlyReportContext struct {
|
||||
Title string
|
||||
LocationName string
|
||||
GeneratedAt time.Time
|
||||
GeneratedAtLabel string
|
||||
ValidPeriod timeutil.Period
|
||||
ValidPeriodLabel string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type ForecastDiscussion struct {
|
||||
KeyMessages []string
|
||||
ShortTerm string
|
||||
type HourlyTemplateModules struct {
|
||||
Metadata *briefing.MetadataModule
|
||||
CurrentConditions *briefing.CurrentConditionsModule
|
||||
HourlyForecast *briefing.HourlyForecastModule
|
||||
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) (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)
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
|
||||
@@ -50,80 +51,87 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh
|
||||
if !metadata.ValidPeriod.IsValid() {
|
||||
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required")
|
||||
}
|
||||
|
||||
current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||
modules, err := hourlyTemplateModules(snapshot)
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||
if err != nil {
|
||||
return HourlyRenderContext{}, err
|
||||
}
|
||||
|
||||
return HourlyRenderContext{
|
||||
ReportTitle: "Hourly Report",
|
||||
LocationName: locationName(metadata),
|
||||
ValidPeriod: periodLabel(metadata.ValidPeriod, location),
|
||||
GeneratedAt: timeLabel(metadata.GeneratedAt, location),
|
||||
GeneratedText: generated,
|
||||
CurrentConditions: currentConditionsLabel(current),
|
||||
HourlyForecast: hourlyForecastRows(hourly),
|
||||
PrecipitationTiming: precipitationTimingLabel(precip),
|
||||
Alerts: alertLabels(alerts),
|
||||
SPCOutlooks: outlookLabels(outlooks),
|
||||
ForecastDiscussion: ForecastDiscussion{
|
||||
KeyMessages: append([]string(nil), discussion.KeyMessages...),
|
||||
ShortTerm: discussion.ShortTerm,
|
||||
Report: HourlyReportContext{
|
||||
Title: "Hourly Report",
|
||||
LocationName: locationName(metadata),
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
GeneratedAtLabel: timeLabel(metadata.GeneratedAt, location),
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location),
|
||||
Timezone: metadata.Timezone,
|
||||
},
|
||||
SPCDiscussions: spcDiscussionLabels(spcDiscussion),
|
||||
WeatherStory: weatherStoryLabel(story),
|
||||
GeneratedText: generated,
|
||||
Modules: modules,
|
||||
Collected: collected,
|
||||
Derived: derived,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
value, ok, err := module.StanzaValue[T](snapshot, name)
|
||||
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
||||
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context requires stanza %q", name)
|
||||
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
return value, nil
|
||||
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
||||
if err != nil {
|
||||
return HourlyTemplateModules{}, err
|
||||
}
|
||||
return HourlyTemplateModules{
|
||||
Metadata: metadata,
|
||||
CurrentConditions: current,
|
||||
HourlyForecast: hourly,
|
||||
PrecipTiming: precip,
|
||||
AlertDigest: alerts,
|
||||
SPCConvectiveOutlooks: outlooks,
|
||||
AreaForecastDiscussion: discussion,
|
||||
SPCConvectiveDiscussion: spcDiscussion,
|
||||
WeatherStory: story,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) {
|
||||
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
||||
output, ok := snapshot.LookupStanza(name)
|
||||
if !ok || output.Value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
value, _, err := module.StanzaValue[T](snapshot, name)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("build hourly render context: %w", err)
|
||||
return nil, fmt.Errorf("build hourly render context: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func locationName(metadata briefing.Metadata) string {
|
||||
@@ -151,210 +159,3 @@ func periodLabel(period timeutil.Period, location *time.Location) string {
|
||||
func timeLabel(value time.Time, location *time.Location) string {
|
||||
return value.In(location).Format("2006-01-02 at 3:04 PM")
|
||||
}
|
||||
|
||||
func currentConditionsLabel(current briefing.CurrentConditionsModule) string {
|
||||
parts := []string{}
|
||||
if current.ConditionText != "" {
|
||||
parts = append(parts, current.ConditionText)
|
||||
}
|
||||
if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" {
|
||||
parts = append(parts, temperature)
|
||||
}
|
||||
if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" {
|
||||
parts = append(parts, "feels like "+apparent)
|
||||
}
|
||||
if current.RelativeHumidityPercent != nil {
|
||||
parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent)))
|
||||
}
|
||||
if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" {
|
||||
parts = append(parts, wind)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "No current conditions available."
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
|
||||
func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow {
|
||||
rows := make([]HourlyForecastRow, 0, len(hourly.Periods))
|
||||
for _, period := range hourly.Periods {
|
||||
summary := period.TextDescription
|
||||
if summary == "" {
|
||||
summary = period.Name
|
||||
}
|
||||
rows = append(rows, HourlyForecastRow{
|
||||
Time: firstNonEmpty(period.PeriodBegins, period.Name),
|
||||
Summary: summary,
|
||||
Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC),
|
||||
Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent),
|
||||
Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func precipitationTimingLabel(timing briefing.PrecipTimingModule) string {
|
||||
parts := []string{}
|
||||
if timing.MaxPopPercent != nil {
|
||||
max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent)
|
||||
if timing.MaxPopTime != "" {
|
||||
max += " at " + timing.MaxPopTime
|
||||
}
|
||||
parts = append(parts, max)
|
||||
}
|
||||
for _, window := range timing.PrecipitationWindows {
|
||||
label := window.PeriodBegins
|
||||
if window.PeriodEnds != "" {
|
||||
label += " to " + window.PeriodEnds
|
||||
}
|
||||
if window.MaxPopPercent != nil {
|
||||
label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent)
|
||||
if window.MaxPopTime != "" {
|
||||
label += " at " + window.MaxPopTime
|
||||
}
|
||||
label += ")"
|
||||
}
|
||||
parts = append(parts, label)
|
||||
}
|
||||
if timing.ThunderMentioned {
|
||||
parts = append(parts, "Thunder is mentioned in the forecast.")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "No precipitation timing signal above threshold."
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func alertLabels(alerts briefing.AlertDigestModule) []string {
|
||||
if alerts.Missing {
|
||||
return []string{"Alert source missing."}
|
||||
}
|
||||
out := make([]string, 0, len(alerts.Relevant))
|
||||
for _, alert := range alerts.Relevant {
|
||||
main := firstNonEmpty(alert.Event, alert.Headline)
|
||||
if main == "" {
|
||||
continue
|
||||
}
|
||||
if alert.Headline != "" && alert.Headline != main {
|
||||
main += ": " + alert.Headline
|
||||
}
|
||||
if alert.Severity != "" {
|
||||
main += " (" + alert.Severity + ")"
|
||||
}
|
||||
out = append(out, main)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string {
|
||||
out := make([]string, 0, len(outlooks.Outlooks))
|
||||
for _, outlook := range outlooks.Outlooks {
|
||||
label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if outlook.PeriodBegins != "" {
|
||||
label += " from " + outlook.PeriodBegins
|
||||
if outlook.PeriodEnds != "" {
|
||||
label += " to " + outlook.PeriodEnds
|
||||
}
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string {
|
||||
out := make([]string, 0, len(discussion.Discussions))
|
||||
for _, record := range discussion.Discussions {
|
||||
label := firstNonEmpty(record.Headline, record.Summary, record.Discussion)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if record.Summary != "" && record.Summary != label {
|
||||
label += ": " + record.Summary
|
||||
}
|
||||
out = append(out, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func weatherStoryLabel(story briefing.WeatherStoryModule) string {
|
||||
if !story.Available {
|
||||
return "No weather story available."
|
||||
}
|
||||
parts := []string{}
|
||||
if story.Title != "" {
|
||||
parts = append(parts, story.Title)
|
||||
}
|
||||
if story.Description != "" {
|
||||
parts = append(parts, story.Description)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Weather story is available."
|
||||
}
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
func temperatureLabel(fahrenheit *float64, celsius *float64) string {
|
||||
if fahrenheit != nil {
|
||||
return fmt.Sprintf("%d F", rounded(*fahrenheit))
|
||||
}
|
||||
if celsius != nil {
|
||||
return fmt.Sprintf("%d C", rounded(*celsius))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func precipitationLabel(percent *float64) string {
|
||||
if percent == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d%% precipitation", rounded(*percent))
|
||||
}
|
||||
|
||||
func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string {
|
||||
speed := ""
|
||||
if mph != nil {
|
||||
speed = fmt.Sprintf("%d mph", rounded(*mph))
|
||||
} else if kmh != nil {
|
||||
speed = fmt.Sprintf("%d km/h", rounded(*kmh))
|
||||
}
|
||||
if direction != "" && speed != "" {
|
||||
speed = direction + " " + speed
|
||||
} else if direction != "" {
|
||||
speed = direction + " wind"
|
||||
}
|
||||
gust := ""
|
||||
if gustMph != nil {
|
||||
gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph))
|
||||
} else if gustKmh != nil {
|
||||
gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh))
|
||||
}
|
||||
switch {
|
||||
case speed != "" && gust != "":
|
||||
return "wind " + speed + ", " + gust
|
||||
case speed != "":
|
||||
return "wind " + speed
|
||||
case gust != "":
|
||||
return "wind " + gust
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func rounded(value float64) int {
|
||||
if value < 0 {
|
||||
return int(value - 0.5)
|
||||
}
|
||||
return int(value + 0.5)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||
@@ -21,45 +23,53 @@ func TestBuildHourlyRenderContext(t *testing.T) {
|
||||
Impacts: "Brief downpours may slow travel.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
}
|
||||
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
|
||||
collected := testCollected()
|
||||
derived := testDerived()
|
||||
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.ReportTitle != "Hourly Report" {
|
||||
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
|
||||
if ctx.Report.Title != "Hourly Report" {
|
||||
t.Fatalf("Report.Title = %q, want Hourly Report", ctx.Report.Title)
|
||||
}
|
||||
if ctx.LocationName != "Brentwood, MO" {
|
||||
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
|
||||
if ctx.Report.LocationName != "Brentwood, MO" {
|
||||
t.Fatalf("Report.LocationName = %q, want Brentwood, MO", ctx.Report.LocationName)
|
||||
}
|
||||
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
|
||||
if ctx.Report.ValidPeriodLabel != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||
t.Fatalf("Report.ValidPeriodLabel = %q, want friendly period", ctx.Report.ValidPeriodLabel)
|
||||
}
|
||||
if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
|
||||
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
|
||||
if ctx.Modules.CurrentConditions == nil || ctx.Modules.CurrentConditions.ConditionText != "Partly cloudy" || ctx.Modules.CurrentConditions.TemperatureF == nil || *ctx.Modules.CurrentConditions.TemperatureF != 74 {
|
||||
t.Fatalf("Modules.CurrentConditions = %#v, want structured current conditions", ctx.Modules.CurrentConditions)
|
||||
}
|
||||
if len(ctx.HourlyForecast) != 2 {
|
||||
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
|
||||
if ctx.Modules.HourlyForecast == nil || len(ctx.Modules.HourlyForecast.Periods) != 2 {
|
||||
t.Fatalf("Modules.HourlyForecast = %#v, want 2 periods", ctx.Modules.HourlyForecast)
|
||||
}
|
||||
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
|
||||
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
|
||||
if period := ctx.Modules.HourlyForecast.Periods[1]; period.PeriodBegins != "2026-05-29 at 10:00 AM" || period.TextDescription != "Showers" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("Modules.HourlyForecast.Periods[1] = %#v, want 10 AM showers row", period)
|
||||
}
|
||||
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
|
||||
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
|
||||
if ctx.Modules.PrecipTiming == nil || ctx.Modules.PrecipTiming.MaxPopPercent == nil || *ctx.Modules.PrecipTiming.MaxPopPercent != 70 {
|
||||
t.Fatalf("Modules.PrecipTiming = %#v, want max pop", ctx.Modules.PrecipTiming)
|
||||
}
|
||||
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
|
||||
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
|
||||
if ctx.Modules.AlertDigest == nil || len(ctx.Modules.AlertDigest.Relevant) != 1 || ctx.Modules.AlertDigest.Relevant[0].Event != "Flood Watch" {
|
||||
t.Fatalf("Modules.AlertDigest = %#v, want alert", ctx.Modules.AlertDigest)
|
||||
}
|
||||
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
|
||||
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
|
||||
if ctx.Modules.SPCConvectiveOutlooks == nil || len(ctx.Modules.SPCConvectiveOutlooks.Outlooks) != 1 || ctx.Modules.SPCConvectiveOutlooks.Outlooks[0].LabelText != "Slight Risk" {
|
||||
t.Fatalf("Modules.SPCConvectiveOutlooks = %#v, want outlook", ctx.Modules.SPCConvectiveOutlooks)
|
||||
}
|
||||
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
|
||||
if ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.AreaForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||
t.Fatalf("Modules.AreaForecastDiscussion = %#v, want discussion text", ctx.Modules.AreaForecastDiscussion)
|
||||
}
|
||||
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
|
||||
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
|
||||
if ctx.Modules.SPCConvectiveDiscussion == nil || len(ctx.Modules.SPCConvectiveDiscussion.Discussions) != 1 || ctx.Modules.SPCConvectiveDiscussion.Discussions[0].Headline != "Mesoscale discussion" {
|
||||
t.Fatalf("Modules.SPCConvectiveDiscussion = %#v, want discussion", ctx.Modules.SPCConvectiveDiscussion)
|
||||
}
|
||||
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
|
||||
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
|
||||
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
|
||||
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory)
|
||||
}
|
||||
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
|
||||
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
|
||||
}
|
||||
if !ctx.Derived.PrecipTiming.ThunderMentioned {
|
||||
t.Fatalf("Derived.PrecipTiming.ThunderMentioned = false, want true")
|
||||
}
|
||||
|
||||
rendered, err := reporttemplate.Render("hourly", ctx)
|
||||
@@ -80,23 +90,23 @@ func TestBuildHourlyRenderContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
|
||||
func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||
Summary: "Storm chances increase.",
|
||||
Timing: "Late morning.",
|
||||
Impacts: "Brief downpours.",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
|
||||
}, testCollected(), facts.DerivedFacts{})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
|
||||
if ctx.Modules.HourlyForecast != nil {
|
||||
t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +130,14 @@ func testMetadata() briefing.Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
func testCollected() facts.CollectedFacts {
|
||||
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
func testDerived() facts.DerivedFacts {
|
||||
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
|
||||
}
|
||||
|
||||
func testSnapshot(t *testing.T) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestTemplateLookup(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Template() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"# {{ .ReportTitle }}", "## Summary", "## Hourly Forecast", "## Weather Story"} {
|
||||
for _, want := range []string{"# {{ .Report.Title }}", "## Summary", "## Hourly Forecast", "## Weather Story"} {
|
||||
if !strings.Contains(source, want) {
|
||||
t.Fatalf("template missing %q:\n%s", want, source)
|
||||
}
|
||||
@@ -54,29 +54,58 @@ func TestSchemaLookup(t *testing.T) {
|
||||
|
||||
func TestRenderHourly(t *testing.T) {
|
||||
rendered, err := Render("hourly", testRenderContext{
|
||||
ReportTitle: "Hourly Report",
|
||||
LocationName: "Brentwood",
|
||||
ValidPeriod: "May 29, 8:30 AM to 2:30 PM",
|
||||
GeneratedAt: "May 29, 8:30 AM",
|
||||
CurrentConditions: "74 F, light south wind.",
|
||||
PrecipitationTiming: "Showers are most likely late morning.",
|
||||
WeatherStory: "Morning storms remain the main story.",
|
||||
Report: testReportContext{
|
||||
Title: "Hourly Report",
|
||||
LocationName: "Brentwood",
|
||||
ValidPeriodLabel: "May 29, 8:30 AM to 2:30 PM",
|
||||
GeneratedAtLabel: "May 29, 8:30 AM",
|
||||
},
|
||||
GeneratedText: testGeneratedText{
|
||||
Summary: "Storm chances increase through late morning.",
|
||||
Timing: "The main window is 10 AM to noon.",
|
||||
Impacts: "Brief downpours may slow travel.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
},
|
||||
HourlyForecast: []testHourlyRow{
|
||||
{Time: "9 AM", Summary: "Cloudy", Temperature: "74 F", Precipitation: "30% showers", Wind: "S 8 mph"},
|
||||
{Time: "10 AM", Summary: "Showers", Temperature: "75 F", Precipitation: "70% showers", Wind: "S 10 mph"},
|
||||
},
|
||||
Alerts: []string{"Flood Watch until 2:30 PM"},
|
||||
SPCOutlooks: []string{"Slight Risk through afternoon"},
|
||||
SPCDiscussions: []string{"Strong storms may develop late morning."},
|
||||
ForecastDiscussion: testForecastDiscussion{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
Modules: testModules{
|
||||
CurrentConditions: &testCurrentConditions{
|
||||
ConditionText: "Partly cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ApparentTemperatureF: floatPtr(76),
|
||||
RelativeHumidityPercent: floatPtr(71),
|
||||
WindDirection: "S",
|
||||
WindSpeedMph: floatPtr(8),
|
||||
},
|
||||
HourlyForecast: &testHourlyForecast{
|
||||
Periods: []testHourlyPeriod{
|
||||
{PeriodBegins: "9 AM", TextDescription: "Cloudy", TemperatureF: floatPtr(74), ProbabilityOfPrecipitationPercent: floatPtr(30), WindDirection: "S", WindSpeedMph: floatPtr(8)},
|
||||
{PeriodBegins: "10 AM", TextDescription: "Showers", TemperatureF: floatPtr(75), ProbabilityOfPrecipitationPercent: floatPtr(70), WindDirection: "S", WindSpeedMph: floatPtr(10)},
|
||||
},
|
||||
},
|
||||
PrecipTiming: &testPrecipTiming{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
PrecipitationWindows: []testPrecipWindow{
|
||||
{PeriodBegins: "10 AM", PeriodEnds: "12 PM", MaxPopPercent: intPtr(70), MaxPopTime: "10 AM"},
|
||||
},
|
||||
},
|
||||
AlertDigest: &testAlertDigest{
|
||||
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}},
|
||||
},
|
||||
SPCConvectiveOutlooks: &testSPCOutlooks{
|
||||
Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}},
|
||||
},
|
||||
SPCConvectiveDiscussion: &testSPCDiscussion{
|
||||
Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}},
|
||||
},
|
||||
AreaForecastDiscussion: &testForecastDiscussion{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
},
|
||||
WeatherStory: &testWeatherStory{
|
||||
Available: true,
|
||||
Title: "Morning storms",
|
||||
Description: "Morning storms remain the main story.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -88,8 +117,8 @@ func TestRenderHourly(t *testing.T) {
|
||||
"Valid: May 29, 8:30 AM to 2:30 PM",
|
||||
"Storm chances increase through late morning.",
|
||||
"## Confidence",
|
||||
"- 10 AM: Showers; 75 F; 70% showers; S 10 mph",
|
||||
"- Flood Watch until 2:30 PM",
|
||||
"- 10 AM: Showers; 75 F; 70% precipitation; wind S 10 mph",
|
||||
"- Flood Watch: Flood Watch until 2:30 PM (Moderate)",
|
||||
"Short-term discussion favors increasing rain coverage.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
@@ -126,7 +155,7 @@ func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenderFailsForMissingContextFields(t *testing.T) {
|
||||
_, err := Render("hourly", map[string]any{"ReportTitle": "Hourly Report"})
|
||||
_, err := Render("hourly", map[string]any{"Report": map[string]any{"Title": "Hourly Report"}})
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want missing field error")
|
||||
}
|
||||
@@ -136,19 +165,16 @@ func TestRenderFailsForMissingContextFields(t *testing.T) {
|
||||
}
|
||||
|
||||
type testRenderContext struct {
|
||||
ReportTitle string
|
||||
LocationName string
|
||||
ValidPeriod string
|
||||
GeneratedAt string
|
||||
CurrentConditions string
|
||||
PrecipitationTiming string
|
||||
WeatherStory string
|
||||
GeneratedText testGeneratedText
|
||||
HourlyForecast []testHourlyRow
|
||||
Alerts []string
|
||||
SPCOutlooks []string
|
||||
SPCDiscussions []string
|
||||
ForecastDiscussion testForecastDiscussion
|
||||
Report testReportContext
|
||||
GeneratedText testGeneratedText
|
||||
Modules testModules
|
||||
}
|
||||
|
||||
type testReportContext struct {
|
||||
Title string
|
||||
LocationName string
|
||||
ValidPeriodLabel string
|
||||
GeneratedAtLabel string
|
||||
}
|
||||
|
||||
type testGeneratedText struct {
|
||||
@@ -158,12 +184,76 @@ type testGeneratedText struct {
|
||||
Confidence string
|
||||
}
|
||||
|
||||
type testHourlyRow struct {
|
||||
Time string
|
||||
Summary string
|
||||
Temperature string
|
||||
Precipitation string
|
||||
Wind string
|
||||
type testModules struct {
|
||||
CurrentConditions *testCurrentConditions
|
||||
HourlyForecast *testHourlyForecast
|
||||
PrecipTiming *testPrecipTiming
|
||||
AlertDigest *testAlertDigest
|
||||
SPCConvectiveOutlooks *testSPCOutlooks
|
||||
AreaForecastDiscussion *testForecastDiscussion
|
||||
SPCConvectiveDiscussion *testSPCDiscussion
|
||||
WeatherStory *testWeatherStory
|
||||
}
|
||||
|
||||
type testCurrentConditions struct {
|
||||
ConditionText string
|
||||
TemperatureF *float64
|
||||
ApparentTemperatureF *float64
|
||||
RelativeHumidityPercent *float64
|
||||
WindSpeedMph *float64
|
||||
WindDirection string
|
||||
}
|
||||
|
||||
type testHourlyForecast struct {
|
||||
Periods []testHourlyPeriod
|
||||
}
|
||||
|
||||
type testHourlyPeriod struct {
|
||||
PeriodBegins string
|
||||
Name string
|
||||
TextDescription string
|
||||
TemperatureF *float64
|
||||
WindSpeedMph *float64
|
||||
WindGustMph *float64
|
||||
WindDirection string
|
||||
ProbabilityOfPrecipitationPercent *float64
|
||||
}
|
||||
|
||||
type testPrecipTiming struct {
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
PrecipitationWindows []testPrecipWindow
|
||||
ThunderMentioned bool
|
||||
}
|
||||
|
||||
type testPrecipWindow struct {
|
||||
PeriodBegins string
|
||||
PeriodEnds string
|
||||
MaxPopPercent *int
|
||||
MaxPopTime string
|
||||
}
|
||||
|
||||
type testAlertDigest struct {
|
||||
Missing bool
|
||||
Relevant []testAlert
|
||||
}
|
||||
|
||||
type testAlert struct {
|
||||
Event string
|
||||
Headline string
|
||||
Severity string
|
||||
}
|
||||
|
||||
type testSPCOutlooks struct {
|
||||
Outlooks []testSPCOutlook
|
||||
}
|
||||
|
||||
type testSPCOutlook struct {
|
||||
Label string
|
||||
LabelText string
|
||||
OutlookType string
|
||||
PeriodBegins string
|
||||
PeriodEnds string
|
||||
}
|
||||
|
||||
type testForecastDiscussion struct {
|
||||
@@ -171,6 +261,30 @@ type testForecastDiscussion struct {
|
||||
ShortTerm string
|
||||
}
|
||||
|
||||
type testSPCDiscussion struct {
|
||||
Discussions []testSPCDiscussionRecord
|
||||
}
|
||||
|
||||
type testSPCDiscussionRecord struct {
|
||||
Headline string
|
||||
Summary string
|
||||
Discussion string
|
||||
}
|
||||
|
||||
type testWeatherStory struct {
|
||||
Available bool
|
||||
Title string
|
||||
Description string
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func assertOrderedText(t *testing.T, text string, ordered []string) {
|
||||
t.Helper()
|
||||
previousIndex := -1
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# {{ .ReportTitle }}
|
||||
# {{ .Report.Title }}
|
||||
|
||||
{{ .LocationName }}
|
||||
Valid: {{ .ValidPeriod }}
|
||||
Generated: {{ .GeneratedAt }}
|
||||
{{ .Report.LocationName }}
|
||||
Valid: {{ .Report.ValidPeriodLabel }}
|
||||
Generated: {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -23,49 +23,78 @@ Generated: {{ .GeneratedAt }}
|
||||
{{ end }}
|
||||
|
||||
## Current Conditions
|
||||
|
||||
{{ .CurrentConditions }}
|
||||
{{ with .Modules.CurrentConditions }}
|
||||
{{ with .ConditionText }}{{ . }}{{ end }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ApparentTemperatureF }}; feels like {{ . }} F{{ end }}{{ with .RelativeHumidityPercent }}; humidity {{ . }}%{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}.
|
||||
{{ else }}
|
||||
No current conditions available.
|
||||
{{ end }}
|
||||
|
||||
## Hourly Forecast
|
||||
{{ range .HourlyForecast }}
|
||||
- {{ .Time }}: {{ .Summary }}{{ with .Temperature }}; {{ . }}{{ end }}{{ with .Precipitation }}; {{ . }}{{ end }}{{ with .Wind }}; {{ . }}{{ end }}
|
||||
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
|
||||
- {{ if .PeriodBegins }}{{ .PeriodBegins }}{{ else }}{{ .Name }}{{ end }}: {{ if .TextDescription }}{{ .TextDescription }}{{ else }}{{ .Name }}{{ end }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .ProbabilityOfPrecipitationPercent }}; {{ . }}% precipitation{{ end }}{{ if .WindDirection }}; wind {{ .WindDirection }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}{{ else }}{{ with .WindSpeedMph }}; wind {{ . }} mph{{ end }}{{ end }}{{ with .WindGustMph }}, gusts {{ . }} mph{{ end }}
|
||||
{{ else }}
|
||||
- No hourly forecast rows available.
|
||||
{{ end }}{{ else }}
|
||||
- No hourly forecast rows available.
|
||||
{{ end }}
|
||||
|
||||
## Precipitation Timing
|
||||
|
||||
{{ .PrecipitationTiming }}
|
||||
{{ with .Modules.PrecipTiming }}
|
||||
{{ with .MaxPopPercent }}Peak precipitation probability: {{ . }}%{{ with $.Modules.PrecipTiming.MaxPopTime }} at {{ . }}{{ end }}.
|
||||
{{ end }}{{ range .PrecipitationWindows }}{{ $window := . }}
|
||||
- Window: {{ .PeriodBegins }}{{ with .PeriodEnds }} to {{ . }}{{ end }}{{ with .MaxPopPercent }}; max {{ . }}%{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}
|
||||
{{ else }}
|
||||
No precipitation windows above threshold.
|
||||
{{ end }}{{ if .ThunderMentioned }}
|
||||
Thunder is mentioned in the forecast.
|
||||
{{ end }}{{ else }}
|
||||
No precipitation timing signal above threshold.
|
||||
{{ end }}
|
||||
|
||||
## Alerts
|
||||
{{ range .Alerts }}
|
||||
- {{ . }}
|
||||
{{ with .Modules.AlertDigest }}{{ if .Missing }}
|
||||
- Alert source missing.
|
||||
{{ else }}{{ range .Relevant }}
|
||||
- {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}{{ with .Headline }}: {{ . }}{{ end }}{{ with .Severity }} ({{ . }}){{ end }}
|
||||
{{ else }}
|
||||
- No active alert overlaps for this report period.
|
||||
{{ end }}{{ end }}{{ else }}
|
||||
- No active alert overlaps for this report period.
|
||||
{{ end }}
|
||||
|
||||
## SPC Outlooks
|
||||
{{ range .SPCOutlooks }}
|
||||
- {{ . }}
|
||||
{{ with .Modules.SPCConvectiveOutlooks }}{{ range .Outlooks }}
|
||||
- {{ if .LabelText }}{{ .LabelText }}{{ else }}{{ if .Label }}{{ .Label }}{{ else }}{{ .OutlookType }}{{ end }}{{ end }}{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}
|
||||
{{ else }}
|
||||
- No overlapping SPC outlooks.
|
||||
{{ end }}{{ else }}
|
||||
- No overlapping SPC outlooks.
|
||||
{{ end }}
|
||||
|
||||
## Forecast Discussion
|
||||
{{ range .ForecastDiscussion.KeyMessages }}
|
||||
{{ with .Modules.AreaForecastDiscussion }}{{ range .KeyMessages }}
|
||||
- {{ . }}
|
||||
{{ end }}{{ with .ForecastDiscussion.ShortTerm }}
|
||||
{{ end }}{{ with .ShortTerm }}
|
||||
|
||||
{{ . }}
|
||||
{{ end }}{{ else }}
|
||||
No area forecast discussion available.
|
||||
{{ end }}
|
||||
|
||||
## SPC Discussion
|
||||
{{ range .SPCDiscussions }}
|
||||
- {{ . }}
|
||||
{{ with .Modules.SPCConvectiveDiscussion }}{{ range .Discussions }}
|
||||
- {{ if .Headline }}{{ .Headline }}{{ else }}{{ if .Summary }}{{ .Summary }}{{ else }}{{ .Discussion }}{{ end }}{{ end }}{{ with .Summary }}: {{ . }}{{ end }}
|
||||
{{ else }}
|
||||
- No overlapping SPC discussion.
|
||||
{{ end }}{{ else }}
|
||||
- No overlapping SPC discussion.
|
||||
{{ end }}
|
||||
|
||||
## Weather Story
|
||||
|
||||
{{ .WeatherStory }}
|
||||
{{ with .Modules.WeatherStory }}{{ if .Available }}
|
||||
{{ with .Title }}{{ . }}{{ end }}{{ with .Description }} - {{ . }}{{ end }}
|
||||
{{ else }}
|
||||
No weather story available.
|
||||
{{ end }}{{ else }}
|
||||
No weather story available.
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user