Add module contracts and registry validation

This commit is contained in:
2026-06-09 20:30:49 +00:00
parent e9508089ab
commit 24dba3bd60
11 changed files with 802 additions and 3 deletions

View File

@@ -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

58
docs/internal/module.md Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,
},
}
}

View File

@@ -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)
}
}

145
internal/module/module.go Normal file
View File

@@ -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{}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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})

View File

@@ -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 {