diff --git a/internal/generatedtext/daily.go b/internal/generatedtext/daily.go index 9cdcc24..f1323e4 100644 --- a/internal/generatedtext/daily.go +++ b/internal/generatedtext/daily.go @@ -1,10 +1,5 @@ package generatedtext -import ( - "fmt" - "strings" -) - type Daily struct { Summary string `json:"summary"` ForecastDiscussion []string `json:"forecast_discussion"` @@ -13,25 +8,21 @@ type Daily struct { } func ValidateDaily(data []byte) (Daily, []byte, error) { - value, err := decodeGeneratedText[Daily](data, "daily") - if err != nil { - return Daily{}, nil, err - } - - value.Summary = strings.TrimSpace(value.Summary) - value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) - value.Confidence = strings.TrimSpace(value.Confidence) - value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) - if value.Summary == "" { - return Daily{}, nil, fmt.Errorf("daily generated text summary is required") - } - if len(value.ForecastDiscussion) == 0 { - return Daily{}, nil, fmt.Errorf("daily generated text forecast discussion is required") - } - - normalized, err := normalizeGeneratedText(value, "daily") - if err != nil { - return Daily{}, nil, err - } - return value, normalized, nil + return validateDayStyleGeneratedText[Daily, *Daily](data, "daily") +} + +func (d *Daily) dayStyleFields() dayStyleFields { + return dayStyleFields{ + Summary: d.Summary, + ForecastDiscussion: d.ForecastDiscussion, + PrecipitationTiming: d.PrecipitationTiming, + Confidence: d.Confidence, + } +} + +func (d *Daily) setDayStyleFields(fields dayStyleFields) { + d.Summary = fields.Summary + d.ForecastDiscussion = fields.ForecastDiscussion + d.PrecipitationTiming = fields.PrecipitationTiming + d.Confidence = fields.Confidence } diff --git a/internal/generatedtext/day_style.go b/internal/generatedtext/day_style.go new file mode 100644 index 0000000..48076e6 --- /dev/null +++ b/internal/generatedtext/day_style.go @@ -0,0 +1,63 @@ +package generatedtext + +import ( + "fmt" + "strings" +) + +type dayStyleFields struct { + Summary string + ForecastDiscussion []string + PrecipitationTiming string + Confidence string +} + +type dayStyleGeneratedText interface { + dayStyleFields() dayStyleFields + setDayStyleFields(dayStyleFields) +} + +func validateDayStyleGeneratedText[T any, PT interface { + *T + dayStyleGeneratedText +}](data []byte, name string) (T, []byte, error) { + value, err := decodeGeneratedText[T](data, name) + if err != nil { + var zero T + return zero, nil, err + } + + pointer := PT(&value) + fields := pointer.dayStyleFields() + fields.Summary = strings.TrimSpace(fields.Summary) + fields.PrecipitationTiming = strings.TrimSpace(fields.PrecipitationTiming) + fields.Confidence = strings.TrimSpace(fields.Confidence) + fields.ForecastDiscussion = trimNonEmpty(fields.ForecastDiscussion) + if fields.Summary == "" { + var zero T + return zero, nil, fmt.Errorf("%s generated text summary is required", name) + } + if len(fields.ForecastDiscussion) == 0 { + var zero T + return zero, nil, fmt.Errorf("%s generated text forecast discussion is required", name) + } + pointer.setDayStyleFields(fields) + + normalized, err := normalizeGeneratedText(value, name) + if err != nil { + var zero T + return zero, nil, err + } + return value, normalized, nil +} + +func trimNonEmpty(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/internal/generatedtext/day_style_test.go b/internal/generatedtext/day_style_test.go new file mode 100644 index 0000000..b337594 --- /dev/null +++ b/internal/generatedtext/day_style_test.go @@ -0,0 +1,131 @@ +package generatedtext + +import ( + "fmt" + "strings" + "testing" +) + +func TestValidateDayStyleGeneratedTextSharedBehavior(t *testing.T) { + reports := []struct { + name string + validate func([]byte) (any, []byte, error) + }{ + { + name: "daily", + validate: func(data []byte) (any, []byte, error) { + value, normalized, err := ValidateDaily(data) + return value, normalized, err + }, + }, + { + name: "today", + validate: func(data []byte) (any, []byte, error) { + value, normalized, err := ValidateToday(data) + return value, normalized, err + }, + }, + { + name: "tomorrow", + validate: func(data []byte) (any, []byte, error) { + value, normalized, err := ValidateTomorrow(data) + return value, normalized, err + }, + }, + } + + for _, report := range reports { + t.Run(report.name, func(t *testing.T) { + t.Run("requires summary", func(t *testing.T) { + _, _, err := report.validate([]byte(`{"forecast_discussion":["First paragraph."]}`)) + want := fmt.Sprintf("%s generated text summary is required", report.name) + if err == nil || err.Error() != want { + t.Fatalf("validate() error = %v, want %q", err, want) + } + }) + + t.Run("requires non-empty forecast discussion", func(t *testing.T) { + _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":[" ",""]}`)) + want := fmt.Sprintf("%s generated text forecast discussion is required", report.name) + if err == nil || err.Error() != want { + t.Fatalf("validate() error = %v, want %q", err, want) + } + }) + + t.Run("trims and normalizes", func(t *testing.T) { + value, normalized, err := report.validate([]byte(`{ + "summary": " Shared summary. ", + "forecast_discussion": [ + " First paragraph. ", + "", + " Second paragraph. " + ], + "precipitation_timing": " Afternoon. ", + "confidence": " Medium " + }`)) + if err != nil { + t.Fatalf("validate() error = %v", err) + } + fields := dayStyleFieldsForTest(t, value) + if fields.Summary != "Shared summary." { + t.Fatalf("Summary = %q, want trimmed summary", fields.Summary) + } + if strings.Join(fields.ForecastDiscussion, "|") != "First paragraph.|Second paragraph." { + t.Fatalf("ForecastDiscussion = %#v, want trimmed non-empty paragraphs", fields.ForecastDiscussion) + } + if fields.PrecipitationTiming != "Afternoon." { + t.Fatalf("PrecipitationTiming = %q, want trimmed precipitation timing", fields.PrecipitationTiming) + } + if fields.Confidence != "Medium" { + t.Fatalf("Confidence = %q, want trimmed confidence", fields.Confidence) + } + want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph.","Second paragraph."],"precipitation_timing":"Afternoon.","confidence":"Medium"}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } + }) + + t.Run("omits empty optional fields", func(t *testing.T) { + _, normalized, err := report.validate([]byte(`{ + "summary": "Shared summary.", + "forecast_discussion": ["First paragraph."], + "precipitation_timing": " ", + "confidence": " " + }`)) + if err != nil { + t.Fatalf("validate() error = %v", err) + } + want := `{"summary":"Shared summary.","forecast_discussion":["First paragraph."]}` + if string(normalized) != want { + t.Fatalf("normalized = %s, want %s", normalized, want) + } + }) + + t.Run("rejects unknown fields", func(t *testing.T) { + _, _, err := report.validate([]byte(`{"summary":"Shared summary.","forecast_discussion":["First paragraph."],"extra":"value"}`)) + if err == nil { + t.Fatal("validate() error = nil, want error") + } + if !strings.Contains(err.Error(), `unknown field "extra"`) { + t.Fatalf("validate() error = %v, want unknown field error", err) + } + }) + }) + } +} + +func dayStyleFieldsForTest(t *testing.T, value any) dayStyleFields { + t.Helper() + + switch value := value.(type) { + case Daily: + return (&value).dayStyleFields() + case Today: + return (&value).dayStyleFields() + case Tomorrow: + return (&value).dayStyleFields() + default: + t.Fatalf("value type = %T, want day-style generated text", value) + return dayStyleFields{} + } +} diff --git a/internal/generatedtext/today.go b/internal/generatedtext/today.go index 5137dda..b400fa7 100644 --- a/internal/generatedtext/today.go +++ b/internal/generatedtext/today.go @@ -1,10 +1,5 @@ package generatedtext -import ( - "fmt" - "strings" -) - type Today struct { Summary string `json:"summary"` ForecastDiscussion []string `json:"forecast_discussion"` @@ -13,25 +8,21 @@ type Today struct { } func ValidateToday(data []byte) (Today, []byte, error) { - value, err := decodeGeneratedText[Today](data, "today") - if err != nil { - return Today{}, nil, err - } - - value.Summary = strings.TrimSpace(value.Summary) - value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) - value.Confidence = strings.TrimSpace(value.Confidence) - value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) - if value.Summary == "" { - return Today{}, nil, fmt.Errorf("today generated text summary is required") - } - if len(value.ForecastDiscussion) == 0 { - return Today{}, nil, fmt.Errorf("today generated text forecast discussion is required") - } - - normalized, err := normalizeGeneratedText(value, "today") - if err != nil { - return Today{}, nil, err - } - return value, normalized, nil + return validateDayStyleGeneratedText[Today, *Today](data, "today") +} + +func (t *Today) dayStyleFields() dayStyleFields { + return dayStyleFields{ + Summary: t.Summary, + ForecastDiscussion: t.ForecastDiscussion, + PrecipitationTiming: t.PrecipitationTiming, + Confidence: t.Confidence, + } +} + +func (t *Today) setDayStyleFields(fields dayStyleFields) { + t.Summary = fields.Summary + t.ForecastDiscussion = fields.ForecastDiscussion + t.PrecipitationTiming = fields.PrecipitationTiming + t.Confidence = fields.Confidence } diff --git a/internal/generatedtext/tomorrow.go b/internal/generatedtext/tomorrow.go index f9590c7..8854acc 100644 --- a/internal/generatedtext/tomorrow.go +++ b/internal/generatedtext/tomorrow.go @@ -1,10 +1,5 @@ package generatedtext -import ( - "fmt" - "strings" -) - type Tomorrow struct { Summary string `json:"summary"` ForecastDiscussion []string `json:"forecast_discussion"` @@ -13,36 +8,21 @@ type Tomorrow struct { } func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { - value, err := decodeGeneratedText[Tomorrow](data, "tomorrow") - if err != nil { - return Tomorrow{}, nil, err - } - - value.Summary = strings.TrimSpace(value.Summary) - value.PrecipitationTiming = strings.TrimSpace(value.PrecipitationTiming) - value.Confidence = strings.TrimSpace(value.Confidence) - value.ForecastDiscussion = trimNonEmpty(value.ForecastDiscussion) - if value.Summary == "" { - return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text summary is required") - } - if len(value.ForecastDiscussion) == 0 { - return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required") - } - - normalized, err := normalizeGeneratedText(value, "tomorrow") - if err != nil { - return Tomorrow{}, nil, err - } - return value, normalized, nil + return validateDayStyleGeneratedText[Tomorrow, *Tomorrow](data, "tomorrow") } -func trimNonEmpty(values []string) []string { - out := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - out = append(out, trimmed) - } +func (t *Tomorrow) dayStyleFields() dayStyleFields { + return dayStyleFields{ + Summary: t.Summary, + ForecastDiscussion: t.ForecastDiscussion, + PrecipitationTiming: t.PrecipitationTiming, + Confidence: t.Confidence, } - return out +} + +func (t *Tomorrow) setDayStyleFields(fields dayStyleFields) { + t.Summary = fields.Summary + t.ForecastDiscussion = fields.ForecastDiscussion + t.PrecipitationTiming = fields.PrecipitationTiming + t.Confidence = fields.Confidence }