From b4e0aadbefbfe4ac857a5e24b4300eda3955ef6f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 12:41:45 +0000 Subject: [PATCH] Clean up generated text helpers --- internal/generatedtext/hourly.go | 24 +--- internal/generatedtext/json.go | 33 +++++ internal/generatedtext/render_context.go | 117 ++++++++++++++---- internal/generatedtext/render_context_test.go | 28 ++++- internal/generatedtext/tomorrow.go | 24 +--- 5 files changed, 160 insertions(+), 66 deletions(-) create mode 100644 internal/generatedtext/json.go diff --git a/internal/generatedtext/hourly.go b/internal/generatedtext/hourly.go index cd02275..9e431ae 100644 --- a/internal/generatedtext/hourly.go +++ b/internal/generatedtext/hourly.go @@ -2,10 +2,7 @@ package generatedtext import ( - "bytes" - "encoding/json" "fmt" - "io" "strings" ) @@ -17,20 +14,9 @@ type Hourly struct { } func ValidateHourly(data []byte) (Hourly, []byte, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - - var value Hourly - if err := decoder.Decode(&value); err != nil { - return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err) - } - var extra any - if err := decoder.Decode(&extra); err != nil { - if err != io.EOF { - return Hourly{}, nil, fmt.Errorf("decode hourly generated text: %w", err) - } - } else { - return Hourly{}, nil, fmt.Errorf("decode hourly generated text: multiple JSON values") + value, err := decodeGeneratedText[Hourly](data, "hourly") + if err != nil { + return Hourly{}, nil, err } value.Summary = strings.TrimSpace(value.Summary) @@ -44,9 +30,9 @@ func ValidateHourly(data []byte) (Hourly, []byte, error) { return Hourly{}, nil, fmt.Errorf("hourly generated text forecast discussion is required") } - normalized, err := json.Marshal(value) + normalized, err := normalizeGeneratedText(value, "hourly") if err != nil { - return Hourly{}, nil, fmt.Errorf("normalize hourly generated text: %w", err) + return Hourly{}, nil, err } return value, normalized, nil } diff --git a/internal/generatedtext/json.go b/internal/generatedtext/json.go new file mode 100644 index 0000000..027a9b4 --- /dev/null +++ b/internal/generatedtext/json.go @@ -0,0 +1,33 @@ +package generatedtext + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +func decodeGeneratedText[T any](data []byte, name string) (T, error) { + var value T + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return value, fmt.Errorf("decode %s generated text: %w", name, err) + } + var extra any + if err := decoder.Decode(&extra); err != nil { + if err != io.EOF { + return value, fmt.Errorf("decode %s generated text: %w", name, err) + } + return value, nil + } + return value, fmt.Errorf("decode %s generated text: multiple JSON values", name) +} + +func normalizeGeneratedText[T any](value T, name string) ([]byte, error) { + normalized, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("normalize %s generated text: %w", name, err) + } + return normalized, nil +} diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index abb7e98..e30b3cc 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -152,39 +152,40 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap } func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { - metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) + lookup := newModuleSnapshotLookup(snapshot) + metadata, err := lookup.metadata() if err != nil { return HourlyTemplateModules{}, err } - current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) + current, err := lookup.currentConditions() if err != nil { return HourlyTemplateModules{}, err } - hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) + hourly, err := lookup.hourlyForecast() if err != nil { return HourlyTemplateModules{}, err } - precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) + precip, err := lookup.precipTiming() if err != nil { return HourlyTemplateModules{}, err } - alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) + alerts, err := lookup.alertDigest() if err != nil { return HourlyTemplateModules{}, err } - outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) + outlooks, err := lookup.spcConvectiveOutlooks() if err != nil { return HourlyTemplateModules{}, err } - discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) + discussion, err := lookup.areaForecastDiscussion() if err != nil { return HourlyTemplateModules{}, err } - spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) + spcDiscussion, err := lookup.spcConvectiveDiscussion() if err != nil { return HourlyTemplateModules{}, err } - story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) + story, err := lookup.weatherStory() if err != nil { return HourlyTemplateModules{}, err } @@ -202,51 +203,52 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err } func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { - metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) + lookup := newModuleSnapshotLookup(snapshot) + metadata, err := lookup.metadata() if err != nil { return TomorrowTemplateModules{}, err } - current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) + current, err := lookup.currentConditions() if err != nil { return TomorrowTemplateModules{}, err } - hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) + hourly, err := lookup.hourlyForecast() if err != nil { return TomorrowTemplateModules{}, err } - daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary)) + daily, err := lookup.derivedDailySummary() if err != nil { return TomorrowTemplateModules{}, err } - dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries)) + dayparts, err := lookup.derivedDaypartSummaries() if err != nil { return TomorrowTemplateModules{}, err } - precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) + precip, err := lookup.precipTiming() if err != nil { return TomorrowTemplateModules{}, err } - alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) + alerts, err := lookup.alertDigest() if err != nil { return TomorrowTemplateModules{}, err } - outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) + outlooks, err := lookup.spcConvectiveOutlooks() if err != nil { return TomorrowTemplateModules{}, err } - discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) + discussion, err := lookup.areaForecastDiscussion() if err != nil { return TomorrowTemplateModules{}, err } - spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) + spcDiscussion, err := lookup.spcConvectiveDiscussion() if err != nil { return TomorrowTemplateModules{}, err } - story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) + story, err := lookup.weatherStory() if err != nil { return TomorrowTemplateModules{}, err } - planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning)) + planning, err := lookup.tomorrowPlanning() if err != nil { return TomorrowTemplateModules{}, err } @@ -267,14 +269,79 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact }, nil } -func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) { - output, ok := snapshot.LookupStanza(name) +type moduleSnapshotLookup struct { + snapshot module.Snapshot + stanzas map[string]module.Output +} + +func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup { + stanzas := make(map[string]module.Output, len(snapshot.Outputs)) + for _, output := range snapshot.Outputs { + stanzas[output.StanzaName] = output + } + return moduleSnapshotLookup{ + snapshot: snapshot, + stanzas: stanzas, + } +} + +func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) { + return optionalStanza[briefing.MetadataModule](lookup, module.Metadata) +} + +func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) { + return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions) +} + +func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) { + return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast) +} + +func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) { + return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary) +} + +func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) { + return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries) +} + +func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) { + return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming) +} + +func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) { + return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest) +} + +func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) { + return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks) +} + +func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) { + return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion) +} + +func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) { + return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion) +} + +func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) { + return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory) +} + +func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) { + return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning) +} + +func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) { + name := string(id) + output, ok := lookup.stanzas[name] if !ok || output.Value == nil { return nil, nil } - value, _, err := module.StanzaValue[T](snapshot, name) + value, _, err := module.StanzaValue[T](lookup.snapshot, name) if err != nil { - return nil, fmt.Errorf("build render context: %w", err) + return nil, fmt.Errorf("build render context module %q: %w", id, err) } return &value, nil } diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index ae8f88d..8bf0f8a 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -109,8 +109,30 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { if err != nil { t.Fatalf("BuildHourlyRenderContext() error = %v", err) } - if ctx.Modules.HourlyForecast != nil { - t.Fatalf("Modules.HourlyForecast = %#v, want nil for omitted module", ctx.Modules.HourlyForecast) + if ctx.Modules.CurrentConditions == nil { + t.Fatal("Modules.CurrentConditions = nil, want populated module") + } + if ctx.Modules.HourlyForecast != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.AlertDigest != nil { + t.Fatalf("Modules = %#v, want omitted optional modules to remain nil", ctx.Modules) + } +} + +func TestBuildRenderContextReportsModuleExtractionError(t *testing.T) { + snapshot, err := module.NewSnapshot([]module.Output{ + {ID: module.CurrentConditions, StanzaName: string(module.CurrentConditions), Value: "not a current conditions stanza"}, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + _, err = BuildHourlyRenderContext(testMetadata(), snapshot, Hourly{ + Summary: "Storm chances increase.", + ForecastDiscussion: "A front will keep the region unsettled.", + }, testCollected(), facts.DerivedFacts{}) + if err == nil { + t.Fatal("BuildHourlyRenderContext() error = nil, want extraction error") + } + if !strings.Contains(err.Error(), string(module.CurrentConditions)) { + t.Fatalf("BuildHourlyRenderContext() error = %v, want module ID", err) } } @@ -212,7 +234,7 @@ func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { if err != nil { t.Fatalf("BuildTomorrowRenderContext() error = %v", err) } - if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 { + if ctx.Modules.Metadata != nil || 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) diff --git a/internal/generatedtext/tomorrow.go b/internal/generatedtext/tomorrow.go index 1f60f3d..f9590c7 100644 --- a/internal/generatedtext/tomorrow.go +++ b/internal/generatedtext/tomorrow.go @@ -1,10 +1,7 @@ package generatedtext import ( - "bytes" - "encoding/json" "fmt" - "io" "strings" ) @@ -16,20 +13,9 @@ type Tomorrow struct { } func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - - var value Tomorrow - if err := decoder.Decode(&value); err != nil { - return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err) - } - var extra any - if err := decoder.Decode(&extra); err != nil { - if err != io.EOF { - return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: %w", err) - } - } else { - return Tomorrow{}, nil, fmt.Errorf("decode tomorrow generated text: multiple JSON values") + value, err := decodeGeneratedText[Tomorrow](data, "tomorrow") + if err != nil { + return Tomorrow{}, nil, err } value.Summary = strings.TrimSpace(value.Summary) @@ -43,9 +29,9 @@ func ValidateTomorrow(data []byte) (Tomorrow, []byte, error) { return Tomorrow{}, nil, fmt.Errorf("tomorrow generated text forecast discussion is required") } - normalized, err := json.Marshal(value) + normalized, err := normalizeGeneratedText(value, "tomorrow") if err != nil { - return Tomorrow{}, nil, fmt.Errorf("normalize tomorrow generated text: %w", err) + return Tomorrow{}, nil, err } return value, normalized, nil }