736 lines
30 KiB
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.DefaultRegistry().MustLookup(report.Weekend)
|
|
|
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
|
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
|
|
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.DefaultRegistry().MustLookup(report.Weekend)
|
|
|
|
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
|
|
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
|
|
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)
|
|
}
|
|
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
|
|
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|