506 lines
18 KiB
Go
506 lines
18 KiB
Go
package briefing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
|
)
|
|
|
|
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)
|
|
}
|
|
for _, item := range definition.Modules {
|
|
moduleDefinition, err := registry.Lookup(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
|
|
}
|
|
if moduleDefinition.Builder == nil {
|
|
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
for _, definition := range report.DefaultRegistry().All() {
|
|
t.Run(string(definition.ID), func(t *testing.T) {
|
|
ctx := derivedModuleContext(definition.ID)
|
|
var outputs []module.Output
|
|
for _, item := range definition.Modules {
|
|
output, err := registry.BuildModule(ctx, item)
|
|
if err != nil {
|
|
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
|
|
}
|
|
if output != nil {
|
|
if output.DataPackageValue() == nil {
|
|
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
|
|
}
|
|
outputs = append(outputs, *output)
|
|
}
|
|
}
|
|
snapshot, err := module.NewSnapshot(outputs)
|
|
if err != nil {
|
|
t.Fatalf("NewSnapshot() error = %v", err)
|
|
}
|
|
if len(snapshot.Outputs) == 0 {
|
|
t.Fatal("snapshot outputs = 0, want default report modules")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
|
|
customExporters := map[module.ID]struct{}{
|
|
module.CurrentConditions: {},
|
|
module.HourlyForecast: {},
|
|
module.DerivedDaypartSummaries: {},
|
|
}
|
|
passThroughExporters := map[module.ID]struct{}{
|
|
module.Metadata: {},
|
|
module.NarrativeForecast: {},
|
|
module.DerivedDailySummary: {},
|
|
module.PrecipTiming: {},
|
|
module.AlertDigest: {},
|
|
module.SPCConvectiveOutlooks: {},
|
|
module.AreaForecastDiscussion: {},
|
|
module.SPCConvectiveDiscussion: {},
|
|
module.WeatherStory: {},
|
|
module.OutdoorWindows: {},
|
|
module.TodayPlanning: {},
|
|
module.TomorrowPlanning: {},
|
|
module.DailyPlanning: {},
|
|
}
|
|
|
|
for _, definition := range defaultModuleDefinitions() {
|
|
_, custom := customExporters[definition.ID]
|
|
_, passThrough := passThroughExporters[definition.ID]
|
|
if custom == passThrough {
|
|
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
|
|
}
|
|
if custom && definition.PromptExporter == nil {
|
|
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
|
|
}
|
|
if passThrough && definition.PromptExporter != nil {
|
|
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
|
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Value: testRegistryValue{Message: "rich"},
|
|
}, nil
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
|
}
|
|
|
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule() error = %v", err)
|
|
}
|
|
if output == nil {
|
|
t.Fatal("BuildModule() output = nil, want output")
|
|
}
|
|
if output.PromptValue != output.Value {
|
|
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
|
|
}
|
|
if output.DataPackageValue() != output.Value {
|
|
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
|
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Value: testRegistryValue{Message: "rich"},
|
|
}, nil
|
|
},
|
|
PromptExporter: func(value any) (any, error) {
|
|
rich, ok := value.(testRegistryValue)
|
|
if !ok {
|
|
return nil, errors.New("unexpected rich value type")
|
|
}
|
|
return testRegistryValue{Message: rich.Message + " prompt"}, nil
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
|
}
|
|
|
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule() error = %v", err)
|
|
}
|
|
got, ok := output.PromptValue.(testRegistryValue)
|
|
if !ok {
|
|
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
|
|
}
|
|
if got.Message != "rich prompt" {
|
|
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
|
|
}
|
|
if output.DataPackageValue() != output.PromptValue {
|
|
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
|
|
}
|
|
if output.Value.(testRegistryValue).Message != "rich" {
|
|
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
|
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
|
},
|
|
PromptExporter: func(any) (any, error) {
|
|
return nil, errors.New("unsupported value")
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
|
}
|
|
|
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
|
if err == nil ||
|
|
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
|
|
!strings.Contains(err.Error(), "unsupported value") {
|
|
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
|
|
called := false
|
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
|
},
|
|
PromptExporter: func(any) (any, error) {
|
|
called = true
|
|
return testRegistryValue{Message: "prompt"}, nil
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
|
}
|
|
|
|
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
|
|
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
|
|
}
|
|
if called {
|
|
t.Fatal("PromptExporter called before output validation")
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
|
|
registry, err := NewModuleRegistry([]ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
Builder: func(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
|
|
},
|
|
PromptExporter: func(any) (any, error) {
|
|
return testRegistryValue{Message: "prompt-only"}, nil
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewModuleRegistry() error = %v", err)
|
|
}
|
|
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
|
|
if err != nil {
|
|
t.Fatalf("BuildModule() error = %v", err)
|
|
}
|
|
snapshot, err := module.NewSnapshot([]module.Output{*output})
|
|
if err != nil {
|
|
t.Fatalf("NewSnapshot() error = %v", err)
|
|
}
|
|
data, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
text := string(data)
|
|
if !strings.Contains(text, `"message":"rich"`) {
|
|
t.Fatalf("snapshot JSON missing rich value: %s", text)
|
|
}
|
|
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
|
|
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
|
|
tests := []struct {
|
|
id report.ID
|
|
wantSections string
|
|
}{
|
|
{id: report.Daily, wantSections: "long_term"},
|
|
{id: report.Hourly, wantSections: "key_messages,short_term"},
|
|
}
|
|
|
|
registry := report.DefaultRegistry()
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.id), func(t *testing.T) {
|
|
definition := registry.MustLookup(tt.id)
|
|
var found bool
|
|
for _, item := range definition.Modules {
|
|
if item.ID != module.AreaForecastDiscussion {
|
|
continue
|
|
}
|
|
found = true
|
|
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
|
if !ok {
|
|
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
|
}
|
|
if strings.Join(options.Sections, ",") != tt.wantSections {
|
|
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("default modules missing area_forecast_discussion")
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, id := range []report.ID{report.Today, report.Tomorrow} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
definition := registry.MustLookup(id)
|
|
var found bool
|
|
for _, item := range definition.Modules {
|
|
if item.ID != module.AreaForecastDiscussion {
|
|
continue
|
|
}
|
|
found = true
|
|
if item.Options != nil {
|
|
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("default modules missing area_forecast_discussion")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
err := registry.ValidateComposition(report.Daily, []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.Daily, []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{}, Builder: noopModuleBuilder},
|
|
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
|
|
})
|
|
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.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
|
|
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
|
|
t.Fatalf("error = %v, want incompatible report", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
|
|
t.Fatalf("ValidateComposition(today) error = %v", err)
|
|
}
|
|
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
|
|
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
|
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
|
|
t.Fatalf("ValidateComposition(daily) error = %v", err)
|
|
}
|
|
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
|
|
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
|
|
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
|
|
{ID: module.Metadata},
|
|
{ID: module.CurrentConditions},
|
|
{ID: module.NarrativeForecast},
|
|
{ID: module.HourlyForecast},
|
|
{ID: module.DerivedDailySummary},
|
|
{ID: module.DerivedDaypartSummaries},
|
|
{ID: module.PrecipTiming},
|
|
{ID: module.AlertDigest},
|
|
{ID: module.SPCConvectiveOutlooks},
|
|
{ID: module.AreaForecastDiscussion},
|
|
{ID: module.SPCConvectiveDiscussion},
|
|
{ID: module.WeatherStory},
|
|
{ID: module.OutdoorWindows},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
for _, id := range []module.ID{
|
|
module.NarrativeForecast,
|
|
module.DerivedDailySummary,
|
|
module.DerivedDaypartSummaries,
|
|
module.OutdoorWindows,
|
|
module.TodayPlanning,
|
|
module.TomorrowPlanning,
|
|
module.DailyPlanning,
|
|
} {
|
|
t.Run(string(id), func(t *testing.T) {
|
|
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
|
|
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
|
|
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
|
|
t.Fatalf("error = %v, want missing builder", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
|
|
_, err := NewModuleRegistry([]ModuleDefinition{
|
|
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
|
|
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
|
|
registry := MustDefaultModuleRegistry()
|
|
err := registry.ValidateComposition(report.Daily, []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.Daily, []module.ConfigItem{
|
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ValidateComposition() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
|
|
ctx := ModuleContext{}
|
|
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
|
t.Fatal("collectedFactAvailable() = true, want false without source")
|
|
}
|
|
|
|
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
|
|
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
|
|
t.Fatal("collectedFactAvailable() = false, want true with checked source")
|
|
}
|
|
}
|
|
|
|
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
|
|
ctx := ModuleContext{}
|
|
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
|
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
|
|
}
|
|
|
|
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
|
|
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
|
|
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
|
|
}
|
|
}
|
|
|
|
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
|
|
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
|
|
}
|
|
|
|
type testRegistryValue struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func testRegistryModuleContext() ModuleContext {
|
|
return ModuleContext{
|
|
Resolved: report.Resolved{
|
|
Definition: report.DefaultRegistry().MustLookup(report.Daily),
|
|
},
|
|
}
|
|
}
|