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
The catalog's report-specific builders receive briefing metadata, a rich module
snapshot, collected facts, derived facts, and the matching validated generated
text. They decode the module stanzas needed by the template and build typed
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
metadata and periods, preserves rich module values, and uses ordered slices for
template iteration rather than maps.
The catalog's report-specific builders receive the prepared report identity, a
rich module snapshot, derived facts needed to order dayparts, and the matching
validated generated text. They require the identity's report ID to match the
selected builder. When the optional metadata stanza is present, every shared
identity field must agree with that prepared authority before context
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
stanzas, type-decoding failures, invalid 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.
Optional source stanzas become nil or fallback context fields. Today also
computes whether its ordered dayparts contain a displayable condition so the
template can render either rows or its explicit no-details fallback. Missing
required stanzas, type-decoding failures, conflicting identity values, invalid
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

View File

@@ -28,8 +28,9 @@ failures actionable with template or partial context.
Top-level templates decide which shared partials they invoke. The current
partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data
packages, and it must not reimplement weather selection or generated-text
validation.
packages or complete fact bundles, and it must not reimplement weather
selection or generated-text validation. Context construction rejects
report-identity disagreements before template execution.
## 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
orchestration to persist.
Focused tests cover template lookup, rendering, partial
behavior, missing keys, and malformed context:
Focused tests cover template lookup, rendering, partial behavior, daypart
fallbacks, missing keys, and malformed context:
```sh
go test ./internal/reporttemplate

View File

@@ -51,7 +51,9 @@ from rendering.
schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
- 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:
@@ -92,7 +94,7 @@ to render the section:
## 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:
| Field | Purpose |
@@ -100,12 +102,6 @@ fields:
| `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.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
@@ -144,8 +140,9 @@ Module pointers can be nil when their source or policy permits omission.
| 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.HasDaypartDetails` | Today |
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
| `.Modules.TodayPlanning` | Today |
| `.Modules.TomorrowPlanning` | Tomorrow |

View File

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

View File

@@ -37,14 +37,15 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity
originalDerived := prepared.derived
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
identity, snapshot, reportFacts, err := prepared.renderInputs()
identity, snapshot, derived, err := prepared.renderInputs()
if err != nil {
t.Fatalf("renderInputs() error = %v", err)
}
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
snapshot.Outputs = nil
reportFacts.Collected.Hourly.Periods[0].TextDescription = "consumer mutation"
derived.PrecipTiming.ThunderMentioned = false
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
if len(bundle.Sources) > 0 {
@@ -54,13 +55,10 @@ func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
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)
}
if prepared.reportFacts.Collected.Hourly.Periods[0].TextDescription == "mutated after preparation" {
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 {
if len(prepared.moduleSnapshot.Outputs) == 0 {
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 {
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 {
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 {
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 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 {
reportID report.ID
@@ -140,11 +140,11 @@ func (h Handler) Validate(data []byte) (any, []byte, error) {
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 {
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) {
@@ -171,34 +171,34 @@ func validateTomorrow(data []byte) (any, []byte, error) {
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)
if !ok {
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)
if !ok {
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)
if !ok {
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)
if !ok {
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 {
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.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
@@ -290,7 +290,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil {
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.",
ForecastDiscussion: "A front will keep showers in the forecast.",
})
@@ -309,7 +309,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil {
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.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
@@ -328,7 +328,7 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) {
if err != nil {
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.",
ForecastDiscussion: []string{"A front will keep showers in the forecast."},
})
@@ -349,7 +349,7 @@ func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) {
if err != nil {
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.",
ForecastDiscussion: []string{"A front will keep rain chances in the forecast."},
})

View File

@@ -2,6 +2,7 @@ package generatedtext
import (
"fmt"
"reflect"
"sort"
"time"
@@ -9,6 +10,7 @@ import (
"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/timeutil"
)
@@ -16,8 +18,6 @@ type HourlyRenderContext struct {
Report HourlyReportContext
GeneratedText Hourly
Modules HourlyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type HourlyReportContext struct {
@@ -31,7 +31,6 @@ type HourlyReportContext struct {
}
type HourlyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
PrecipTiming *briefing.PrecipTimingModule
@@ -46,24 +45,18 @@ type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type DailyRenderContext struct {
Report DailyReportContext
GeneratedText Daily
Modules DailyTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayRenderContext struct {
Report TodayReportContext
GeneratedText Today
Modules TodayTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
type TodayReportContext struct {
@@ -78,12 +71,12 @@ type TodayReportContext struct {
}
type TodayTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule
Dayparts []TodayDaypartContext
HasDaypartDetails bool
PrecipTiming *briefing.PrecipTimingModule
AlertDigest *briefing.AlertDigestModule
SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule
@@ -110,7 +103,6 @@ type TomorrowReportContext struct {
}
type TomorrowTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -142,7 +134,6 @@ type DailyReportContext struct {
}
type DailyTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -175,7 +166,6 @@ type dayStyleReportContext struct {
}
type dayStyleTemplateModules struct {
Metadata *briefing.MetadataModule
CurrentConditions *briefing.CurrentConditionsModule
HourlyForecast *briefing.HourlyForecastModule
DerivedDailySummary *briefing.DerivedDailySummaryModule
@@ -188,7 +178,10 @@ type dayStyleTemplateModules struct {
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)
if err != nil {
return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err)
@@ -215,12 +208,13 @@ func BuildHourlyRenderContext(identity briefing.PreparedIdentity, snapshot modul
},
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, 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 {
return dayName + "'s Weather"
})
@@ -235,12 +229,13 @@ func BuildDailyRenderContext(identity briefing.PreparedIdentity, snapshot module
Report: reportContext.dailyReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, 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 {
return "Today's Weather"
})
@@ -255,12 +250,13 @@ func BuildTodayRenderContext(identity briefing.PreparedIdentity, snapshot module
Report: reportContext.todayReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, 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 {
return dayName + "'s Weather"
})
@@ -275,11 +271,36 @@ func BuildTomorrowRenderContext(identity briefing.PreparedIdentity, snapshot mod
Report: reportContext.tomorrowReportContext(),
GeneratedText: generated,
Modules: modules,
Collected: collected,
Derived: derived,
}, 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) {
location, err := timeutil.LoadLocation(identity.Timezone)
if err != nil {
@@ -359,7 +380,6 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
return DailyTemplateModules{}, err
}
return DailyTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
@@ -385,13 +405,14 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
if err != nil {
return TodayTemplateModules{}, err
}
dayparts := orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries)
return TodayTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
DerivedDaypartSummaries: common.DerivedDaypartSummaries,
Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries),
Dayparts: dayparts,
HasDaypartDetails: hasTodayDaypartDetails(dayparts),
PrecipTiming: common.PrecipTiming,
AlertDigest: common.AlertDigest,
SPCConvectiveOutlooks: common.SPCConvectiveOutlooks,
@@ -404,10 +425,6 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts)
func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return HourlyTemplateModules{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return HourlyTemplateModules{}, err
@@ -441,7 +458,6 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err
return HourlyTemplateModules{}, err
}
return HourlyTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
PrecipTiming: precip,
@@ -463,7 +479,6 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
return TomorrowTemplateModules{}, err
}
return TomorrowTemplateModules{
Metadata: common.Metadata,
CurrentConditions: common.CurrentConditions,
HourlyForecast: common.HourlyForecast,
DerivedDailySummary: common.DerivedDailySummary,
@@ -481,10 +496,6 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact
func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) {
lookup := newModuleSnapshotLookup(snapshot)
metadata, err := lookup.metadata()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
current, err := lookup.currentConditions()
if err != nil {
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
@@ -526,7 +537,6 @@ func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateM
return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err
}
return dayStyleTemplateModules{
Metadata: metadata,
CurrentConditions: current,
HourlyForecast: hourly,
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) {
return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions)
}
@@ -638,6 +644,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod
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 {
orderedRows := orderedDaypartRows(dayparts, ordered)
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.",
PrecipitationTiming: "A cold front is moving into the region.",
}
collected := testCollected()
derived := testDerived()
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated, collected, derived)
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
if err != nil {
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" {
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)
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -113,7 +104,7 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
})
if err != nil {
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
}
@@ -135,7 +126,7 @@ func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) {
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
Summary: "Storm chances increase.",
ForecastDiscussion: "A front will keep the region unsettled.",
}, testCollected(), facts.DerivedFacts{})
})
if err == nil {
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) {
metadata := testTodayMetadata()
snapshot := testTodaySnapshot(t)
@@ -155,10 +223,9 @@ func TestBuildTodayRenderContext(t *testing.T) {
},
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
}
collected := testCollected()
derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, collected, derived)
ctx, err := BuildTodayRenderContext(metadata, snapshot, generated, derived)
if err != nil {
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" {
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 {
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" {
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 == "" {
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." {
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)
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -260,7 +320,7 @@ func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) {
ctx, err := BuildTodayRenderContext(metadata, snapshot, Today{
Summary: "Today summary.",
ForecastDiscussion: []string{"Morning conditions stay quiet."},
}, testCollected(), testTodayDerived())
}, testTodayDerived())
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
@@ -281,13 +341,16 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildTodayRenderContext(testTodayMetadata(), snapshot, Today{
Summary: "Dry weather is expected today.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
}, facts.DerivedFacts{})
if err != nil {
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)
}
if ctx.Modules.HasDaypartDetails {
t.Fatal("Modules.HasDaypartDetails = true, want no displayable dayparts")
}
rendered, err := reporttemplate.Render("today", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -310,7 +373,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel string
timezone string
validPeriod timeutil.Period
metadata bool
current bool
hourly bool
dailySummary bool
@@ -323,8 +385,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion bool
story bool
planning bool
collected bool
derived bool
}
tests := []struct {
name string
@@ -340,12 +400,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 3,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{
Summary: "Daily summary.",
ForecastDiscussion: []string{"Daily discussion."},
}, collected, derived)
}, derived)
if err != nil {
t.Fatalf("BuildDailyRenderContext() error = %v", err)
}
@@ -355,7 +414,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -368,8 +426,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.DailyPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
@@ -380,12 +436,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTodayDerived()
ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{
Summary: "Today summary.",
ForecastDiscussion: []string{"Today discussion."},
}, collected, derived)
}, derived)
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
@@ -395,7 +450,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -408,8 +462,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TodayPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
@@ -420,12 +472,11 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
wantDayparts: 2,
build: func(t *testing.T) result {
t.Helper()
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{
Summary: "Tomorrow summary.",
ForecastDiscussion: []string{"Tomorrow discussion."},
}, collected, derived)
}, derived)
if err != nil {
t.Fatalf("BuildTomorrowRenderContext() error = %v", err)
}
@@ -435,7 +486,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
generatedAtLabel: ctx.Report.GeneratedAtLabel,
timezone: ctx.Report.Timezone,
validPeriod: ctx.Report.ValidPeriod,
metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow,
current: ctx.Modules.CurrentConditions != nil,
hourly: ctx.Modules.HourlyForecast != nil,
dailySummary: ctx.Modules.DerivedDailySummary != nil,
@@ -448,8 +498,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil,
story: ctx.Modules.WeatherStory != nil,
planning: ctx.Modules.TomorrowPlanning != nil,
collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt),
derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries),
}
},
},
@@ -473,7 +521,7 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
if !result.validPeriod.IsValid() {
t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod)
}
if !result.metadata || !result.current || !result.hourly || !result.dailySummary || !result.daypartSummaries || !result.precip || !result.alerts || !result.outlooks || !result.discussion || !result.spcDiscussion || !result.story {
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)
}
if result.dayparts != test.wantDayparts {
@@ -482,9 +530,6 @@ func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *te
if !result.planning {
t.Fatalf("planning = false, want %s planning module populated", test.name)
}
if !result.collected || !result.derived {
t.Fatalf("facts passthrough = collected:%t derived:%t, want both true", result.collected, result.derived)
}
})
}
}
@@ -500,10 +545,9 @@ func TestBuildDailyRenderContext(t *testing.T) {
},
PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.",
}
collected := testCollected()
derived := testDailyDerived()
ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived)
ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, derived)
if err != nil {
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" {
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 {
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." {
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)
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -601,11 +635,11 @@ func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{
Summary: "Dry weather is expected for the selected day.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
}, facts.DerivedFacts{})
if err != nil {
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)
}
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.",
}
collected := testCollected()
derived := testTomorrowDerived()
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived)
ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, derived)
if err != nil {
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" {
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 {
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 {
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)
if err != nil {
t.Fatalf("Render() error = %v", err)
@@ -721,11 +744,11 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) {
ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{
Summary: "Dry weather is expected tomorrow.",
ForecastDiscussion: []string{"High pressure keeps conditions quiet."},
}, testCollected(), facts.DerivedFacts{})
}, facts.DerivedFacts{})
if err != nil {
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)
}
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 {
return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}}
}
@@ -878,6 +897,7 @@ func testTodaySnapshot(t *testing.T) module.Snapshot {
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testTodayMetadata().ValidPeriod,
Location: testTodayMetadata().Location,
},
},
{
@@ -1010,6 +1030,7 @@ func testDailySnapshot(t *testing.T) module.Snapshot {
Units: "imperial",
Timezone: "America/Chicago",
ValidPeriod: testDailyMetadata().ValidPeriod,
Location: testDailyMetadata().Location,
},
},
{
@@ -1289,6 +1310,7 @@ func testTomorrowSnapshot(t *testing.T) module.Snapshot {
Units: "imperial",
Timezone: "America/Chicago",
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) {
rendered, err := Render("tomorrow", testTomorrowRenderContext{
Report: testTomorrowReportContext{
@@ -1013,6 +1033,15 @@ type testTodayModules struct {
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 {
Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming

View File

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