Validate fact requirements through briefing vocabulary

This commit is contained in:
2026-08-13 04:02:21 +00:00
parent 2c4c0bbd90
commit 71b7a74d3d
4 changed files with 167 additions and 93 deletions

View File

@@ -10,7 +10,10 @@ snapshot, construct YAML, invoke Promptkit, or render a report.
Every `ModuleDefinition` declares an ID, stanza name, default option value, Every `ModuleDefinition` declares an ID, stanza name, default option value,
required collected and derived facts, supported report IDs, missing-data required collected and derived facts, supported report IDs, missing-data
behavior, duplicate policy, builder, and optional prompt exporter. behavior, duplicate policy, builder, and optional prompt exporter. The
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
identity, category, and availability predicate; registry construction rejects
unknown requirements and requirements listed under the wrong category.
`BuildModule` first verifies the requested module, report compatibility, and `BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior: option shape. It then applies the declared missing-data behavior:

View File

@@ -10,6 +10,7 @@ const (
type factRequirement struct { type factRequirement struct {
identity string identity string
category factRequirementCategory category factRequirementCategory
available func(ModuleContext) bool
} }
func (r factRequirement) String() string { func (r factRequirement) String() string {
@@ -17,25 +18,55 @@ func (r factRequirement) String() string {
} }
var ( var (
currentConditionsRequirement = factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement} currentConditionsRequirement = &factRequirement{identity: "collected.current_conditions", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
narrativeForecastRequirement = factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement} return ctx.Collected.Current != nil
hourlyForecastRequirement = factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement} }}
alertsRequirement = factRequirement{identity: "collected.alerts", category: collectedFactRequirement} narrativeForecastRequirement = &factRequirement{identity: "collected.narrative_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
discussionRequirement = factRequirement{identity: "collected.discussion", category: collectedFactRequirement} return ctx.Collected.Narrative != nil
weatherStoryRequirement = factRequirement{identity: "collected.weather_story", category: collectedFactRequirement} }}
spcOutlooksRequirement = factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement} hourlyForecastRequirement = &factRequirement{identity: "collected.hourly_forecast", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
sourceMetadataRequirement = factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement} return ctx.Collected.Hourly != nil
}}
alertsRequirement = &factRequirement{identity: "collected.alerts", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Alerts != nil
}}
discussionRequirement = &factRequirement{identity: "collected.discussion", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.Discussion != nil
}}
weatherStoryRequirement = &factRequirement{identity: "collected.weather_story", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.WeatherStory != nil
}}
spcOutlooksRequirement = &factRequirement{identity: "collected.spc_convective_outlooks", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Collected.SPCConvectiveOutlooks != nil
}}
sourceMetadataRequirement = &factRequirement{identity: "collected.source_metadata", category: collectedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
}}
hourlyPeriodsRequirement = factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement} hourlyPeriodsRequirement = &factRequirement{identity: "derived.hourly_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
narrativePeriodsRequirement = factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement} return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
alertOverlapsRequirement = factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement} }}
dailySummariesRequirement = factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement} narrativePeriodsRequirement = &factRequirement{identity: "derived.narrative_periods", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
daypartSummariesRequirement = factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement} return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
precipTimingRequirement = factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement} }}
spcDerivedOutlooksRequirement = factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement} alertOverlapsRequirement = &factRequirement{identity: "derived.alert_overlaps", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
dailySummariesRequirement = &factRequirement{identity: "derived.daily_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DailySummaries) > 0
}}
daypartSummariesRequirement = &factRequirement{identity: "derived.daypart_summaries", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return len(ctx.Derived.DaypartSummaries) > 0
}}
precipTimingRequirement = &factRequirement{identity: "derived.precip_timing", category: derivedFactRequirement, available: func(ModuleContext) bool {
return true
}}
spcDerivedOutlooksRequirement = &factRequirement{identity: "derived.spc_convective_outlooks", category: derivedFactRequirement, available: func(ctx ModuleContext) bool {
return ctx.Derived.SPCConvectiveOutlooks != nil
}}
) )
var factRequirementVocabulary = []factRequirement{ var factRequirementVocabulary = []*factRequirement{
currentConditionsRequirement, currentConditionsRequirement,
narrativeForecastRequirement, narrativeForecastRequirement,
hourlyForecastRequirement, hourlyForecastRequirement,

View File

@@ -28,8 +28,8 @@ type ModuleDefinition struct {
ID module.ID ID module.ID
StanzaName string StanzaName string
DefaultOptions any DefaultOptions any
RequiredCollected []factRequirement RequiredCollected []*factRequirement
RequiredDerived []factRequirement RequiredDerived []*factRequirement
SupportedReports []report.ID SupportedReports []report.ID
MissingData module.MissingDataBehavior MissingData module.MissingDataBehavior
AllowDuplicate bool AllowDuplicate bool
@@ -76,6 +76,9 @@ func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
if definition.MissingData == module.MissingDataWarn { if definition.MissingData == module.MissingDataWarn {
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData) return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
} }
if err := validateFactRequirements(definition); err != nil {
return ModuleRegistry{}, err
}
if existingID, ok := seenStanzas[definition.StanzaName]; ok { 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) return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
} }
@@ -153,60 +156,48 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string { func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
var missing []string var missing []string
for _, requirement := range definition.RequiredCollected { for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) { if !requirement.available(ctx) {
missing = append(missing, requirement.String()) missing = append(missing, requirement.String())
} }
} }
for _, requirement := range definition.RequiredDerived { for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) { if !requirement.available(ctx) {
missing = append(missing, requirement.String()) missing = append(missing, requirement.String())
} }
} }
return missing return missing
} }
func collectedFactAvailable(requirement factRequirement, ctx ModuleContext) bool { func validateFactRequirements(definition ModuleDefinition) error {
switch requirement { if err := validateFactRequirementCategory(definition.ID, definition.RequiredCollected, collectedFactRequirement); err != nil {
case currentConditionsRequirement: return err
return ctx.Collected.Current != nil
case narrativeForecastRequirement:
return ctx.Collected.Narrative != nil
case hourlyForecastRequirement:
return ctx.Collected.Hourly != nil
case alertsRequirement:
return ctx.Collected.Alerts != nil
case discussionRequirement:
return ctx.Collected.Discussion != nil
case weatherStoryRequirement:
return ctx.Collected.WeatherStory != nil
case spcOutlooksRequirement:
return ctx.Collected.SPCConvectiveOutlooks != nil
case sourceMetadataRequirement:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
} }
return validateFactRequirementCategory(definition.ID, definition.RequiredDerived, derivedFactRequirement)
} }
func derivedFactAvailable(requirement factRequirement, ctx ModuleContext) bool { func validateFactRequirementCategory(moduleID module.ID, requirements []*factRequirement, want factRequirementCategory) error {
switch requirement { for _, requirement := range requirements {
case hourlyPeriodsRequirement: if requirement == nil {
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0 return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, "")
case narrativePeriodsRequirement:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case alertOverlapsRequirement:
return true
case dailySummariesRequirement:
return len(ctx.Derived.DailySummaries) > 0
case daypartSummariesRequirement:
return len(ctx.Derived.DaypartSummaries) > 0
case precipTimingRequirement:
return true
case spcDerivedOutlooksRequirement:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
} }
descriptor, ok := lookupFactRequirement(requirement.identity)
if !ok || descriptor != requirement {
return fmt.Errorf("module %q uses unknown %s fact requirement %q", moduleID, want, requirement.identity)
}
if descriptor.category != want {
return fmt.Errorf("module %q lists %s fact requirement %q as %s", moduleID, descriptor.category, descriptor.identity, want)
}
}
return nil
}
func lookupFactRequirement(identity string) (*factRequirement, bool) {
for _, requirement := range factRequirementVocabulary {
if requirement.identity == identity {
return requirement, true
}
}
return nil, false
} }
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error { func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
@@ -291,7 +282,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.Metadata, ID: module.Metadata,
StanzaName: "metadata", StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{}, DefaultOptions: module.MetadataOptions{},
RequiredCollected: []factRequirement{sourceMetadataRequirement}, RequiredCollected: []*factRequirement{sourceMetadataRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule, Builder: buildMetadataModule,
@@ -300,7 +291,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.CurrentConditions, ID: module.CurrentConditions,
StanzaName: "current_conditions", StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{}, DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []factRequirement{currentConditionsRequirement}, RequiredCollected: []*factRequirement{currentConditionsRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule, Builder: buildCurrentConditionsModule,
@@ -310,8 +301,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.NarrativeForecast, ID: module.NarrativeForecast,
StanzaName: "narrative_forecast", StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{}, DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []factRequirement{narrativeForecastRequirement}, RequiredCollected: []*factRequirement{narrativeForecastRequirement},
RequiredDerived: []factRequirement{narrativePeriodsRequirement}, RequiredDerived: []*factRequirement{narrativePeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule, Builder: buildNarrativeForecastModule,
@@ -320,8 +311,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.HourlyForecast, ID: module.HourlyForecast,
StanzaName: "hourly_forecast", StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{}, DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []factRequirement{hourlyForecastRequirement}, RequiredCollected: []*factRequirement{hourlyForecastRequirement},
RequiredDerived: []factRequirement{hourlyPeriodsRequirement}, RequiredDerived: []*factRequirement{hourlyPeriodsRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule, Builder: buildHourlyForecastModule,
@@ -331,7 +322,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDailySummary, ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary", StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{}, DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement, precipTimingRequirement}, RequiredDerived: []*factRequirement{dailySummariesRequirement, precipTimingRequirement},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow}, SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule, Builder: buildDerivedDailySummaryModule,
@@ -340,7 +331,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DerivedDaypartSummaries, ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries", StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{}, DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []factRequirement{daypartSummariesRequirement}, RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports, SupportedReports: daypartReports,
MissingData: module.MissingDataError, MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule, Builder: buildDerivedDaypartSummariesModule,
@@ -350,7 +341,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.PrecipTiming, ID: module.PrecipTiming,
StanzaName: "precip_timing", StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{}, DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []factRequirement{precipTimingRequirement}, RequiredDerived: []*factRequirement{precipTimingRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule, Builder: buildPrecipTimingModule,
@@ -359,8 +350,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AlertDigest, ID: module.AlertDigest,
StanzaName: "alert_digest", StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{}, DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []factRequirement{alertsRequirement}, RequiredCollected: []*factRequirement{alertsRequirement},
RequiredDerived: []factRequirement{alertOverlapsRequirement}, RequiredDerived: []*factRequirement{alertOverlapsRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule, Builder: buildAlertDigestModule,
@@ -369,8 +360,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveOutlooks, ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks), StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{}, DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []factRequirement{spcOutlooksRequirement}, RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement}, RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule, Builder: buildSPCConvectiveOutlooksModule,
@@ -379,7 +370,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.AreaForecastDiscussion, ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion", StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{}, DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []factRequirement{discussionRequirement}, RequiredCollected: []*factRequirement{discussionRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule, Builder: buildAreaForecastDiscussionModule,
@@ -388,8 +379,8 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.SPCConvectiveDiscussion, ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion), StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{}, DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []factRequirement{spcOutlooksRequirement}, RequiredCollected: []*factRequirement{spcOutlooksRequirement},
RequiredDerived: []factRequirement{spcDerivedOutlooksRequirement}, RequiredDerived: []*factRequirement{spcDerivedOutlooksRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule, Builder: buildSPCConvectiveDiscussionModule,
@@ -398,7 +389,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.WeatherStory, ID: module.WeatherStory,
StanzaName: "weather_story", StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{}, DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []factRequirement{weatherStoryRequirement}, RequiredCollected: []*factRequirement{weatherStoryRequirement},
SupportedReports: allReports, SupportedReports: allReports,
MissingData: module.MissingDataOmit, MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule, Builder: buildWeatherStoryModule,
@@ -407,7 +398,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.OutdoorWindows, ID: module.OutdoorWindows,
StanzaName: "outdoor_windows", StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{}, DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []factRequirement{daypartSummariesRequirement}, RequiredDerived: []*factRequirement{daypartSummariesRequirement},
SupportedReports: daypartReports, SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule, Builder: buildOutdoorWindowsModule,
@@ -416,7 +407,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TodayPlanning, ID: module.TodayPlanning,
StanzaName: "today_planning", StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{}, DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement}, RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Today}, SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule, Builder: buildTodayPlanningModule,
@@ -425,7 +416,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.TomorrowPlanning, ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning", StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{}, DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement}, RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Tomorrow}, SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule, Builder: buildTomorrowPlanningModule,
@@ -434,7 +425,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
ID: module.DailyPlanning, ID: module.DailyPlanning,
StanzaName: "daily_planning", StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{}, DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []factRequirement{dailySummariesRequirement}, RequiredDerived: []*factRequirement{dailySummariesRequirement},
SupportedReports: []report.ID{report.Daily}, SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty, MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule, Builder: buildDailyPlanningModule,

View File

@@ -31,7 +31,7 @@ func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
} }
func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) { func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
vocabulary := make(map[string]factRequirement, len(factRequirementVocabulary)) vocabulary := make(map[string]*factRequirement, len(factRequirementVocabulary))
for _, requirement := range factRequirementVocabulary { for _, requirement := range factRequirementVocabulary {
if requirement.identity == "" { if requirement.identity == "" {
t.Fatal("fact requirement identity is empty") t.Fatal("fact requirement identity is empty")
@@ -42,10 +42,13 @@ func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
if _, exists := vocabulary[requirement.identity]; exists { if _, exists := vocabulary[requirement.identity]; exists {
t.Fatalf("duplicate fact requirement %q", requirement.identity) t.Fatalf("duplicate fact requirement %q", requirement.identity)
} }
if requirement.available == nil {
t.Fatalf("fact requirement %q has no availability predicate", requirement.identity)
}
vocabulary[requirement.identity] = requirement vocabulary[requirement.identity] = requirement
} }
used := map[factRequirement]struct{}{} used := map[*factRequirement]struct{}{}
for _, definition := range defaultModuleDefinitions() { for _, definition := range defaultModuleDefinitions() {
for _, requirement := range definition.RequiredCollected { for _, requirement := range definition.RequiredCollected {
assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement) assertFactRequirementCategory(t, vocabulary, used, definition.ID, requirement, collectedFactRequirement)
@@ -61,7 +64,7 @@ func TestFactRequirementVocabularyMatchesModuleDefinitions(t *testing.T) {
} }
} }
func assertFactRequirementCategory(t *testing.T, vocabulary map[string]factRequirement, used map[factRequirement]struct{}, moduleID module.ID, requirement factRequirement, want factRequirementCategory) { func assertFactRequirementCategory(t *testing.T, vocabulary map[string]*factRequirement, used map[*factRequirement]struct{}, moduleID module.ID, requirement *factRequirement, want factRequirementCategory) {
t.Helper() t.Helper()
descriptor, ok := vocabulary[requirement.identity] descriptor, ok := vocabulary[requirement.identity]
if !ok { if !ok {
@@ -480,6 +483,52 @@ func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
} }
} }
func TestModuleRegistryRejectsInvalidFactRequirements(t *testing.T) {
tests := []struct {
name string
configure func(*ModuleDefinition)
wantErr string
}{
{
name: "Unknown",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{{identity: "collected.unknown", category: collectedFactRequirement}}
},
wantErr: `module "metadata" uses unknown collected fact requirement "collected.unknown"`,
},
{
name: "DerivedListedAsCollected",
configure: func(definition *ModuleDefinition) {
definition.RequiredCollected = []*factRequirement{dailySummariesRequirement}
},
wantErr: `module "metadata" lists derived fact requirement "derived.daily_summaries" as collected`,
},
{
name: "CollectedListedAsDerived",
configure: func(definition *ModuleDefinition) {
definition.RequiredDerived = []*factRequirement{currentConditionsRequirement}
},
wantErr: `module "metadata" lists collected fact requirement "collected.current_conditions" as derived`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := ModuleDefinition{
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
Builder: noopModuleBuilder,
}
tt.configure(&definition)
_, err := NewModuleRegistry([]ModuleDefinition{definition})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) { func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{ _, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder}, {ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
@@ -513,25 +562,25 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) { func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{} ctx := ModuleContext{}
if collectedFactAvailable(spcOutlooksRequirement, ctx) { if spcOutlooksRequirement.available(ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source") t.Fatal("SPC outlook requirement is available, want false without source")
} }
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}} ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(spcOutlooksRequirement, ctx) { if !spcOutlooksRequirement.available(ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source") t.Fatal("SPC outlook requirement is unavailable, want true with checked source")
} }
} }
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) { func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{} ctx := ModuleContext{}
if derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) { if spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks") t.Fatal("derived SPC outlook requirement is available, want false without derived outlooks")
} }
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}} ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(spcDerivedOutlooksRequirement, ctx) { if !spcDerivedOutlooksRequirement.available(ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks") t.Fatal("derived SPC outlook requirement is unavailable, want true for checked empty derived outlooks")
} }
} }