diff --git a/internal/generatedtext/daily_test.go b/internal/generatedtext/daily_test.go deleted file mode 100644 index 05adea8..0000000 --- a/internal/generatedtext/daily_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package generatedtext - -import ( - "strings" - "testing" -) - -func TestValidateDailyNormalizesFields(t *testing.T) { - value, err := ValidateDaily([]byte(`{ - "summary": " Showers are possible during the selected day. ", - "forecast_discussion": [ - " A front will keep rain chances in the forecast. ", - "", - " Temperatures stay seasonable by afternoon. " - ], - "precipitation_timing": " Rain is most likely during the afternoon. " - }`)) - if err != nil { - t.Fatalf("ValidateDaily() error = %v", err) - } - if value.Summary != "Showers are possible during the selected day." { - t.Fatalf("Summary = %q, want trimmed summary", value.Summary) - } - if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances in the forecast.|Temperatures stay seasonable by afternoon." { - t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion) - } - if value.PrecipitationTiming != "Rain is most likely during the afternoon." { - t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) - } -} - -func TestValidateDailyPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - value, err := ValidateDaily([]byte(`{ - "summary": "Showers are possible during the selected day.", - "forecast_discussion": ["A front will keep rain chances in the forecast."], - "precipitation_timing": " " - }`)) - if err != nil { - t.Fatalf("ValidateDaily() error = %v", err) - } - if value.PrecipitationTiming != "" { - t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) - } -} - -func TestValidateDailyRejectsInvalidInput(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "malformed", - in: `{`, - want: "decode daily generated text", - }, - { - name: "unknown field", - in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"extra":"value"}`, - want: "unsupported field", - }, - { - name: "missing summary", - in: `{"forecast_discussion":["A front will keep rain chances in the forecast."]}`, - want: "summary is required", - }, - { - name: "blank summary", - in: `{"summary":" ","forecast_discussion":["A front will keep rain chances in the forecast."]}`, - want: "summary is required", - }, - { - name: "missing forecast discussion", - in: `{"summary":"Showers are possible during the selected day."}`, - want: "forecast discussion is required", - }, - { - name: "blank forecast discussion", - in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":[" ",""]}`, - want: "forecast discussion is required", - }, - { - name: "forecast discussion wrong type", - in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":"A front will keep rain chances in the forecast."}`, - want: "forecast discussion must be an array", - }, - { - name: "multiple values", - in: `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."]} {}`, - want: "multiple JSON values", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := ValidateDaily([]byte(test.in)) - if err == nil { - t.Fatal("ValidateDaily() error = nil, want error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("ValidateDaily() error = %v, want %q", err, test.want) - } - }) - } -} diff --git a/internal/generatedtext/day_style_test.go b/internal/generatedtext/day_style_test.go index ea37925..ba5aa41 100644 --- a/internal/generatedtext/day_style_test.go +++ b/internal/generatedtext/day_style_test.go @@ -119,6 +119,57 @@ func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { } } +func TestDayStyleValidatorsReturnReportSpecificTypes(t *testing.T) { + tests := []struct { + name string + validate func([]byte) (any, error) + matches func(any) bool + }{ + { + name: "daily", + validate: func(data []byte) (any, error) { + return ValidateDaily(data) + }, + matches: func(value any) bool { + _, ok := value.(Daily) + return ok + }, + }, + { + name: "today", + validate: func(data []byte) (any, error) { + return ValidateToday(data) + }, + matches: func(value any) bool { + _, ok := value.(Today) + return ok + }, + }, + { + name: "tomorrow", + validate: func(data []byte) (any, error) { + return ValidateTomorrow(data) + }, + matches: func(value any) bool { + _, ok := value.(Tomorrow) + return ok + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, err := test.validate([]byte(`{"summary":"Summary.","forecast_discussion":["Discussion."],"precipitation_timing":""}`)) + if err != nil { + t.Fatalf("validate() error = %v", err) + } + if !test.matches(value) { + t.Fatalf("validate() type = %T, want %s generated text", value, test.name) + } + }) + } +} + func dayStyleFieldsForTest(t *testing.T, value any) dayStyleFields { t.Helper() diff --git a/internal/generatedtext/json_test.go b/internal/generatedtext/json_test.go index 9dcf811..cbab177 100644 --- a/internal/generatedtext/json_test.go +++ b/internal/generatedtext/json_test.go @@ -10,42 +10,6 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) -func TestGeneratedTextValidatorsRequireExactObjectFields(t *testing.T) { - validators := []struct { - name string - valid string - validate func([]byte) error - }{ - {name: "daily", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateDailyJSON}, - {name: "today", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTodayJSON}, - {name: "tomorrow", valid: `{"summary":"Summary","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTomorrowJSON}, - {name: "hourly", valid: `{"summary":"Summary","forecast_discussion":"Discussion","precipitation_timing":""}`, validate: validateHourlyJSON}, - } - for _, validator := range validators { - t.Run(validator.name, func(t *testing.T) { - for _, test := range []struct { - name string - input string - valid bool - }{ - {name: "canonical", input: validator.valid, valid: true}, - {name: "missing", input: strings.Replace(validator.valid, `"summary":"Summary",`, "", 1)}, - {name: "additional", input: strings.Replace(validator.valid, "}", `,"extra":"value"}`, 1)}, - {name: "case variant summary", input: strings.Replace(validator.valid, `"summary"`, `"Summary"`, 1)}, - {name: "case variant discussion", input: strings.Replace(validator.valid, `"forecast_discussion"`, `"Forecast_Discussion"`, 1)}, - {name: "duplicate", input: strings.Replace(validator.valid, `"summary":"Summary",`, `"summary":"Summary","summary":"Other",`, 1)}, - } { - t.Run(test.name, func(t *testing.T) { - err := validator.validate([]byte(test.input)) - if (err == nil) != test.valid { - t.Fatalf("validate(%s) error = %v, want valid = %t", test.input, err, test.valid) - } - }) - } - }) - } -} - func TestTypedValidatorsMatchEmbeddedSchemaObjectShape(t *testing.T) { validators := []struct { name string @@ -68,6 +32,7 @@ func TestTypedValidatorsMatchEmbeddedSchemaObjectShape(t *testing.T) { {name: "missing", input: strings.Replace(validator.valid, `"summary":"Summary",`, "", 1)}, {name: "additional", input: strings.Replace(validator.valid, "}", `,"extra":"value"}`, 1)}, {name: "case variant", input: strings.Replace(validator.valid, `"summary"`, `"Summary"`, 1)}, + {name: "discussion case variant", input: strings.Replace(validator.valid, `"forecast_discussion"`, `"Forecast_Discussion"`, 1)}, {name: "null", input: strings.Replace(validator.valid, `"precipitation_timing":""`, `"precipitation_timing":null`, 1)}, {name: "wrong type", input: strings.Replace(validator.valid, `"summary":"Summary"`, `"summary":false`, 1)}, } { @@ -87,6 +52,27 @@ func TestTypedValidatorsMatchEmbeddedSchemaObjectShape(t *testing.T) { } } +func TestTypedValidatorsRejectDuplicateFields(t *testing.T) { + validators := []struct { + name string + input string + validate func([]byte) error + }{ + {name: "daily", input: `{"summary":"Summary","summary":"Other","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateDailyJSON}, + {name: "today", input: `{"summary":"Summary","summary":"Other","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTodayJSON}, + {name: "tomorrow", input: `{"summary":"Summary","summary":"Other","forecast_discussion":["Discussion"],"precipitation_timing":""}`, validate: validateTomorrowJSON}, + {name: "hourly", input: `{"summary":"Summary","summary":"Other","forecast_discussion":"Discussion","precipitation_timing":""}`, validate: validateHourlyJSON}, + } + + for _, validator := range validators { + t.Run(validator.name, func(t *testing.T) { + if err := validator.validate([]byte(validator.input)); err == nil { + t.Fatal("validate() error = nil, want duplicate-field rejection") + } + }) + } +} + func TestTypedValidatorsMatchEmbeddedSchemaContentLimits(t *testing.T) { validators := []struct { name string diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 83956bb..026ef74 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -71,27 +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) } - rendered, err := reporttemplate.Render("hourly", ctx) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - for _, want := range []string{ - "# Hourly Report", - "**Updated:** Friday, May 29, 2026 at 8:30 AM", - "Storm chances increase through late morning.", - "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", - "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM.", - "A cold front is moving into the region.", - "A front will keep the region unsettled.", - } { - if !strings.Contains(text, want) { - t.Fatalf("rendered template missing %q:\n%s", want, text) - } - } - if strings.Contains(text, "Avoid low-water crossings.") { - t.Fatalf("rendered template includes alert instruction:\n%s", text) - } } func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { @@ -268,40 +247,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) } - rendered, err := reporttemplate.Render("today", ctx) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - for _, want := range []string{ - "# Today's Weather", - "**Forecast date:** Monday, June 15, 2026", - "Today starts quiet, then showers become more likely later in the day.", - "Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.", - "- **Morning:** Partly cloudy, with temperatures in the low 60s.", - "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", - "## Precipitation Timing", - "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", - "The most likely rain window is from midafternoon into early evening.", - "Morning conditions should stay mostly dry.", - "Rain chances increase during the afternoon as deeper moisture arrives.", - } { - if !strings.Contains(text, want) { - t.Fatalf("rendered template missing %q:\n%s", want, text) - } - } - assertOrderedText(t, text, []string{ - "# Today's Weather", - "## Current Conditions", - "## Daypart Forecast", - "- **Morning:**", - "- **Afternoon:**", - "## Precipitation Timing", - "## Forecast Discussion", - }) - if strings.Contains(text, "## Planning Notes") || strings.Contains(text, "- Take sunglasses early.") || strings.Contains(text, "Forecast details are limited") { - t.Fatalf("rendered template included removed Today template content:\n%s", text) - } } func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) { @@ -324,12 +269,8 @@ func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) { if err != nil { t.Fatalf("BuildTodayRenderContext() error = %v", err) } - rendered, err := reporttemplate.Render("today", ctx) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - if !strings.Contains(string(rendered), "Mañana") { - t.Fatalf("rendered report = %q, want Unicode daypart display name", rendered) + if len(ctx.Modules.Dayparts) == 0 || ctx.Modules.Dayparts[0].Summary.DisplayName != "Mañana" { + t.Fatalf("dayparts = %#v, want Unicode daypart display name", ctx.Modules.Dayparts) } } @@ -351,19 +292,6 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) { 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) - } - text := string(rendered) - for _, unwanted := range []string{"## Current Conditions", "## Precipitation Timing", "## Planning Notes", "Forecast details are limited"} { - if strings.Contains(text, unwanted) { - t.Fatalf("rendered template included %q without module data:\n%s", unwanted, text) - } - } - if !strings.Contains(text, "- No daypart forecast details are available.") { - t.Fatalf("rendered template missing daypart fallback:\n%s", text) - } } func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *testing.T) { @@ -593,42 +521,10 @@ 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) } - rendered, err := reporttemplate.Render("daily", ctx) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - for _, want := range []string{ - "# Monday's Weather", - "**Forecast date:** Monday, June 15, 2026", - "**Updated:** Saturday, June 13, 2026 at 9:14 AM", - "The selected day starts quiet, then showers become more likely later in the day.", - "- **Morning:** Partly cloudy, with temperatures in the low 60s.", - "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", - "- **Evening:** Mostly cloudy, with temperatures in the upper 60s.", - "## Precipitation Timing", - "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", - "The most likely rain window is from midafternoon into early evening.", - "Morning conditions should stay mostly dry.", - "Rain chances increase during the afternoon as deeper moisture arrives.", - } { - if !strings.Contains(text, want) { - t.Fatalf("rendered template missing %q:\n%s", want, text) - } - } - assertOrderedText(t, text, []string{ - "# Monday's Weather", - "## Daypart Forecast", - "- **Morning:**", - "- **Afternoon:**", - "- **Evening:**", - "## Precipitation Timing", - "## Forecast Discussion", - }) } -func TestValidatedGeneratedTextRendersAsPlainText(t *testing.T) { - generated, err := ValidateDaily([]byte("{\"summary\":\"Summary.\\n## Fabricated Alert\\n- Fabricated warning\",\"forecast_discussion\":[\"Discussion with [unsafe](javascript:alert(1))\"],\"precipitation_timing\":\"Timing.\\n```not code```\"}")) +func TestValidatedGeneratedTextBuildsAndRendersTypedContext(t *testing.T) { + generated, err := ValidateDaily([]byte(`{"summary":"Daily summary.","forecast_discussion":["Daily discussion."],"precipitation_timing":"Late-day showers."}`)) if err != nil { t.Fatalf("ValidateDaily() error = %v", err) } @@ -640,19 +536,9 @@ func TestValidatedGeneratedTextRendersAsPlainText(t *testing.T) { if err != nil { t.Fatalf("Render() error = %v", err) } - text := string(rendered) - for _, want := range []string{ - "Summary.\n\\## Fabricated Alert\n\\- Fabricated warning", - "Discussion with \\[unsafe\\]\\(javascript:alert\\(1\\)\\)", - "Timing.\n\\`\\`\\`not code\\`\\`\\`", - } { - if !strings.Contains(text, want) { - t.Fatalf("rendered report missing escaped generated prose %q:\n%s", want, text) - } - } - for _, unwanted := range []string{"\n## Fabricated Alert", "\n- Fabricated warning", "[unsafe](javascript:alert(1))", "```not code```"} { - if strings.Contains(text, unwanted) { - t.Fatalf("rendered report preserved generated Markdown structure %q:\n%s", unwanted, text) + for _, want := range []string{"# Monday's Weather", "Daily summary.", "Daily discussion.", "Late-day showers."} { + if !strings.Contains(string(rendered), want) { + t.Fatalf("rendered report missing %q:\n%s", want, rendered) } } } @@ -672,17 +558,6 @@ func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) { 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) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - if strings.Contains(text, "## Precipitation Timing") { - t.Fatalf("rendered template included precipitation section without windows:\n%s", text) - } - if !strings.Contains(text, "- No daypart forecast details are available.") { - t.Fatalf("rendered template missing daypart fallback:\n%s", text) - } } func TestBuildTomorrowRenderContext(t *testing.T) { @@ -735,35 +610,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) } - rendered, err := reporttemplate.Render("tomorrow", ctx) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - for _, want := range []string{ - "# Monday's Weather", - "**Forecast date:** Monday, June 15, 2026", - "Tomorrow starts quiet, then showers become more likely later in the day.", - "- **Morning:** Partly cloudy, with temperatures in the low 60s.", - "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", - "## Precipitation Timing", - "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", - "The most likely rain window is from midafternoon into early evening.", - "Morning conditions should stay mostly dry.", - "Rain chances increase during the afternoon as deeper moisture arrives.", - } { - if !strings.Contains(text, want) { - t.Fatalf("rendered template missing %q:\n%s", want, text) - } - } - assertOrderedText(t, text, []string{ - "# Monday's Weather", - "## Daypart Forecast", - "- **Morning:**", - "- **Afternoon:**", - "## Precipitation Timing", - "## Forecast Discussion", - }) } func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { @@ -781,17 +627,6 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { 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) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - text := string(rendered) - if strings.Contains(text, "## Precipitation Timing") { - t.Fatalf("rendered template included precipitation section without windows:\n%s", text) - } - if !strings.Contains(text, "- No daypart forecast details are available.") { - t.Fatalf("rendered template missing daypart fallback:\n%s", text) - } } func testMetadata() briefing.PreparedIdentity { @@ -1461,18 +1296,3 @@ func floatPtr(value float64) *float64 { func intPtr(value int) *int { return &value } - -func assertOrderedText(t *testing.T, text string, ordered []string) { - t.Helper() - previousIndex := -1 - for _, want := range ordered { - index := strings.Index(text, want) - if index < 0 { - t.Fatalf("text missing %q:\n%s", want, text) - } - if index <= previousIndex { - t.Fatalf("%q appears out of order in:\n%s", want, text) - } - previousIndex = index - } -} diff --git a/internal/generatedtext/today_test.go b/internal/generatedtext/today_test.go deleted file mode 100644 index 19c3fa8..0000000 --- a/internal/generatedtext/today_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package generatedtext - -import ( - "strings" - "testing" -) - -func TestValidateTodayNormalizesFields(t *testing.T) { - value, err := ValidateToday([]byte(`{ - "summary": " Showers are likely today. ", - "forecast_discussion": [ - " A front will keep rain chances elevated. ", - "", - " Temperatures stay mild through the afternoon. " - ], - "precipitation_timing": " Rain is most likely during the afternoon. " - }`)) - if err != nil { - t.Fatalf("ValidateToday() error = %v", err) - } - if value.Summary != "Showers are likely today." { - t.Fatalf("Summary = %q, want trimmed summary", value.Summary) - } - if strings.Join(value.ForecastDiscussion, "|") != "A front will keep rain chances elevated.|Temperatures stay mild through the afternoon." { - t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion) - } - if value.PrecipitationTiming != "Rain is most likely during the afternoon." { - t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) - } -} - -func TestValidateTodayPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - value, err := ValidateToday([]byte(`{ - "summary": "Showers are likely today.", - "forecast_discussion": ["A front will keep rain chances elevated."], - "precipitation_timing": " " - }`)) - if err != nil { - t.Fatalf("ValidateToday() error = %v", err) - } - if value.PrecipitationTiming != "" { - t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) - } -} - -func TestValidateTodayRejectsInvalidInput(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "malformed", - in: `{`, - want: "decode today generated text", - }, - { - name: "unknown field", - in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."],"extra":"value"}`, - want: "unsupported field", - }, - { - name: "missing summary", - in: `{"forecast_discussion":["A front will keep rain chances elevated."]}`, - want: "summary is required", - }, - { - name: "blank summary", - in: `{"summary":" ","forecast_discussion":["A front will keep rain chances elevated."]}`, - want: "summary is required", - }, - { - name: "missing forecast discussion", - in: `{"summary":"Showers are likely today."}`, - want: "forecast discussion is required", - }, - { - name: "blank forecast discussion", - in: `{"summary":"Showers are likely today.","forecast_discussion":[" ",""]}`, - want: "forecast discussion is required", - }, - { - name: "forecast discussion wrong type", - in: `{"summary":"Showers are likely today.","forecast_discussion":"A front will keep rain chances elevated."}`, - want: "forecast discussion must be an array", - }, - { - name: "multiple values", - in: `{"summary":"Showers are likely today.","forecast_discussion":["A front will keep rain chances elevated."]} {}`, - want: "multiple JSON values", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := ValidateToday([]byte(test.in)) - if err == nil { - t.Fatal("ValidateToday() error = nil, want error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("ValidateToday() error = %v, want %q", err, test.want) - } - }) - } -} diff --git a/internal/generatedtext/tomorrow_test.go b/internal/generatedtext/tomorrow_test.go deleted file mode 100644 index 2f2432b..0000000 --- a/internal/generatedtext/tomorrow_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package generatedtext - -import ( - "strings" - "testing" -) - -func TestValidateTomorrowNormalizesFields(t *testing.T) { - value, err := ValidateTomorrow([]byte(`{ - "summary": " Storms become more likely tomorrow. ", - "forecast_discussion": [ - " A front will keep showers in the forecast. ", - "", - " Temperatures stay seasonable by afternoon. " - ], - "precipitation_timing": " Rain is most likely before sunrise. " - }`)) - if err != nil { - t.Fatalf("ValidateTomorrow() error = %v", err) - } - if value.Summary != "Storms become more likely tomorrow." { - t.Fatalf("Summary = %q, want trimmed summary", value.Summary) - } - if strings.Join(value.ForecastDiscussion, "|") != "A front will keep showers in the forecast.|Temperatures stay seasonable by afternoon." { - t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", value.ForecastDiscussion) - } - if value.PrecipitationTiming != "Rain is most likely before sunrise." { - t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", value.PrecipitationTiming) - } -} - -func TestValidateTomorrowPreservesRequiredEmptyPrecipitationTiming(t *testing.T) { - value, err := ValidateTomorrow([]byte(`{ - "summary": "Storms become more likely tomorrow.", - "forecast_discussion": ["A front will keep showers in the forecast."], - "precipitation_timing": " " - }`)) - if err != nil { - t.Fatalf("ValidateTomorrow() error = %v", err) - } - if value.PrecipitationTiming != "" { - t.Fatalf("PrecipitationTiming = %q, want required empty value", value.PrecipitationTiming) - } -} - -func TestValidateTomorrowRejectsInvalidInput(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "malformed", - in: `{`, - want: "decode tomorrow generated text", - }, - { - name: "unknown field", - in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."],"extra":"value"}`, - want: "unsupported field", - }, - { - name: "missing summary", - in: `{"forecast_discussion":["A front will keep showers in the forecast."]}`, - want: "summary is required", - }, - { - name: "blank summary", - in: `{"summary":" ","forecast_discussion":["A front will keep showers in the forecast."]}`, - want: "summary is required", - }, - { - name: "missing forecast discussion", - in: `{"summary":"Storms become more likely tomorrow."}`, - want: "forecast discussion is required", - }, - { - name: "blank forecast discussion", - in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":[" ",""]}`, - want: "forecast discussion is required", - }, - { - name: "forecast discussion wrong type", - in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":"A front will keep showers in the forecast."}`, - want: "forecast discussion must be an array", - }, - { - name: "multiple values", - in: `{"summary":"Storms become more likely tomorrow.","forecast_discussion":["A front will keep showers in the forecast."]} {}`, - want: "multiple JSON values", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := ValidateTomorrow([]byte(test.in)) - if err == nil { - t.Fatal("ValidateTomorrow() error = nil, want error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("ValidateTomorrow() error = %v, want %q", err, test.want) - } - }) - } -} diff --git a/internal/promptassets/promptassets_test.go b/internal/promptassets/promptassets_test.go index a1a2448..18d0485 100644 --- a/internal/promptassets/promptassets_test.go +++ b/internal/promptassets/promptassets_test.go @@ -222,6 +222,7 @@ func TestSchemasAreCanonicalAndIndependent(t *testing.T) { if schema.Type != "object" || schema.AdditionalProperties || strings.Join(schema.Required, ",") != "summary,forecast_discussion,precipitation_timing" { t.Fatalf("schema = %#v, want strict generated-text object", schema) } + assertGeneratedTextSchemaShape(t, id, schema.Properties) if _, ok := schema.Properties["confidence"]; ok { t.Fatalf("schema properties = %#v, do not want retired confidence field", schema.Properties) } @@ -237,6 +238,34 @@ func TestSchemasAreCanonicalAndIndependent(t *testing.T) { } } +func assertGeneratedTextSchemaShape(t *testing.T, id string, properties map[string]any) { + t.Helper() + for _, name := range []string{"summary", "precipitation_timing"} { + property, ok := properties[name].(map[string]any) + if !ok || property["type"] != "string" { + t.Fatalf("schema property %q = %#v, want string", name, properties[name]) + } + } + + discussion, ok := properties["forecast_discussion"].(map[string]any) + if !ok { + t.Fatalf("schema forecast_discussion = %#v, want property", properties["forecast_discussion"]) + } + if id == "hourly" { + if discussion["type"] != "string" || discussion["maxLength"] != float64(12_000) { + t.Fatalf("hourly forecast_discussion = %#v, want bounded string", discussion) + } + return + } + if discussion["type"] != "array" || discussion["minItems"] != float64(1) || discussion["maxItems"] != float64(12) { + t.Fatalf("%s forecast_discussion = %#v, want bounded non-empty array", id, discussion) + } + items, ok := discussion["items"].(map[string]any) + if !ok || items["type"] != "string" || items["maxLength"] != float64(4_000) { + t.Fatalf("%s forecast_discussion items = %#v, want bounded strings", id, discussion["items"]) + } +} + func TestPromptkitInspectsEmbeddedPromptsOffline(t *testing.T) { engine, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithPromptFS(promptassets.PromptFS(), "."), diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 32df6eb..2ff7fec 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -1,11 +1,8 @@ package reporttemplate import ( - "encoding/json" "strings" "testing" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets" ) func TestTemplateLookup(t *testing.T) { @@ -59,149 +56,6 @@ func TestTodayTemplateLookup(t *testing.T) { } } -func TestSchemaLookup(t *testing.T) { - data, err := promptassets.Schema("hourly") - if err != nil { - t.Fatalf("Schema() error = %v", err) - } - assertStringSchema(t, data, "summary,forecast_discussion,precipitation_timing", []string{"summary", "forecast_discussion", "precipitation_timing"}) -} - -func TestTomorrowSchemaLookup(t *testing.T) { - data, err := promptassets.Schema("tomorrow") - if err != nil { - t.Fatalf("Schema() error = %v", err) - } - schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing") - property, ok := schema.Properties["forecast_discussion"].(map[string]any) - if !ok { - t.Fatal("schema property forecast_discussion missing or invalid") - } - if property["type"] != "array" { - t.Fatalf("forecast_discussion type = %v, want array", property["type"]) - } - if property["minItems"] != float64(1) { - t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) - } - items, ok := property["items"].(map[string]any) - if !ok || items["type"] != "string" { - t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) - } - for _, field := range []string{"summary", "precipitation_timing"} { - property, ok := schema.Properties[field].(map[string]any) - if !ok { - t.Fatalf("schema property %q missing or invalid", field) - } - if property["type"] != "string" { - t.Fatalf("schema property %q type = %v, want string", field, property["type"]) - } - } -} - -func TestDailySchemaLookup(t *testing.T) { - data, err := promptassets.Schema("daily") - if err != nil { - t.Fatalf("Schema() error = %v", err) - } - schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing") - property, ok := schema.Properties["forecast_discussion"].(map[string]any) - if !ok { - t.Fatal("schema property forecast_discussion missing or invalid") - } - if property["type"] != "array" { - t.Fatalf("forecast_discussion type = %v, want array", property["type"]) - } - if property["minItems"] != float64(1) { - t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) - } - items, ok := property["items"].(map[string]any) - if !ok || items["type"] != "string" { - t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) - } - for _, field := range []string{"summary", "precipitation_timing"} { - property, ok := schema.Properties[field].(map[string]any) - if !ok { - t.Fatalf("schema property %q missing or invalid", field) - } - if property["type"] != "string" { - t.Fatalf("schema property %q type = %v, want string", field, property["type"]) - } - } -} - -func TestTodaySchemaLookup(t *testing.T) { - data, err := promptassets.Schema("today") - if err != nil { - t.Fatalf("Schema() error = %v", err) - } - schema := assertSchema(t, data, "summary,forecast_discussion,precipitation_timing") - property, ok := schema.Properties["forecast_discussion"].(map[string]any) - if !ok { - t.Fatal("schema property forecast_discussion missing or invalid") - } - if property["type"] != "array" { - t.Fatalf("forecast_discussion type = %v, want array", property["type"]) - } - if property["minItems"] != float64(1) { - t.Fatalf("forecast_discussion minItems = %v, want 1", property["minItems"]) - } - items, ok := property["items"].(map[string]any) - if !ok || items["type"] != "string" { - t.Fatalf("forecast_discussion items = %#v, want string items", property["items"]) - } - for _, field := range []string{"summary", "precipitation_timing"} { - property, ok := schema.Properties[field].(map[string]any) - if !ok { - t.Fatalf("schema property %q missing or invalid", field) - } - if property["type"] != "string" { - t.Fatalf("schema property %q type = %v, want string", field, property["type"]) - } - } -} - -func assertStringSchema(t *testing.T, data []byte, required string, fields []string) { - t.Helper() - schema := assertSchema(t, data, required) - for _, field := range fields { - property, ok := schema.Properties[field].(map[string]any) - if !ok { - t.Fatalf("schema property %q missing or invalid", field) - } - if property["type"] != "string" { - t.Fatalf("schema property %q type = %v, want string", field, property["type"]) - } - } -} - -func assertSchema(t *testing.T, data []byte, required string) struct { - Type string `json:"type"` - AdditionalProperties bool `json:"additionalProperties"` - Required []string `json:"required"` - Properties map[string]any `json:"properties"` -} { - t.Helper() - var schema struct { - Type string `json:"type"` - AdditionalProperties bool `json:"additionalProperties"` - Required []string `json:"required"` - Properties map[string]any `json:"properties"` - } - if err := json.Unmarshal(data, &schema); err != nil { - t.Fatalf("schema is invalid JSON: %v", err) - } - if schema.Type != "object" { - t.Fatalf("schema type = %q, want object", schema.Type) - } - if schema.AdditionalProperties { - t.Fatal("additionalProperties = true, want false") - } - if strings.Join(schema.Required, ",") != required { - t.Fatalf("required = %#v, want %s", schema.Required, required) - } - return schema -} - func TestRenderHourly(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{