Files
weatherreporter/internal/briefing/base_modules_test.go

736 lines
30 KiB
Go

package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"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"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
tests := []struct {
id module.ID
stanza string
}{
{id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
{id: module.HourlyForecast, stanza: "hourly_forecast"},
{id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"},
}
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil {
t.Fatal("BuildModule() output = nil, want stanza")
}
if output.ID != tt.id || output.StanzaName != tt.stanza {
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
}
})
}
}
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
if !period.MentionPrecipitation {
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
}
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
}
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
}
period := prompt.Periods[0]
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
}
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
}
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
periods := []weatherdata.ForecastPeriod{
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
}
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
if value[0].MentionPrecipitation {
t.Fatalf("19%% MentionPrecipitation = true, want false")
}
if !value[1].MentionPrecipitation {
t.Fatalf("20%% MentionPrecipitation = false, want true")
}
if value[2].MentionPrecipitation {
t.Fatalf("nil MentionPrecipitation = true, want false")
}
}
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[NarrativeForecastModule](t, output)
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
}
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
}
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
}
if period.WindDirection != "NE" {
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal narrative forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
}
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
}
}
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
t.Fatalf("metadata = %#v, want report identity", value)
}
if value.Location == nil || value.Location.Name != "Brentwood" {
t.Fatalf("Location = %#v, want configured location", value.Location)
}
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
}
jsonText := string(data)
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
}
if strings.Contains(jsonText, `"alerts"`) {
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
}
}
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
}
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
}
if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
}
if value.WindDirectionText != "south" {
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal current conditions: %v", err)
}
jsonText := string(data)
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
}
}
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
}
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
}
if prompt.WindDirection != "S" {
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
if !strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{}
ctx.Derived.AlertOverlaps = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(checked empty) error = %v", err)
}
checked := moduleValue[AlertDigestModule](t, output)
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
}
ctx.Collected.Alerts = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(missing) error = %v", err)
}
missing := moduleValue[AlertDigestModule](t, output)
if missing.Checked || !missing.Missing {
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
}
}
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
Event: "Wind Advisory",
Headline: "Wind Advisory until 8 PM",
Severity: "Moderate",
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
Instruction: "Secure outdoor objects.",
Description: "Gusty winds may blow around unsecured objects.",
}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(alert digest) error = %v", err)
}
value := moduleValue[AlertDigestModule](t, output)
if len(value.Relevant) != 1 {
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
}
alert := value.Relevant[0]
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
}
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
}
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
}
}
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Current = nil
ctx.Collected.Narrative = nil
ctx.Collected.Hourly = nil
ctx.Derived.ValidPeriodNarrativePeriods = nil
ctx.Derived.ValidPeriodHourlyPeriods = nil
ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = nil
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err)
}
if output != nil {
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
}
}
}
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
if err != nil {
t.Fatalf("BuildModule(afd) error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("AFD = %#v, want discussion sections", afd)
}
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule(weather story) error = %v", err)
}
story := moduleValue[WeatherStoryModule](t, storyOutput)
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
}
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
}
data, err := json.Marshal(storyOutput.Value)
if err != nil {
t.Fatalf("Marshal weather story: %v", err)
}
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
}
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
}
}
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want only short_term section", afd)
}
}
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("hourly default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
}
}
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("daily default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
t.Fatalf("AFD = %#v, want only long term section", afd)
}
}
func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
},
}
isDay := true
tempF := 74.4
apparentF := 75.6
humidity := 70.6
windMph := 8.4
windDirection := 190.0
narrativeTempF := 81.0
narrativePop := 60.0
narrativeWind := 12.0
narrativeWindDirection := 45.0
hourlyTempF := 76.0
hourlyPop := 70.0
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
Resolved: resolved,
Collected: facts.CollectedFacts{
Current: &weatherdata.Current{
ConditionText: "Partly cloudy",
IsDay: &isDay,
TemperatureF: &tempF,
ApparentTemperatureF: &apparentF,
RelativeHumidityPercent: &humidity,
WindSpeedMph: &windMph,
WindDirectionDegrees: &windDirection,
},
Narrative: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "narrative",
Periods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
},
Hourly: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "hourly",
Periods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
{
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
TextDescription: "Tomorrow showers.",
},
},
},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}},
Discussion: &weatherdata.Discussion{
Product: "discussion",
KeyMessages: []string{"Scattered showers are possible."},
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
},
WeatherStory: &weatherdata.WeatherStory{
OfficeID: "LSX",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
UpdatedAt: &updatedAt,
Title: "Rain Chances",
Description: "Scattered showers are possible.",
AltText: "Weather story graphic with rain chances.",
Priority: true,
Order: 1,
DownloadURL: "https://example.invalid/story.png",
},
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
SourceWarnings: []weatherdata.SourceWarning{{
Source: "daily",
Code: "missing_source",
Severity: "warning",
Message: "daily source is missing",
Endpoint: "/forecast/daily",
CompletenessImpact: "source omitted",
}},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
},
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
AlertOverlaps: []forecast.AlertOverlap{{
Event: "Flood Watch",
Headline: "Flooding possible",
Severity: "Moderate",
}},
},
Units: "us",
Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}
}
func moduleValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module value: %v", err)
}
return value
}
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.DataPackageValue())
if err != nil {
t.Fatalf("marshal module data package value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module data package value: %v", err)
}
return value
}
func mustMarshalModuleJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
return string(data)
}
func mustParseModuleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}