From 24dba3bd60d985b95311f74fc1be9340d478035f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 9 Jun 2026 20:30:49 +0000 Subject: [PATCH] Add module contracts and registry validation --- docs/internal/briefing.md | 9 ++ docs/internal/module.md | 58 +++++++ docs/internal/report-registry.md | 6 +- docs/policy/development.md | 1 + internal/briefing/modules.go | 241 ++++++++++++++++++++++++++++++ internal/briefing/modules_test.go | 76 ++++++++++ internal/module/module.go | 145 ++++++++++++++++++ internal/module/module_test.go | 84 +++++++++++ internal/report/definition.go | 10 ++ internal/report/period_test.go | 94 ++++++++++++ internal/report/registry.go | 81 +++++++++- 11 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 docs/internal/module.md create mode 100644 internal/briefing/modules.go create mode 100644 internal/briefing/modules_test.go create mode 100644 internal/module/module.go create mode 100644 internal/module/module_test.go diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 6681e47..69dfe16 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -21,6 +21,8 @@ Outputs: - `briefing.Package` with common metadata and one report-specific content object for Daily, 3-Day, Weekend, or Storm Report +- module registry definitions for known module IDs, stanza names, option + shapes, fact requirements, report compatibility, and missing-data behavior - optional `currentConditions` prompt context from normalized `/conditions/current` data when available - optional structured `weatherStory` context on report-specific briefing @@ -30,6 +32,8 @@ Outputs: ## Boundaries - This package selects and shapes weather facts for prompts. +- It owns module registry validation, but app orchestration does not execute + modules yet. - It does not fetch weather data, compare prior snapshots, build `data_package` files, invoke Scriptorium, or write workflow metadata. @@ -65,6 +69,10 @@ None. Builders either return a complete briefing package or an error. least one derived summary. - Storm briefing construction requires a Storm Report definition and forecast bundle. +- Module registry construction rejects duplicate module IDs and duplicate + stanza names. +- Module composition validation rejects unknown modules, duplicate modules, + incompatible report/module combinations, and invalid typed options. - Save failures include path and operation context. ## Tests @@ -75,6 +83,7 @@ Inspect: - `internal/briefing/three_day_test.go` - `internal/briefing/weekend_test.go` - `internal/briefing/storm_test.go` +- `internal/briefing/modules_test.go` - `internal/app/app_test.go` ## Invariants diff --git a/docs/internal/module.md b/docs/internal/module.md new file mode 100644 index 0000000..2cb87ca --- /dev/null +++ b/docs/internal/module.md @@ -0,0 +1,58 @@ +# Module Contract Internals + +This document describes the implemented module contract boundary. + +## Purpose + +`internal/module` defines stable module IDs, typed configuration items, module +outputs, and module snapshots. It is a shared contract imported by report +definitions and briefing registry code. + +## Inputs And Outputs + +Inputs: + +- ordered `module.ConfigItem` values from report definitions +- `module.Output` values assembled by callers + +Outputs: + +- stable `module.ID` constants +- typed option structs for known modules +- `module.Snapshot` with ordered outputs and schema version +- typed stanza lookup through `module.StanzaValue` + +## Boundaries + +- This package owns shared module identifiers and output envelope contracts. +- It does not define report IDs, build briefing stanzas, fetch weather data, + derive facts, write state, or invoke Scriptorium. + +## State Or Manifest Behavior + +`module.Snapshot` uses schema version `weatherreporter.modules.v1`. Snapshot +validation rejects duplicate module outputs and duplicate stanza names while +preserving output order. + +## Failure Behavior + +- Snapshot validation fails when schema version, module ID, or stanza name is + missing. +- Snapshot validation fails on duplicate module IDs or duplicate stanza names. +- Typed stanza lookup returns `found=false` for missing stanzas and wraps JSON + marshal/decode failures with stanza context. + +## Tests + +Inspect: + +- `internal/module/module_test.go` +- `internal/briefing/modules_test.go` +- `internal/report/period_test.go` + +## Invariants + +- `internal/module` does not import `internal/report`. +- Module IDs are stable strings. +- Each module output has exactly one stanza name and one typed value. +- Snapshot output order is caller-owned and preserved. diff --git a/docs/internal/report-registry.md b/docs/internal/report-registry.md index 5afe92d..575a73c 100644 --- a/docs/internal/report-registry.md +++ b/docs/internal/report-registry.md @@ -23,6 +23,7 @@ Each report definition declares: - generated-report eligibility - prior-report compatibility list - morning or evening batch membership +- default ordered module composition ## Implemented Reports @@ -70,6 +71,7 @@ output path copying uses batch output names from report definitions. - State paths use `ArtifactGroup`. - Batch output copies use `BatchOutputName`. - Generation checks `Generated`. +- Module composition defaults use `Modules`. - Prior lookup checks `CompatiblePriorIDs` and the comparison strategy. - RunIDs include the resolved report ID. @@ -93,5 +95,5 @@ Inspect: - Daily Today and Daily Tomorrow both use `weather.daily_report`. - Valid periods are half-open intervals independent of rendered report text. - Artifact grouping, batch output filenames, generated-report eligibility, - comparison compatibility, and comparison strategy are declared by report - definition. + default module composition, comparison compatibility, and comparison strategy + are declared by report definition. diff --git a/docs/policy/development.md b/docs/policy/development.md index c875eb1..eb538f0 100644 --- a/docs/policy/development.md +++ b/docs/policy/development.md @@ -20,6 +20,7 @@ Developers and LLM coding agents should use it with source warnings. - `internal/forecast`: deterministic forecast derivation. - `internal/facts`: collected and derived report fact contracts. +- `internal/module`: module IDs, config items, output envelopes, and snapshots. - `internal/report`: report definitions, valid periods, batches, output names, and comparison declarations. - `internal/briefing`: report-specific briefing package builders. diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go new file mode 100644 index 0000000..4651f86 --- /dev/null +++ b/internal/briefing/modules.go @@ -0,0 +1,241 @@ +package briefing + +import ( + "fmt" + "reflect" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +type ModuleDefinition struct { + ID module.ID + StanzaName string + DefaultOptions any + RequiredCollected []module.FactRequirement + RequiredDerived []module.FactRequirement + SupportedReports []report.ID + MissingData module.MissingDataBehavior + AllowDuplicate bool +} + +type ModuleRegistry struct { + definitions map[module.ID]ModuleDefinition +} + +func DefaultModuleRegistry() (ModuleRegistry, error) { + return NewModuleRegistry(defaultModuleDefinitions()) +} + +func MustDefaultModuleRegistry() ModuleRegistry { + registry, err := DefaultModuleRegistry() + if err != nil { + panic(err) + } + return registry +} + +func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) { + registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}} + seenStanzas := map[string]module.ID{} + for i, definition := range definitions { + if definition.ID == "" { + return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i) + } + if definition.StanzaName == "" { + return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID) + } + if _, ok := registry.definitions[definition.ID]; ok { + return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID) + } + if existingID, ok := seenStanzas[definition.StanzaName]; ok { + return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID) + } + seenStanzas[definition.StanzaName] = definition.ID + registry.definitions[definition.ID] = definition + } + return registry, nil +} + +func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) { + definition, ok := r.definitions[id] + if !ok { + return ModuleDefinition{}, fmt.Errorf("unknown module %q", id) + } + return definition, nil +} + +func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error { + seenModules := map[module.ID]struct{}{} + seenStanzas := map[string]module.ID{} + for i, item := range items { + definition, err := r.Lookup(item.ID) + if err != nil { + return fmt.Errorf("modules[%d]: %w", i, err) + } + if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate { + return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID) + } + seenModules[item.ID] = struct{}{} + if existingID, ok := seenStanzas[definition.StanzaName]; ok { + return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID) + } + seenStanzas[definition.StanzaName] = item.ID + if !definition.SupportsReport(reportID) { + return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID) + } + if err := definition.ValidateOptions(item.Options); err != nil { + return fmt.Errorf("modules[%d]: %w", i, err) + } + } + return nil +} + +func (d ModuleDefinition) SupportsReport(id report.ID) bool { + if len(d.SupportedReports) == 0 { + return true + } + for _, supported := range d.SupportedReports { + if supported == id { + return true + } + } + return false +} + +func (d ModuleDefinition) ValidateOptions(options any) error { + if options == nil { + return nil + } + if d.DefaultOptions == nil { + return fmt.Errorf("module %q does not accept options", d.ID) + } + want := reflect.TypeOf(d.DefaultOptions) + got := reflect.TypeOf(options) + if got == want { + return nil + } + if got.Kind() == reflect.Pointer && got.Elem() == want { + return nil + } + return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want) +} + +func defaultModuleDefinitions() []ModuleDefinition { + allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm} + daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend} + return []ModuleDefinition{ + { + ID: module.Metadata, + StanzaName: "metadata", + DefaultOptions: module.MetadataOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata}, + SupportedReports: allReports, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.CurrentConditions, + StanzaName: "current_conditions", + DefaultOptions: module.CurrentConditionsOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions}, + SupportedReports: allReports, + MissingData: module.MissingDataOmit, + }, + { + ID: module.DerivedDailySummary, + StanzaName: "derived_daily_summary", + DefaultOptions: module.DerivedDailySummaryOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow}, + MissingData: module.MissingDataError, + }, + { + ID: module.DerivedDaypartSummaries, + StanzaName: "derived_daypart_summaries", + DefaultOptions: module.DerivedDaypartSummariesOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + SupportedReports: daypartReports, + MissingData: module.MissingDataError, + }, + { + ID: module.HourlyTable, + StanzaName: "hourly_table", + DefaultOptions: module.HourlyTableOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods}, + SupportedReports: []report.ID{report.Storm}, + MissingData: module.MissingDataError, + }, + { + ID: module.PrecipTiming, + StanzaName: "precip_timing", + DefaultOptions: module.PrecipTimingOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + SupportedReports: allReports, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.AlertDigest, + StanzaName: "alert_digest", + DefaultOptions: module.AlertDigestOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedAlerts}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps}, + SupportedReports: allReports, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.AreaForecastDiscussion, + StanzaName: "area_forecast_discussion", + DefaultOptions: module.AreaForecastDiscussionOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedDiscussion}, + SupportedReports: allReports, + MissingData: module.MissingDataOmit, + }, + { + ID: module.WeatherStory, + StanzaName: "weather_story", + DefaultOptions: module.WeatherStoryOptions{}, + RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory}, + SupportedReports: allReports, + MissingData: module.MissingDataOmit, + }, + { + ID: module.ForecastDelta, + StanzaName: "forecast_delta", + DefaultOptions: module.ForecastDeltaOptions{}, + SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay}, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.OutdoorWindows, + StanzaName: "outdoor_windows", + DefaultOptions: module.OutdoorWindowsOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries}, + SupportedReports: daypartReports, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.TomorrowPlanning, + StanzaName: "tomorrow_planning", + DefaultOptions: module.TomorrowPlanningOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + SupportedReports: []report.ID{report.DailyTomorrow}, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.WeekendPlanning, + StanzaName: "weekend_planning", + DefaultOptions: module.WeekendPlanningOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries}, + SupportedReports: []report.ID{report.Weekend}, + MissingData: module.MissingDataEmpty, + }, + { + ID: module.StormWindowSummary, + StanzaName: "storm_window_summary", + DefaultOptions: module.StormWindowSummaryOptions{}, + RequiredDerived: []module.FactRequirement{module.RequiresDerivedStormWindowSummary}, + SupportedReports: []report.ID{report.Storm}, + MissingData: module.MissingDataError, + }, + } +} diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go new file mode 100644 index 0000000..282d54d --- /dev/null +++ b/internal/briefing/modules_test.go @@ -0,0 +1,76 @@ +package briefing + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) { + registry := MustDefaultModuleRegistry() + for _, definition := range report.DefaultRegistry().All() { + if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil { + t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err) + } + } +} + +func TestModuleRegistryRejectsUnknownModule(t *testing.T) { + registry := MustDefaultModuleRegistry() + err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}}) + if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) { + t.Fatalf("error = %v, want unknown module", err) + } +} + +func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) { + registry := MustDefaultModuleRegistry() + err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + {ID: module.Metadata}, + {ID: module.Metadata}, + }) + if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) { + t.Fatalf("error = %v, want duplicate module", err) + } +} + +func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) { + _, err := NewModuleRegistry([]ModuleDefinition{ + {ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}}, + {ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}}, + }) + if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) { + t.Fatalf("error = %v, want duplicate stanza name", err) + } +} + +func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) { + registry := MustDefaultModuleRegistry() + err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.StormWindowSummary}}) + if err == nil || !strings.Contains(err.Error(), `module "storm_window_summary" is not compatible with report "daily_today"`) { + t.Fatalf("error = %v, want incompatible report", err) + } +} + +func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) { + registry := MustDefaultModuleRegistry() + err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + {ID: module.Metadata, Options: module.CurrentConditionsOptions{}}, + }) + if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) { + t.Fatalf("error = %v, want invalid option shape", err) + } +} + +func TestModuleRegistryAcceptsTypedOptions(t *testing.T) { + registry := MustDefaultModuleRegistry() + err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{ + {ID: module.Metadata, Options: module.MetadataOptions{}}, + {ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}}, + }) + if err != nil { + t.Fatalf("ValidateComposition() error = %v", err) + } +} diff --git a/internal/module/module.go b/internal/module/module.go new file mode 100644 index 0000000..e8d1a6a --- /dev/null +++ b/internal/module/module.go @@ -0,0 +1,145 @@ +// Package module defines stable module contracts for prompt-facing stanzas. +package module + +import ( + "encoding/json" + "fmt" +) + +const SnapshotSchemaVersion = "weatherreporter.modules.v1" + +type ID string + +const ( + Metadata ID = "metadata" + CurrentConditions ID = "current_conditions" + DerivedDailySummary ID = "derived_daily_summary" + DerivedDaypartSummaries ID = "derived_daypart_summaries" + HourlyTable ID = "hourly_table" + PrecipTiming ID = "precip_timing" + AlertDigest ID = "alert_digest" + AreaForecastDiscussion ID = "area_forecast_discussion" + WeatherStory ID = "weather_story" + ForecastDelta ID = "forecast_delta" + OutdoorWindows ID = "outdoor_windows" + TomorrowPlanning ID = "tomorrow_planning" + WeekendPlanning ID = "weekend_planning" + StormWindowSummary ID = "storm_window_summary" +) + +type ConfigItem struct { + ID ID `json:"id"` + Options any `json:"options,omitempty"` +} + +type Output struct { + ID ID `json:"id"` + StanzaName string `json:"stanzaName"` + Value any `json:"value"` +} + +type Snapshot struct { + SchemaVersion string `json:"schemaVersion"` + Outputs []Output `json:"outputs"` +} + +func NewSnapshot(outputs []Output) (Snapshot, error) { + snapshot := Snapshot{ + SchemaVersion: SnapshotSchemaVersion, + Outputs: append([]Output(nil), outputs...), + } + if err := snapshot.Validate(); err != nil { + return Snapshot{}, err + } + return snapshot, nil +} + +func (s Snapshot) Validate() error { + if s.SchemaVersion == "" { + return fmt.Errorf("schemaVersion is required") + } + seenModules := map[ID]struct{}{} + seenStanzas := map[string]struct{}{} + for i, output := range s.Outputs { + if output.ID == "" { + return fmt.Errorf("outputs[%d].id is required", i) + } + if output.StanzaName == "" { + return fmt.Errorf("outputs[%d].stanzaName is required", i) + } + if _, ok := seenModules[output.ID]; ok { + return fmt.Errorf("duplicate module output %q", output.ID) + } + seenModules[output.ID] = struct{}{} + if _, ok := seenStanzas[output.StanzaName]; ok { + return fmt.Errorf("duplicate stanza name %q", output.StanzaName) + } + seenStanzas[output.StanzaName] = struct{}{} + } + return nil +} + +func (s Snapshot) LookupStanza(name string) (Output, bool) { + for _, output := range s.Outputs { + if output.StanzaName == name { + return output, true + } + } + return Output{}, false +} + +func StanzaValue[T any](s Snapshot, name string) (T, bool, error) { + var zero T + output, ok := s.LookupStanza(name) + if !ok { + return zero, false, nil + } + data, err := json.Marshal(output.Value) + if err != nil { + return zero, true, fmt.Errorf("marshal stanza %q: %w", name, err) + } + if err := json.Unmarshal(data, &zero); err != nil { + return zero, true, fmt.Errorf("decode stanza %q: %w", name, err) + } + return zero, true, nil +} + +type FactRequirement string + +const ( + CollectedCurrentConditions FactRequirement = "collected.current_conditions" + CollectedAlerts FactRequirement = "collected.alerts" + CollectedDiscussion FactRequirement = "collected.discussion" + CollectedWeatherStory FactRequirement = "collected.weather_story" + CollectedSourceMetadata FactRequirement = "collected.source_metadata" + RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods" + RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods" + RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps" + RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries" + RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries" + RequiresDerivedStormWindowSummary FactRequirement = "derived.storm_window_summary" +) + +type MissingDataBehavior string + +const ( + MissingDataOmit MissingDataBehavior = "omit" + MissingDataEmpty MissingDataBehavior = "empty" + MissingDataError MissingDataBehavior = "error" + MissingDataWarn MissingDataBehavior = "warn" +) + +type MetadataOptions struct{} +type CurrentConditionsOptions struct{} +type DerivedDailySummaryOptions struct{} +type DerivedDaypartSummariesOptions struct{} +type HourlyTableOptions struct{} +type PrecipTimingOptions struct{} +type AlertDigestOptions struct{} +type AreaForecastDiscussionOptions struct{} +type WeatherStoryOptions struct{} +type ForecastDeltaOptions struct{} +type OutdoorWindowsOptions struct{} +type TomorrowPlanningOptions struct{} +type WeekendPlanningOptions struct{} +type StormWindowSummaryOptions struct{} diff --git a/internal/module/module_test.go b/internal/module/module_test.go new file mode 100644 index 0000000..b35b1a4 --- /dev/null +++ b/internal/module/module_test.go @@ -0,0 +1,84 @@ +package module + +import ( + "encoding/json" + "strings" + "testing" +) + +type testStanza struct { + Message string `json:"message"` + Count int `json:"count"` +} + +func TestSnapshotPreservesOutputOrderAndJSON(t *testing.T) { + snapshot, err := NewSnapshot([]Output{ + {ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "first", Count: 1}}, + {ID: AlertDigest, StanzaName: "alert_digest", Value: testStanza{Message: "second", Count: 2}}, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + if snapshot.SchemaVersion != SnapshotSchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", snapshot.SchemaVersion, SnapshotSchemaVersion) + } + if snapshot.Outputs[0].ID != Metadata || snapshot.Outputs[1].ID != AlertDigest { + t.Fatalf("Outputs order = %#v, want input order", snapshot.Outputs) + } + + data, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + got := string(data) + want := `{"schemaVersion":"weatherreporter.modules.v1","outputs":[{"id":"metadata","stanzaName":"metadata","value":{"message":"first","count":1}},{"id":"alert_digest","stanzaName":"alert_digest","value":{"message":"second","count":2}}]}` + if got != want { + t.Fatalf("json = %s, want %s", got, want) + } +} + +func TestSnapshotRejectsDuplicateOutputs(t *testing.T) { + _, err := NewSnapshot([]Output{ + {ID: Metadata, StanzaName: "metadata", Value: struct{}{}}, + {ID: Metadata, StanzaName: "other_metadata", Value: struct{}{}}, + }) + if err == nil || !strings.Contains(err.Error(), `duplicate module output "metadata"`) { + t.Fatalf("duplicate module error = %v, want duplicate module output", err) + } + + _, err = NewSnapshot([]Output{ + {ID: Metadata, StanzaName: "metadata", Value: struct{}{}}, + {ID: CurrentConditions, StanzaName: "metadata", Value: struct{}{}}, + }) + if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) { + t.Fatalf("duplicate stanza error = %v, want duplicate stanza name", err) + } +} + +func TestStanzaValueDecodesTypedOutput(t *testing.T) { + snapshot, err := NewSnapshot([]Output{ + {ID: Metadata, StanzaName: "metadata", Value: testStanza{Message: "available", Count: 3}}, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + + value, found, err := StanzaValue[testStanza](snapshot, "metadata") + if err != nil { + t.Fatalf("StanzaValue() error = %v", err) + } + if !found { + t.Fatal("StanzaValue() found = false, want true") + } + if value.Message != "available" || value.Count != 3 { + t.Fatalf("StanzaValue() = %#v, want decoded stanza", value) + } + + _, found, err = StanzaValue[testStanza](snapshot, "missing") + if err != nil { + t.Fatalf("StanzaValue(missing) error = %v", err) + } + if found { + t.Fatal("StanzaValue(missing) found = true, want false") + } +} diff --git a/internal/report/definition.go b/internal/report/definition.go index 79171c8..b05e815 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) @@ -42,6 +43,7 @@ type Definition struct { BatchOutputName string Generated bool CompatiblePriorIDs []ID + Modules []module.ConfigItem Morning bool Evening bool resolve func(ResolveRequest) (timeutil.Period, error) @@ -63,6 +65,14 @@ func (d Definition) CompatibleWithPrior(id ID) bool { return false } +func (d Definition) ModuleIDs() []module.ID { + ids := make([]module.ID, 0, len(d.Modules)) + for _, item := range d.Modules { + ids = append(ids, item.ID) + } + return ids +} + type ResolveRequest struct { Now time.Time Location *time.Location diff --git a/internal/report/period_test.go b/internal/report/period_test.go index eef449c..aa98f9f 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) @@ -251,6 +252,99 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { } } +func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { + tests := []struct { + id ID + want []module.ID + }{ + { + id: DailyToday, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.ForecastDelta, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + }, + }, + { + id: DailyTomorrow, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.ForecastDelta, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + module.TomorrowPlanning, + }, + }, + { + id: ThreeDay, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.ForecastDelta, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + }, + }, + { + id: Weekend, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + module.WeekendPlanning, + }, + }, + { + id: Storm, + want: []module.ID{ + module.Metadata, + module.CurrentConditions, + module.HourlyTable, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.StormWindowSummary, + }, + }, + } + + registry := DefaultRegistry() + for _, tt := range tests { + t.Run(string(tt.id), func(t *testing.T) { + definition, err := registry.Lookup(tt.id) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + if !reflect.DeepEqual(definition.ModuleIDs(), tt.want) { + t.Fatalf("ModuleIDs() = %#v, want %#v", definition.ModuleIDs(), tt.want) + } + }) + } +} + func TestResolvedMetadata(t *testing.T) { location := mustLoadLocation(t) resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location}) diff --git a/internal/report/registry.go b/internal/report/registry.go index acb56af..386e428 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -1,6 +1,10 @@ package report -import "fmt" +import ( + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +) type Registry struct { definitions map[ID]Definition @@ -17,6 +21,7 @@ func DefaultRegistry() Registry { BatchOutputName: "daily.md", Generated: true, CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + Modules: dailyTodayModules(), Morning: true, resolve: resolveDailyToday, }, @@ -29,6 +34,7 @@ func DefaultRegistry() Registry { BatchOutputName: "tomorrow.md", Generated: true, CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + Modules: dailyTomorrowModules(), Evening: true, resolve: resolveDailyTomorrow, }, @@ -41,6 +47,7 @@ func DefaultRegistry() Registry { BatchOutputName: "three-day.md", Generated: true, CompatiblePriorIDs: []ID{ThreeDay}, + Modules: threeDayModules(), Morning: true, resolve: resolveThreeDay, }, @@ -53,6 +60,7 @@ func DefaultRegistry() Registry { BatchOutputName: "weekend.md", Generated: true, CompatiblePriorIDs: []ID{Weekend}, + Modules: weekendModules(), Morning: true, resolve: resolveWeekend, }, @@ -65,6 +73,7 @@ func DefaultRegistry() Registry { BatchOutputName: "storm.md", Generated: true, CompatiblePriorIDs: []ID{Storm}, + Modules: stormModules(), resolve: resolveStorm, }, } @@ -75,6 +84,76 @@ func DefaultRegistry() Registry { return registry } +func dailyTodayModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.ForecastDelta, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + ) +} + +func dailyTomorrowModules() []module.ConfigItem { + items := dailyTodayModules() + items = append(items, module.ConfigItem{ID: module.TomorrowPlanning}) + return items +} + +func threeDayModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.ForecastDelta, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + ) +} + +func weekendModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + module.WeekendPlanning, + ) +} + +func stormModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.HourlyTable, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.StormWindowSummary, + ) +} + +func moduleItems(ids ...module.ID) []module.ConfigItem { + items := make([]module.ConfigItem, 0, len(ids)) + for _, id := range ids { + items = append(items, module.ConfigItem{ID: id}) + } + return items +} + func (r Registry) Lookup(id ID) (Definition, error) { definition, ok := r.definitions[id] if !ok {