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 TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) { vocabulary := make(map[string]factRequirement, len(factRequirementVocabulary)) for _, requirement := range factRequirementVocabulary { if requirement.identity == "" { t.Fatal("fact requirement identity is empty") } if requirement.category != collectedFactRequirement && requirement.category != derivedFactRequirement { t.Fatalf("fact requirement %q category = %q, want collected or derived", requirement.identity, requirement.category) } if _, exists := vocabulary[requirement.identity]; exists { t.Fatalf("duplicate fact requirement %q", requirement.identity) } vocabulary[requirement.identity] = requirement } used := map[factRequirement]struct{}{} for _, definition := range defaultModuleDefinitions() { for _, requirement := range definition.RequiredCollected { assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement) } for _, requirement := range definition.RequiredDerived { assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, derivedFactRequirement) } } for _, requirement := range factRequirementVocabulary { if _, ok := used[requirement]; !ok { t.Fatalf("fact requirement %q is not used by a module definition", requirement.identity) } } } func assertFactRequirementCategory(t *testing.T, vocabulary map[string]factRequirement, used map[factRequirement]struct{}, moduleID module.ID, requirement factRequirement, want factRequirementCategory) { t.Helper() descriptor, ok := vocabulary[requirement.identity] if !ok { t.Fatalf("module %q uses unknown fact requirement %q", moduleID, requirement.identity) } if descriptor != requirement { t.Fatalf("module %q requirement %q does not use the vocabulary descriptor", moduleID, requirement.identity) } if requirement.category != want { t.Fatalf("module %q requirement %q category = %q, want %q", moduleID, requirement.identity, requirement.category, want) } used[requirement] = struct{}{} } 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} { 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{}}, {ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}}, }) if err != nil { t.Fatalf("ValidateComposition() error = %v", err) } } func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) { ctx := ModuleContext{} if collectedFactAvailable(spcOutlooksRequirement, ctx) { t.Fatal("collectedFactAvailable() = true, want false without source") } ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}} if !collectedFactAvailable(spcOutlooksRequirement, ctx) { t.Fatal("collectedFactAvailable() = false, want true with checked source") } } func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) { ctx := ModuleContext{} if derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) { t.Fatal("derivedFactAvailable() = true, want false without derived outlooks") } ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}} if !derivedFactAvailable(spcDerivedOutlooksRequirement, 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), }, } }