Validate render identity and daypart fallbacks

This commit is contained in:
2026-08-13 02:11:25 +00:00
parent 360c665a3e
commit 2bd921f247
12 changed files with 253 additions and 184 deletions

View File

@@ -40,18 +40,24 @@ receive the same enforcement in this package.
## Render contexts ## Render contexts
The catalog's report-specific builders receive briefing metadata, a rich module The catalog's report-specific builders receive the prepared report identity, a
snapshot, collected facts, derived facts, and the matching validated generated rich module snapshot, derived facts needed to order dayparts, and the matching
text. They decode the module stanzas needed by the template and build typed validated generated text. They require the identity's report ID to match the
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates selected builder. When the optional metadata stanza is present, every shared
metadata and periods, preserves rich module values, and uses ordered slices for identity field must agree with that prepared authority before context
template iteration rather than maps. construction continues. Builders then decode the module stanzas needed by the
template and build typed Daily, Today, Tomorrow, or Hourly contexts. Contexts
expose only display-ready report values, generated prose, and module values;
they do not expose complete collected or derived fact bundles. Ordered slices
remain the template iteration surface rather than maps.
Optional source stanzas become nil or fallback context fields. Missing required Optional source stanzas become nil or fallback context fields. Today also
stanzas, type-decoding failures, invalid metadata, or a generated-text type computes whether its ordered dayparts contain a displayable condition so the
that does not match the chosen handler fail before template execution. Prompt template can render either rows or its explicit no-details fallback. Missing
packages, raw Promptkit output handling, and template asset lookup remain required stanzas, type-decoding failures, conflicting identity values, invalid
outside this package. metadata, or a generated-text type that does not match the chosen handler fail
before template execution. Prompt packages, raw Promptkit output handling, and
template asset lookup remain outside this package.
## Verification and invariants ## Verification and invariants

View File

@@ -28,8 +28,9 @@ failures actionable with template or partial context.
Top-level templates decide which shared partials they invoke. The current Top-level templates decide which shared partials they invoke. The current
partials cover daypart forecast variants, alert digest, and precipitation partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data timing. Template code receives curated typed contexts rather than raw data
packages, and it must not reimplement weather selection or generated-text packages or complete fact bundles, and it must not reimplement weather
validation. selection or generated-text validation. Context construction rejects
report-identity disagreements before template execution.
## Boundaries and verification ## Boundaries and verification
@@ -38,8 +39,8 @@ text, construct contexts, resolve report definitions, write state, execute
Promptkit, or upload reports. It produces Markdown bytes for application Promptkit, or upload reports. It produces Markdown bytes for application
orchestration to persist. orchestration to persist.
Focused tests cover template lookup, rendering, partial Focused tests cover template lookup, rendering, partial behavior, daypart
behavior, missing keys, and malformed context: fallbacks, missing keys, and malformed context:
```sh ```sh
go test ./internal/reporttemplate go test ./internal/reporttemplate

View File

@@ -51,7 +51,9 @@ from rendering.
schema, validator, render context, and template together. The validation and schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md). catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over - Use `.Modules.Dayparts` for ordered daypart output. Do not range over
`.Modules.DerivedDaypartSummaries`, which is a map. `.Modules.DerivedDaypartSummaries`, which is a map. The Today partial uses
`.Modules.HasDaypartDetails` to ensure its heading has either rows or the
explicit no-details fallback.
Minimal optional-value pattern: Minimal optional-value pattern:
@@ -92,7 +94,7 @@ to render the section:
## Render Context ## Render Context
Every rendered template receives one typed context with these five top-level Every rendered template receives one typed context with these three top-level
fields: fields:
| Field | Purpose | | Field | Purpose |
@@ -100,12 +102,6 @@ fields:
| `.Report` | Display labels and canonical report timing metadata. | | `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. | | `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. | | `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| `.Collected` | Normalized upstream facts for advanced use. |
| `.Derived` | Shared calculated facts for advanced use. |
`.Collected` and `.Derived` are available for an exceptional display need, but
they are lower-level contracts. Keep reusable weather derivation in Go and use
the module surface for normal template work.
### Report Metadata ### Report Metadata
@@ -144,8 +140,9 @@ Module pointers can be nil when their source or policy permits omission.
| Module field | Available in | | Module field | Available in |
| --- | --- | | --- | --- |
| `.Modules.Metadata`, `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts | | `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow | | `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
| `.Modules.HasDaypartDetails` | Today |
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily | | `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
| `.Modules.TodayPlanning` | Today | | `.Modules.TodayPlanning` | Today |
| `.Modules.TomorrowPlanning` | Tomorrow | | `.Modules.TomorrowPlanning` | Tomorrow |

View File

@@ -7,6 +7,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext" "gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
@@ -18,7 +19,7 @@ import (
// executions for one resolved report. // executions for one resolved report.
type preparedReport struct { type preparedReport struct {
resolved report.Resolved resolved report.Resolved
reportFacts ReportFacts derived facts.DerivedFacts
moduleSnapshot module.Snapshot moduleSnapshot module.Snapshot
identity briefing.PreparedIdentity identity briefing.PreparedIdentity
sourceWarnings []weatherdata.SourceWarning sourceWarnings []weatherdata.SourceWarning
@@ -73,9 +74,9 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err} return preparedReport{}, &preparationError{operation: "lookup generated text catalog", err: err}
} }
clonedFacts, err := clonePreparedValue(reportFacts) clonedDerived, err := clonePreparedValue(reportFacts.Derived)
if err != nil { if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared report facts", err: err} return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
} }
clonedSnapshot, err := clonePreparedValue(moduleSnapshot) clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
if err != nil { if err != nil {
@@ -87,7 +88,7 @@ func prepareReport(req prepareReportRequest) (preparedReport, error) {
} }
prepared := preparedReport{ prepared := preparedReport{
resolved: cloneResolved(req.Resolved), resolved: cloneResolved(req.Resolved),
reportFacts: clonedFacts, derived: clonedDerived,
moduleSnapshot: clonedSnapshot, moduleSnapshot: clonedSnapshot,
identity: clonedIdentity, identity: clonedIdentity,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...), sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
@@ -120,20 +121,20 @@ func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...) return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
} }
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, ReportFacts, error) { func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
identity, err := clonePreparedValue(p.identity) identity, err := clonePreparedValue(p.identity)
if err != nil { if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
} }
snapshot, err := clonePreparedValue(p.moduleSnapshot) snapshot, err := clonePreparedValue(p.moduleSnapshot)
if err != nil { if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
} }
reportFacts, err := clonePreparedValue(p.reportFacts) derived, err := clonePreparedValue(p.derived)
if err != nil { if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, ReportFacts{}, err return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
} }
return identity, snapshot, reportFacts, nil return identity, snapshot, derived, nil
} }
func clonePreparedValue[T any](value T) (T, error) { func clonePreparedValue[T any](value T) (T, error) {

View File

@@ -37,14 +37,15 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
originalDataPackage := append([]byte(nil), prepared.dataPackage...) originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity originalIdentity := prepared.identity
originalDerived := prepared.derived
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...) originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
identity, snapshot, reportFacts, err := prepared.renderInputs() identity, snapshot, derived, err := prepared.renderInputs()
if err != nil { if err != nil {
t.Fatalf("renderInputs() error = %v", err) t.Fatalf("renderInputs() error = %v", err)
} }
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"}) identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
snapshot.Outputs = nil snapshot.Outputs = nil
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation" derived.PrecipTiming.ThunderMentioned = false
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation" bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"}) bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
if len(bundle.Sources) > 0 { if len(bundle.Sources) > 0 {
@@ -54,13 +55,10 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
bundle.Sources[0].Query["mutated"] = "true" bundle.Sources[0].Query["mutated"] = "true"
} }
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) { if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.derived, originalDerived) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
t.Fatalf("prepared values changed after caller mutation: %#v", prepared) t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
} }
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" { if len(prepared.moduleSnapshot.Outputs) == 0 {
t.Fatal("prepared report facts retain caller-owned weather data")
}
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "consumer mutation" || len(prepared.moduleSnapshot.Outputs) == 0 {
t.Fatal("prepared report values retain consumer mutation") t.Fatal("prepared report values retain consumer mutation")
} }
} }

View File

@@ -115,11 +115,11 @@ func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (p
if err != nil { if err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err} return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
} }
identity, snapshot, reportFacts, err := req.Prepared.renderInputs() identity, snapshot, derived, err := req.Prepared.renderInputs()
if err != nil { if err != nil {
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err} return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
} }
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, reportFacts.Collected, reportFacts.Derived, generatedText) renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
if err != nil { if err != nil {
return outcome, nil, &profileExecutionError{operation: "build render context", err: err} return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
} }

View File

@@ -25,7 +25,7 @@ const (
type validator func([]byte) (any, []byte, error) type validator func([]byte) (any, []byte, error)
type renderContextBuilder func(report.ID, string, briefing.PreparedIdentity, module.Snapshot, facts.CollectedFacts, facts.DerivedFacts, any) (any, error) type renderContextBuilder func(report.ID, string, briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, any) (any, error)
type catalogEntry struct { type catalogEntry struct {
reportID report.ID reportID report.ID
@@ -140,11 +140,11 @@ func (h Handler) Validate(data []byte) (any, []byte, error) {
return h.validate(data) return h.validate(data)
} }
func (h Handler) BuildRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func (h Handler) BuildRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, derived facts.DerivedFacts, generated any) (any, error) {
if h.renderContextBuilder == nil { if h.renderContextBuilder == nil {
return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID) return nil, fmt.Errorf("render-context builder is not registered for template %q on report %q", h.templateID, h.reportID)
} }
return h.renderContextBuilder(h.reportID, h.templateID, identity, snapshot, collected, derived, generated) return h.renderContextBuilder(h.reportID, h.templateID, identity, snapshot, derived, generated)
} }
func (h Handler) Render(data any) ([]byte, error) { func (h Handler) Render(data any) ([]byte, error) {
@@ -171,34 +171,34 @@ func validateTomorrow(data []byte) (any, []byte, error) {
return ValidateTomorrow(data) return ValidateTomorrow(data)
} }
func buildHourlyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func buildHourlyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, _ facts.DerivedFacts, generated any) (any, error) {
hourly, ok := generated.(Hourly) hourly, ok := generated.(Hourly)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID) return nil, fmt.Errorf("report template %q requires hourly generated text for report %q", templateID, reportID)
} }
return BuildHourlyRenderContext(identity, snapshot, hourly, collected, derived) return BuildHourlyRenderContext(identity, snapshot, hourly)
} }
func buildDailyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func buildDailyContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, derived facts.DerivedFacts, generated any) (any, error) {
daily, ok := generated.(Daily) daily, ok := generated.(Daily)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID) return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID)
} }
return BuildDailyRenderContext(identity, snapshot, daily, collected, derived) return BuildDailyRenderContext(identity, snapshot, daily, derived)
} }
func buildTodayContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func buildTodayContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, derived facts.DerivedFacts, generated any) (any, error) {
today, ok := generated.(Today) today, ok := generated.(Today)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID) return nil, fmt.Errorf("report template %q requires today generated text for report %q", templateID, reportID)
} }
return BuildTodayRenderContext(identity, snapshot, today, collected, derived) return BuildTodayRenderContext(identity, snapshot, today, derived)
} }
func buildTomorrowContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { func buildTomorrowContext(reportID report.ID, templateID string, identity briefing.PreparedIdentity, snapshot module.Snapshot, derived facts.DerivedFacts, generated any) (any, error) {
tomorrow, ok := generated.(Tomorrow) tomorrow, ok := generated.(Tomorrow)
if !ok { if !ok {
return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID) return nil, fmt.Errorf("report template %q requires tomorrow generated text for report %q", templateID, reportID)
} }
return BuildTomorrowRenderContext(identity, snapshot, tomorrow, collected, derived) return BuildTomorrowRenderContext(identity, snapshot, tomorrow, derived)
} }

View File

@@ -275,7 +275,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LookupDefinition(hourly) error = %v", err) t.Fatalf("LookupDefinition(hourly) error = %v", err)
} }
_, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testCollected(), testDerived(), Tomorrow{ _, err = hourlyHandler.BuildRenderContext(testMetadata(), testSnapshot(t), testDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.", Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."}, ForecastDiscussion: []string{"A front will keep showers in the forecast."},
}) })
@@ -290,7 +290,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LookupDefinition(tomorrow) error = %v", err) t.Fatalf("LookupDefinition(tomorrow) error = %v", err)
} }
_, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Hourly{ _, err = tomorrowHandler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testTomorrowDerived(), Hourly{
Summary: "Storm chances increase.", Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep showers in the forecast.", ForecastDiscussion: "A front will keep showers in the forecast.",
}) })
@@ -309,7 +309,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LookupDefinition(today) error = %v", err) t.Fatalf("LookupDefinition(today) error = %v", err)
} }
_, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testCollected(), testTodayDerived(), Tomorrow{ _, err = todayHandler.BuildRenderContext(testTodayMetadata(), testTodaySnapshot(t), testTodayDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.", Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."}, ForecastDiscussion: []string{"A front will keep showers in the forecast."},
}) })
@@ -328,7 +328,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err) t.Fatalf("LookupDefinition(daily) error = %v", err)
} }
_, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{ _, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testDailyDerived(), Tomorrow{
Summary: "Storms become more likely tomorrow.", Summary: "Storms become more likely tomorrow.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."}, ForecastDiscussion: []string{"A front will keep showers in the forecast."},
}) })
@@ -349,7 +349,7 @@ func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LookupDefinition(daily) error = %v", err) t.Fatalf("LookupDefinition(daily) error = %v", err)
} }
ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{ ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testDailyDerived(), Daily{
Summary: "Showers are possible during the selected day.", Summary: "Showers are possible during the selected day.",
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."}, ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
}) })

View File

@@ -2,6 +2,7 @@ package generatedtext
import ( import (
"fmt" "fmt"
"reflect"
"sort" "sort"
"time" "time"
@@ -9,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -16,8 +18,6 @@ type HourlyRenderContext struct {
Report HourlyReportContext Report HourlyReportContext
GeneratedText Hourly GeneratedText Hourly
Modules HourlyTemplateModules Modules HourlyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
} }
type HourlyReportContext struct { type HourlyReportContext struct {
@@ -31,7 +31,6 @@ type HourlyReportContext struct {
} }
type HourlyTemplateModules struct { type HourlyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule HourlyForecast *briefing.HourlyForecastModule
PrecipTiming *briefing.PrecipTimingModule PrecipTiming *briefing.PrecipTimingModule
@@ -46,24 +45,18 @@ type TomorrowRenderContext struct {
Report TomorrowReportContext Report TomorrowReportContext
GeneratedText Tomorrow GeneratedText Tomorrow
Modules TomorrowTemplateModules Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
} }
type DailyRenderContext struct { type DailyRenderContext struct {
Report DailyReportContext Report DailyReportContext
GeneratedText Daily GeneratedText Daily
Modules DailyTemplateModules Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
} }
type TodayRenderContext struct { type TodayRenderContext struct {
Report TodayReportContext Report TodayReportContext
GeneratedText Today GeneratedText Today
Modules TodayTemplateModules Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
} }
type TodayReportContext struct { type TodayReportContext struct {
@@ -78,12 +71,12 @@ type TodayReportContext struct {
} }
type TodayTemplateModules struct { type TodayTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TodayDaypartContext Dayparts []TodayDaypartContext
HasDaypartDetails bool
PrecipTiming *briefing.PrecipTimingModule PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
@@ -110,7 +103,6 @@ type TomorrowReportContext struct {
} }
type TomorrowTemplateModules struct { type TomorrowTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -142,7 +134,6 @@ type DailyReportContext struct {
} }
type DailyTemplateModules struct { type DailyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -175,7 +166,6 @@ type dayStyleReportContext struct {
} }
type dayStyleTemplateModules struct { type dayStyleTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -188,7 +178,10 @@ type dayStyleTemplateModules struct {
WeatherStory *briefing.WeatherStoryModule WeatherStory *briefing.WeatherStoryModule
} }
func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) {
if err := validateRenderIdentity(report.Hourly, identity, snapshot); err != nil {
return HourlyRenderContext{}, err
}
location, err := timeutil.LoadLocation(identity.Timezone) location, err := timeutil.LoadLocation(identity.Timezone)
if err != nil { if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
@@ -215,12 +208,13 @@ func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot modul
}, },
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected,
Derived: derived,
}, nil }, nil
} }
func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Daily, derived facts.DerivedFacts) (DailyRenderContext, error) {
if err := validateRenderIdentity(report.Daily, identity, snapshot); err != nil {
return DailyRenderContext{}, err
}
reportContext, err := buildDayStyleReportContext(identity, "daily", func(dayName string) string { reportContext, err := buildDayStyleReportContext(identity, "daily", func(dayName string) string {
return dayName + "'s Weather" return dayName + "'s Weather"
}) })
@@ -235,12 +229,13 @@ func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module
Report: reportContext.dailyReportContext(), Report: reportContext.dailyReportContext(),
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected,
Derived: derived,
}, nil }, nil
} }
func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Today, derived facts.DerivedFacts) (TodayRenderContext, error) {
if err := validateRenderIdentity(report.Today, identity, snapshot); err != nil {
return TodayRenderContext{}, err
}
reportContext, err := buildDayStyleReportContext(identity, "today", func(string) string { reportContext, err := buildDayStyleReportContext(identity, "today", func(string) string {
return "Today's Weather" return "Today's Weather"
}) })
@@ -255,12 +250,13 @@ func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module
Report: reportContext.todayReportContext(), Report: reportContext.todayReportContext(),
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected,
Derived: derived,
}, nil }, nil
} }
func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot module.Snapshot, generated Tomorrow, derived facts.DerivedFacts) (TomorrowRenderContext, error) {
if err := validateRenderIdentity(report.Tomorrow, identity, snapshot); err != nil {
return TomorrowRenderContext{}, err
}
reportContext, err := buildDayStyleReportContext(identity, "tomorrow", func(dayName string) string { reportContext, err := buildDayStyleReportContext(identity, "tomorrow", func(dayName string) string {
return dayName + "'s Weather" return dayName + "'s Weather"
}) })
@@ -275,11 +271,36 @@ func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot mod
Report: reportContext.tomorrowReportContext(), Report: reportContext.tomorrowReportContext(),
GeneratedText: generated, GeneratedText: generated,
Modules: modules, Modules: modules,
Collected: collected,
Derived: derived,
}, nil }, nil
} }
func validateRenderIdentity(expected report.ID, identity briefing.PreparedIdentity, snapshot module.Snapshot) error {
if identity.ReportID != expected {
return fmt.Errorf("render identity report %q does not match expected report %q", identity.ReportID, expected)
}
metadata, found, err := module.StanzaValue[briefing.MetadataModule](snapshot, string(module.Metadata))
if err != nil {
return fmt.Errorf("read metadata module for report %q: %w", expected, err)
}
if !found || metadataMatchesIdentity(metadata, identity) {
return nil
}
return fmt.Errorf("metadata module does not match render identity for report %q", expected)
}
func metadataMatchesIdentity(metadata briefing.MetadataModule, identity briefing.PreparedIdentity) bool {
if metadata.RunID != identity.RunID || metadata.ReportID != identity.ReportID || metadata.Variant != identity.Variant || metadata.PromptID != identity.PromptID || !metadata.GeneratedAt.Equal(identity.GeneratedAt) || metadata.Units != identity.Units || metadata.Timezone != identity.Timezone || metadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(metadata.Location, identity.Location) || len(metadata.SourceWarnings) != len(identity.SourceWarnings) {
return false
}
for index, warning := range identity.SourceWarnings {
summary := metadata.SourceWarnings[index]
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
return false
}
}
return true
}
func buildDayStyleReportContext(identity briefing.PreparedIdentity, name string, title func(string) string) (dayStyleReportContext, error) { func buildDayStyleReportContext(identity briefing.PreparedIdentity, name string, title func(string) string) (dayStyleReportContext, error) {
location, err := timeutil.LoadLocation(identity.Timezone) location, err := timeutil.LoadLocation(identity.Timezone)
if err != nil { if err != nil {
@@ -359,7 +380,6 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return DailyTemplateModules{}, err return DailyTemplateModules{}, err
} }
return DailyTemplateModules{ return DailyTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions, CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary, DerivedDailySummary: common.DerivedDailySummary,
@@ -385,13 +405,14 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
if err != nil { if err != nil {
return TodayTemplateModules{}, err return TodayTemplateModules{}, err
} }
dayparts := orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries)
return TodayTemplateModules{ return TodayTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions, CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary, DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries, DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries), Dayparts: dayparts,
HasDaypartDetails: hasTodayDaypartDetails(dayparts),
PrecipTiming: common.PrecipTiming, PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest, AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks, SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
@@ -404,10 +425,6 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot) lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := lookup.currentConditions() current, err := lookup.currentConditions()
if err != nil { if err != nil {
return HourlyTemplateModules{}, err return HourlyTemplateModules{}, err
@@ -441,7 +458,6 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
return HourlyTemplateModules{}, err return HourlyTemplateModules{}, err
} }
return HourlyTemplateModules{ return HourlyTemplateModules{
Metadata: metadata,
CurrentConditions: current, CurrentConditions: current,
HourlyForecast: hourly, HourlyForecast: hourly,
PrecipTiming: precip, PrecipTiming: precip,
@@ -463,7 +479,6 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
return TomorrowTemplateModules{}, err return TomorrowTemplateModules{}, err
} }
return TomorrowTemplateModules{ return TomorrowTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions, CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast, HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary, DerivedDailySummary: common.DerivedDailySummary,
@@ -481,10 +496,6 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) { func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) {
lookup := newModuleSnapshotLookup(snapshot) lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
current, err := lookup.currentConditions() current, err := lookup.currentConditions()
if err != nil { if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
@@ -526,7 +537,6 @@ func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateM
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
} }
return dayStyleTemplateModules{ return dayStyleTemplateModules{
Metadata: metadata,
CurrentConditions: current, CurrentConditions: current,
HourlyForecast: hourly, HourlyForecast: hourly,
DerivedDailySummary: daily, DerivedDailySummary: daily,
@@ -556,10 +566,6 @@ func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup {
} }
} }
func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) {
return optionalStanza[briefing.MetadataModule](lookup, module.Metadata)
}
func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) { func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions) return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
} }
@@ -638,6 +644,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod
return out return out
} }
func hasTodayDaypartDetails(dayparts []TodayDaypartContext) bool {
for _, daypart := range dayparts {
if daypart.Summary.DominantConditionDisplay != "" || daypart.Summary.DominantCondition != "" {
return true
}
}
return false
}
func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext { func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext {
orderedRows := orderedDaypartRows(dayparts, ordered) orderedRows := orderedDaypartRows(dayparts, ordered)
out := make([]DailyDaypartContext, 0, len(orderedRows)) out := make([]DailyDaypartContext, 0, len(orderedRows))

View File

@@ -22,9 +22,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
ForecastDiscussion: "A front will keep the region unsettled.", ForecastDiscussion: "A front will keep the region unsettled.",
PrecipitationTiming: "A cold front is moving into the region.", PrecipitationTiming: "A cold front is moving into the region.",
} }
collected := testCollected() ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
derived := testDerived()
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
if err != nil { if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err) t.Fatalf("BuildHourlyRenderContext() error = %v", err)
} }
@@ -73,13 +71,6 @@ func TestBuildHourlyRenderContext(t *testing.T) {
if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" { if ctx.Modules.WeatherStory == nil || ctx.Modules.WeatherStory.Title != "Morning storms" {
t.Fatalf("Modules.WeatherStory = %#v, want story", ctx.Modules.WeatherStory) 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) rendered, err := reporttemplate.Render("hourly", ctx)
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -113,7 +104,7 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.", Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.", ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{}) })
if err != nil { if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err) t.Fatalf("BuildHourlyRenderContext() error = %v", err)
} }
@@ -135,7 +126,7 @@ func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ _, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.", Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.", ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{}) })
if err == nil { if err == nil {
t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error") t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error")
} }
@@ -144,6 +135,83 @@ func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
} }
} }
func TestRenderContextsRejectConflictingIdentity(t *testing.T) {
tests := []struct {
name string
build func() error
}{
{
name: "hourly",
build: func() error {
identity := testMetadata()
identity.ReportID = report.Daily
_, err := BuildHourlyRenderContext(identity, testSnapshot(t), Hourly{})
return err
},
},
{
name: "daily",
build: func() error {
identity := testDailyMetadata()
identity.ReportID = report.Today
_, err := BuildDailyRenderContext(identity, testDailySnapshot(t), Daily{}, testDailyDerived())
return err
},
},
{
name: "today",
build: func() error {
identity := testTodayMetadata()
identity.ReportID = report.Tomorrow
_, err := BuildTodayRenderContext(identity, testTodaySnapshot(t), Today{}, testTodayDerived())
return err
},
},
{
name: "tomorrow",
build: func() error {
identity := testTomorrowMetadata()
identity.ReportID = report.Hourly
_, err := BuildTomorrowRenderContext(identity, testTomorrowSnapshot(t), Tomorrow{}, testTomorrowDerived())
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := test.build()
if err == nil {
t.Fatal("render context error = nil, want conflicting identity rejection")
}
if !strings.Contains(err.Error(), "render identity report") {
t.Fatalf("render context error = %v, want identity mismatch", err)
}
})
}
}
func TestRenderContextRejectsMetadataModuleMismatch(t *testing.T) {
identity := testTodayMetadata()
snapshot := testTodaySnapshot(t)
for index := range snapshot.Outputs {
if snapshot.Outputs[index].ID != module.Metadata {
continue
}
metadata := snapshot.Outputs[index].Value.(briefing.MetadataModule)
metadata.PromptID = "weather.daily_generated_text"
snapshot.Outputs[index].Value = metadata
}
_, err := BuildTodayRenderContext(identity, snapshot, Today{}, testTodayDerived())
if err == nil {
t.Fatal("BuildTodayRenderContext() error = nil, want metadata module mismatch")
}
if !strings.Contains(err.Error(), "metadata module does not match render identity") {
t.Fatalf("BuildTodayRenderContext() error = %v, want metadata mismatch", err)
}
}
func TestBuildTodayRenderContext(t *testing.T) { func TestBuildTodayRenderContext(t *testing.T) {
metadata := testTodayMetadata() metadata := testTodayMetadata()
snapshot := testTodaySnapshot(t) snapshot := testTodaySnapshot(t)
@@ -155,10 +223,9 @@ func TestBuildTodayRenderContext(t *testing.T) {
}, },
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.", PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
} }
collected := testCollected()
derived := testTodayDerived() derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, collected, derived) ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, derived)
if err != nil { if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err) t.Fatalf("BuildTodayRenderContext() error = %v", err)
} }
@@ -171,9 +238,6 @@ func TestBuildTodayRenderContext(t *testing.T) {
if ctx.Report.GeneratedAtLabel != "Monday, June 15, 2026 at 7:14 AM" { if ctx.Report.GeneratedAtLabel != "Monday, June 15, 2026 at 7:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
} }
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Today {
t.Fatalf("Modules.Metadata = %#v, want today metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast) t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
} }
@@ -186,6 +250,9 @@ func TestBuildTodayRenderContext(t *testing.T) {
if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" { if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" {
t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts) t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts)
} }
if !ctx.Modules.HasDaypartDetails {
t.Fatal("Modules.HasDaypartDetails = false, want displayable dayparts")
}
if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" { if morning := (*ctx.Modules.DerivedDaypartSummaries)["morning"]; morning.DominantConditionDisplay == "" || morning.DominantConditionLower == "" || morning.TemperatureSteadyPhraseF == "" {
t.Fatalf("today rich morning daypart helpers = %#v, want render-context helper fields", morning) t.Fatalf("today rich morning daypart helpers = %#v, want render-context helper fields", morning)
} }
@@ -201,13 +268,6 @@ func TestBuildTodayRenderContext(t *testing.T) {
if ctx.Modules.TodayPlanning.MorningReadiness[0] != "Take sunglasses early." { if ctx.Modules.TodayPlanning.MorningReadiness[0] != "Take sunglasses early." {
t.Fatalf("Modules.TodayPlanning = %#v, want today planning facts", ctx.Modules.TodayPlanning) t.Fatalf("Modules.TodayPlanning = %#v, want today planning facts", ctx.Modules.TodayPlanning)
} }
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("today", ctx) rendered, err := reporttemplate.Render("today", ctx)
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -260,7 +320,7 @@ func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) {
ctx, err := BuildTodayRenderContext(metadata, snapshot, Today{ ctx, err := BuildTodayRenderContext(metadata, snapshot, Today{
Summary: "Today summary.", Summary: "Today summary.",
ForecastDiscussion: []string{"Morning conditions stay quiet."}, ForecastDiscussion: []string{"Morning conditions stay quiet."},
}, testCollected(), testTodayDerived()) }, testTodayDerived())
if err != nil { if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err) t.Fatalf("BuildTodayRenderContext() error = %v", err)
} }
@@ -281,13 +341,16 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{ ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{
Summary: "Dry weather is expected today.", Summary: "Dry weather is expected today.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{}) }, facts.DerivedFacts{})
if err != nil { if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err) t.Fatalf("BuildTodayRenderContext() error = %v", err)
} }
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 || ctx.Modules.TodayPlanning != nil { if ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 || ctx.Modules.TodayPlanning != nil {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
} }
if ctx.Modules.HasDaypartDetails {
t.Fatal("Modules.HasDaypartDetails = true, want no displayable dayparts")
}
rendered, err := reporttemplate.Render("today", ctx) rendered, err := reporttemplate.Render("today", ctx)
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -310,7 +373,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel string generatedAtLabel string
timezone string timezone string
validPeriod timeutil.Period validPeriod timeutil.Period
metadata bool
current bool current bool
hourly bool hourly bool
dailySummary bool dailySummary bool
@@ -323,8 +385,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion bool spcDiscussion bool
story bool story bool
planning bool planning bool
collected bool
derived bool
} }
tests := []struct { tests := []struct {
name string name string
@@ -340,12 +400,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 3, wantDayparts: 3,
build: func(t *testing.T) result { build: func(t *testing.T) result {
t.Helper() t.Helper()
collected := testCollected()
derived := testDailyDerived() derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{ ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{
Summary: "Daily summary.", Summary: "Daily summary.",
ForecastDiscussion: []string{"Daily discussion."}, ForecastDiscussion: []string{"Daily discussion."},
}, collected, derived) }, derived)
if err != nil { if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err) t.Fatalf("BuildDailyRenderContext() error = %v", err)
} }
@@ -355,7 +414,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone, timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod, validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily,
current: ctx.Modules.CurrentConditions != nil, current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil, hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -368,8 +426,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil, story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.DailyPlanning != nil, planning: ctx.Modules.DailyPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
} }
}, },
}, },
@@ -380,12 +436,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 2, wantDayparts: 2,
build: func(t *testing.T) result { build: func(t *testing.T) result {
t.Helper() t.Helper()
collected := testCollected()
derived := testTodayDerived() derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{ ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{
Summary: "Today summary.", Summary: "Today summary.",
ForecastDiscussion: []string{"Today discussion."}, ForecastDiscussion: []string{"Today discussion."},
}, collected, derived) }, derived)
if err != nil { if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err) t.Fatalf("BuildTodayRenderContext() error = %v", err)
} }
@@ -395,7 +450,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone, timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod, validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today,
current: ctx.Modules.CurrentConditions != nil, current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil, hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -408,8 +462,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil, story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TodayPlanning != nil, planning: ctx.Modules.TodayPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
} }
}, },
}, },
@@ -420,12 +472,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 2, wantDayparts: 2,
build: func(t *testing.T) result { build: func(t *testing.T) result {
t.Helper() t.Helper()
collected := testCollected()
derived := testTomorrowDerived() derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{ ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{
Summary: "Tomorrow summary.", Summary: "Tomorrow summary.",
ForecastDiscussion: []string{"Tomorrow discussion."}, ForecastDiscussion: []string{"Tomorrow discussion."},
}, collected, derived) }, derived)
if err != nil { if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err) t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
} }
@@ -435,7 +486,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel, generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone, timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod, validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow,
current: ctx.Modules.CurrentConditions != nil, current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil, hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil, dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -448,8 +498,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil, story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TomorrowPlanning != nil, planning: ctx.Modules.TomorrowPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
} }
}, },
}, },
@@ -473,7 +521,7 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
if !result.validPeriod.IsValid() { if !result.validPeriod.IsValid() {
t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod) 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 { if !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) t.Fatalf("common modules = %#v, want all shared modules populated", result)
} }
if result.dayparts != test.wantDayparts { if result.dayparts != test.wantDayparts {
@@ -482,9 +530,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
if !result.planning { if !result.planning {
t.Fatalf("planning = false, want %s planning module populated", test.name) 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)
}
}) })
} }
} }
@@ -500,10 +545,9 @@ func TestBuildDailyRenderContext(t *testing.T) {
}, },
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.", PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
} }
collected := testCollected()
derived := testDailyDerived() derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived) ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, derived)
if err != nil { if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err) t.Fatalf("BuildDailyRenderContext() error = %v", err)
} }
@@ -519,9 +563,6 @@ func TestBuildDailyRenderContext(t *testing.T) {
if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" { if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" {
t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone) t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone)
} }
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Daily {
t.Fatalf("Modules.Metadata = %#v, want daily metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast) t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
} }
@@ -552,13 +593,6 @@ func TestBuildDailyRenderContext(t *testing.T) {
if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." { if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." {
t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning) t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning)
} }
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("daily", ctx) rendered, err := reporttemplate.Render("daily", ctx)
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -601,11 +635,11 @@ func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{ ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{
Summary: "Dry weather is expected for the selected day.", Summary: "Dry weather is expected for the selected day.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{}) }, facts.DerivedFacts{})
if err != nil { if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err) t.Fatalf("BuildDailyRenderContext() error = %v", err)
} }
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 { if ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
} }
rendered, err := reporttemplate.Render("daily", ctx) rendered, err := reporttemplate.Render("daily", ctx)
@@ -632,10 +666,9 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
}, },
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.", PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
} }
collected := testCollected()
derived := testTomorrowDerived() derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived) ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, derived)
if err != nil { if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err) t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
} }
@@ -648,9 +681,6 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" { if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" {
t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel)
} }
if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow {
t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata)
}
if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil {
t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast) t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast)
} }
@@ -675,13 +705,6 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil { if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil {
t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) t.Fatalf("optional modules missing from render context: %#v", ctx.Modules)
} }
if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) {
t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt)
}
if len(ctx.Derived.DaypartSummaries) != 2 {
t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries)
}
rendered, err := reporttemplate.Render("tomorrow", ctx) rendered, err := reporttemplate.Render("tomorrow", ctx)
if err != nil { if err != nil {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
@@ -721,11 +744,11 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{ ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
Summary: "Dry weather is expected tomorrow.", Summary: "Dry weather is expected tomorrow.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{}) }, facts.DerivedFacts{})
if err != nil { if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err) t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
} }
if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 { if ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 {
t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules)
} }
rendered, err := reporttemplate.Render("tomorrow", ctx) rendered, err := reporttemplate.Render("tomorrow", ctx)
@@ -821,10 +844,6 @@ func testTodayMetadata() briefing.PreparedIdentity {
} }
} }
func testCollected() facts.CollectedFacts {
return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)}
}
func testDerived() facts.DerivedFacts { func testDerived() facts.DerivedFacts {
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}} return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
} }
@@ -878,6 +897,7 @@ func testTodaySnapshot(t *testing.T) module.Snapshot {
Units: "imperial", Units: "imperial",
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: testTodayMetadata().ValidPeriod, ValidPeriod: testTodayMetadata().ValidPeriod,
Location: testTodayMetadata().Location,
}, },
}, },
{ {
@@ -1010,6 +1030,7 @@ func testDailySnapshot(t *testing.T) module.Snapshot {
Units: "imperial", Units: "imperial",
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: testDailyMetadata().ValidPeriod, ValidPeriod: testDailyMetadata().ValidPeriod,
Location: testDailyMetadata().Location,
}, },
}, },
{ {
@@ -1289,6 +1310,7 @@ func testTomorrowSnapshot(t *testing.T) module.Snapshot {
Units: "imperial", Units: "imperial",
Timezone: "America/Chicago", Timezone: "America/Chicago",
ValidPeriod: testTomorrowMetadata().ValidPeriod, ValidPeriod: testTomorrowMetadata().ValidPeriod,
Location: testTomorrowMetadata().Location,
}, },
}, },
{ {

View File

@@ -704,6 +704,26 @@ func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) {
} }
} }
func TestRenderTodayUsesDaypartFallbackWhenRowsAreNotDisplayable(t *testing.T) {
rendered, err := Render("today", testTodayRenderContext{
Report: testTodayReportContext{Title: "Today", ForecastDateLabel: "Monday, June 15, 2026"},
GeneratedText: testTomorrowGeneratedText{Summary: "Today summary."},
Modules: testTodayModules{Dayparts: []testTomorrowDaypart{
{Key: "morning", Summary: testDaypartSummary{DisplayName: "Morning"}},
}},
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
text := string(rendered)
if !strings.Contains(text, "## Daypart Forecast\n- No daypart forecast details are available.") {
t.Fatalf("rendered template missing daypart fallback:\n%s", text)
}
if strings.Contains(text, "- **Morning:**") {
t.Fatalf("rendered template included an unavailable daypart row:\n%s", text)
}
}
func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) { func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{ rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{ Report: testTomorrowReportContext{
@@ -1013,6 +1033,15 @@ type testTodayModules struct {
TodayPlanning *testTodayPlanning TodayPlanning *testTodayPlanning
} }
func (modules testTodayModules) HasDaypartDetails() bool {
for _, daypart := range modules.Dayparts {
if daypart.Summary.DominantConditionDisplay != "" || daypart.Summary.DominantCondition != "" {
return true
}
}
return false
}
type testDailyModules struct { type testDailyModules struct {
Dayparts []testDailyDaypart Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming

View File

@@ -1,5 +1,5 @@
{{ define "today_daypart_forecast" }}## Daypart Forecast {{ 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 }} {{ if .Modules.HasDaypartDetails }}{{ range .Modules.Dayparts }}{{ $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 }}{{ else }}- No daypart forecast details are available.
{{ end }} {{ end }}
{{ end }} {{ end }}