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

@@ -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,
},
},
{