Add hourly generated text contract
This commit is contained in:
249
internal/generatedtext/render_context_test.go
Normal file
249
internal/generatedtext/render_context_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package generatedtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/reporttemplate"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestBuildHourlyRenderContext(t *testing.T) {
|
||||
metadata := testMetadata()
|
||||
snapshot := testSnapshot(t)
|
||||
generated := Hourly{
|
||||
Summary: "Storm chances increase through late morning.",
|
||||
Timing: "The main window is 10 AM to noon.",
|
||||
Impacts: "Brief downpours may slow travel.",
|
||||
Confidence: "Medium confidence in timing.",
|
||||
}
|
||||
ctx, err := BuildHourlyRenderContext(metadata, snapshot, generated)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v", err)
|
||||
}
|
||||
if ctx.ReportTitle != "Hourly Report" {
|
||||
t.Fatalf("ReportTitle = %q, want Hourly Report", ctx.ReportTitle)
|
||||
}
|
||||
if ctx.LocationName != "Brentwood, MO" {
|
||||
t.Fatalf("LocationName = %q, want Brentwood, MO", ctx.LocationName)
|
||||
}
|
||||
if ctx.ValidPeriod != "2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM" {
|
||||
t.Fatalf("ValidPeriod = %q, want friendly period", ctx.ValidPeriod)
|
||||
}
|
||||
if ctx.CurrentConditions != "Partly cloudy; 74 F; feels like 76 F; humidity 71%; wind S 8 mph." {
|
||||
t.Fatalf("CurrentConditions = %q, want deterministic summary", ctx.CurrentConditions)
|
||||
}
|
||||
if len(ctx.HourlyForecast) != 2 {
|
||||
t.Fatalf("HourlyForecast length = %d, want 2", len(ctx.HourlyForecast))
|
||||
}
|
||||
if row := ctx.HourlyForecast[1]; row.Time != "2026-05-29 at 10:00 AM" || row.Summary != "Showers" || row.Precipitation != "70% precipitation" {
|
||||
t.Fatalf("HourlyForecast[1] = %#v, want 10 AM showers row", row)
|
||||
}
|
||||
if !strings.Contains(ctx.PrecipitationTiming, "Peak precipitation probability 70% at 10 AM") {
|
||||
t.Fatalf("PrecipitationTiming = %q, want peak probability", ctx.PrecipitationTiming)
|
||||
}
|
||||
if strings.Join(ctx.Alerts, "|") != "Flood Watch: Flood Watch until early afternoon (Moderate)" {
|
||||
t.Fatalf("Alerts = %#v, want alert label", ctx.Alerts)
|
||||
}
|
||||
if strings.Join(ctx.SPCOutlooks, "|") != "Slight Risk from 2026-05-29 at 7:00 AM to 2026-05-29 at 3:00 PM" {
|
||||
t.Fatalf("SPCOutlooks = %#v, want outlook label", ctx.SPCOutlooks)
|
||||
}
|
||||
if ctx.ForecastDiscussion.ShortTerm != "Short-term discussion favors increasing rain coverage." {
|
||||
t.Fatalf("ForecastDiscussion.ShortTerm = %q, want discussion text", ctx.ForecastDiscussion.ShortTerm)
|
||||
}
|
||||
if strings.Join(ctx.SPCDiscussions, "|") != "Mesoscale discussion: Strong storms may develop late morning." {
|
||||
t.Fatalf("SPCDiscussions = %#v, want discussion label", ctx.SPCDiscussions)
|
||||
}
|
||||
if ctx.WeatherStory != "Morning storms - Morning storms remain the main story." {
|
||||
t.Fatalf("WeatherStory = %q, want story label", ctx.WeatherStory)
|
||||
}
|
||||
|
||||
rendered, err := reporttemplate.Render("hourly", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"# Hourly Report",
|
||||
"Storm chances increase through late morning.",
|
||||
"- 2026-05-29 at 10:00 AM: Showers; 75 F; 70% precipitation; wind S 10 mph, gusts 18 mph",
|
||||
"- Flood Watch: Flood Watch until early afternoon (Moderate)",
|
||||
"Morning storms - Morning storms remain the main story.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHourlyRenderContextRequiresCoreStanzas(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: briefing.CurrentConditionsModule{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
_, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{
|
||||
Summary: "Storm chances increase.",
|
||||
Timing: "Late morning.",
|
||||
Impacts: "Brief downpours.",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildHourlyRenderContext() error = nil, want missing stanza error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `requires stanza "hourly_forecast"`) {
|
||||
t.Fatalf("BuildHourlyRenderContext() error = %v, want missing hourly forecast stanza", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testMetadata() briefing.Metadata {
|
||||
generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC)
|
||||
return briefing.Metadata{
|
||||
RunID: "run-1",
|
||||
ReportID: report.Hourly,
|
||||
PromptID: "weather.hourly_generated_text",
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "imperial",
|
||||
Timezone: "America/Chicago",
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: generatedAt,
|
||||
End: generatedAt.Add(6 * time.Hour),
|
||||
},
|
||||
Location: &briefing.LocationContext{
|
||||
Name: "Brentwood",
|
||||
Region: "MO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(t *testing.T) module.Snapshot {
|
||||
t.Helper()
|
||||
snapshot, err := module.NewSnapshot([]module.Output{
|
||||
{
|
||||
ID: module.CurrentConditions,
|
||||
StanzaName: string(module.CurrentConditions),
|
||||
Value: briefing.CurrentConditionsModule{
|
||||
ConditionText: "Partly cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ApparentTemperatureF: floatPtr(76),
|
||||
RelativeHumidityPercent: floatPtr(71),
|
||||
WindSpeedMph: floatPtr(8),
|
||||
WindDirection: "S",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.HourlyForecast,
|
||||
StanzaName: string(module.HourlyForecast),
|
||||
Value: briefing.HourlyForecastModule{
|
||||
Periods: []briefing.HourlyForecastPeriod{
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 9:00 AM",
|
||||
TextDescription: "Cloudy",
|
||||
TemperatureF: floatPtr(74),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(30),
|
||||
WindSpeedMph: floatPtr(8),
|
||||
WindDirection: "S",
|
||||
},
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
TextDescription: "Showers",
|
||||
TemperatureF: floatPtr(75),
|
||||
ProbabilityOfPrecipitationPercent: floatPtr(70),
|
||||
WindSpeedMph: floatPtr(10),
|
||||
WindGustMph: floatPtr(18),
|
||||
WindDirection: "S",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: string(module.PrecipTiming),
|
||||
Value: briefing.PrecipTimingModule{
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
ProbabilityThreshold: 50,
|
||||
PrecipitationWindows: []briefing.PrecipitationWindowModule{
|
||||
{
|
||||
PeriodBegins: "2026-05-29 at 10:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 12:00 PM",
|
||||
MaxPopPercent: intPtr(70),
|
||||
MaxPopTime: "10 AM",
|
||||
},
|
||||
},
|
||||
ThunderMentioned: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AlertDigest,
|
||||
StanzaName: string(module.AlertDigest),
|
||||
Value: briefing.AlertDigestModule{
|
||||
Checked: true,
|
||||
ActiveCount: 1,
|
||||
RelevantCount: 1,
|
||||
Relevant: []briefing.AlertSummary{
|
||||
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
Value: briefing.SPCConvectiveOutlooksModule{
|
||||
Checked: true,
|
||||
OutlookCount: 1,
|
||||
Outlooks: []briefing.SPCConvectiveOutlookRecord{
|
||||
{
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
PeriodBegins: "2026-05-29 at 7:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 3:00 PM",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: string(module.AreaForecastDiscussion),
|
||||
Value: briefing.AreaForecastDiscussionModule{
|
||||
KeyMessages: []string{"Storms are most likely late morning."},
|
||||
ShortTerm: "Short-term discussion favors increasing rain coverage.",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveDiscussion,
|
||||
StanzaName: string(module.SPCConvectiveDiscussion),
|
||||
Value: briefing.SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: "categorical severity_rank >= 3",
|
||||
Discussions: []briefing.SPCConvectiveDiscussionRecord{
|
||||
{Headline: "Mesoscale discussion", Summary: "Strong storms may develop late morning."},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: module.WeatherStory,
|
||||
StanzaName: string(module.WeatherStory),
|
||||
Value: briefing.WeatherStoryModule{
|
||||
Available: true,
|
||||
Title: "Morning storms",
|
||||
Description: "Morning storms remain the main story.",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSnapshot() error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func floatPtr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user