Add module contracts and registry validation
This commit is contained in:
145
internal/module/module.go
Normal file
145
internal/module/module.go
Normal 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{}
|
||||
84
internal/module/module_test.go
Normal file
84
internal/module/module_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user