diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index 6112bd8..1326062 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -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 diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index 11981ef..0db1720 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -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 diff --git a/docs/templates.md b/docs/templates.md index 05391d9..d324162 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -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 | diff --git a/internal/app/prepared_report.go b/internal/app/prepared_report.go index 9970f22..e1da3f8 100644 --- a/internal/app/prepared_report.go +++ b/internal/app/prepared_report.go @@ -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) { diff --git a/internal/app/prepared_report_test.go b/internal/app/prepared_report_test.go index 8974f9d..000add7 100644 --- a/internal/app/prepared_report_test.go +++ b/internal/app/prepared_report_test.go @@ -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") } } diff --git a/internal/app/profile_execution.go b/internal/app/profile_execution.go index ccda9ea..743a83a 100644 --- a/internal/app/profile_execution.go +++ b/internal/app/profile_execution.go @@ -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} } diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index 9c4aaf0..efe3215 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -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) } diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index eb285b3..682d7e6 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -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."}, }) diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index ecd3504..d55140d 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -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)) diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 66d77c2..58017ef 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -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, }, }, { diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 3023703..fdec34f 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -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 diff --git a/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl b/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl index 842db3f..b25a319 100644 --- a/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl +++ b/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl @@ -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 }}