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

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