Make report module overrides explicit
This commit is contained in:
@@ -381,7 +381,11 @@ func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||||
registry, err := report.DefaultRegistry().WithModuleOverrides(cfg.ReportModuleOverrides())
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
return report.Registry{}, err
|
||||||
|
}
|
||||||
|
registry, err := report.DefaultRegistry().WithModuleOverrides(overrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Registry{}, err
|
return report.Registry{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1895,6 +1895,24 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateRejectsInvalidProgrammaticReportOverrides(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Reports = map[string]config.ReportConfig{
|
||||||
|
"moon": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportDaily,
|
||||||
|
}, mustParse("2026-05-29T05:00:00-05:00"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ResolveGenerate() error = nil, want report override error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reports.moon") {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %q, want report override context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func moduleIDsForTest(ids []module.ID) []string {
|
func moduleIDsForTest(ids []module.ID) []string {
|
||||||
out := make([]string, 0, len(ids))
|
out := make([]string, 0, len(ids))
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -91,7 +92,10 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
if len(cfg.Notify.Distributor.ReportPathTemplates) != 1 {
|
||||||
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
t.Fatalf("ReportPathTemplates = %#v, want example archive path", cfg.Notify.Distributor.ReportPathTemplates)
|
||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
hourly := overrides[report.Hourly]
|
hourly := overrides[report.Hourly]
|
||||||
if len(hourly) != 9 {
|
if len(hourly) != 9 {
|
||||||
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
|
t.Fatalf("hourly example override length = %d, want 9", len(hourly))
|
||||||
@@ -155,7 +159,10 @@ reports:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
items := overrides[report.DailyToday]
|
items := overrides[report.DailyToday]
|
||||||
if len(items) != 6 {
|
if len(items) != 6 {
|
||||||
t.Fatalf("daily override length = %d, want 6", len(items))
|
t.Fatalf("daily override length = %d, want 6", len(items))
|
||||||
@@ -192,7 +199,10 @@ reports:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
items := overrides[report.Hourly]
|
items := overrides[report.Hourly]
|
||||||
if len(items) != 4 {
|
if len(items) != 4 {
|
||||||
t.Fatalf("hourly override length = %d, want 4", len(items))
|
t.Fatalf("hourly override length = %d, want 4", len(items))
|
||||||
@@ -223,7 +233,10 @@ reports:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
overrides := cfg.ReportModuleOverrides()
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
|
if len(overrides[report.ThreeDay]) != 1 || overrides[report.ThreeDay][0].ID != module.Metadata {
|
||||||
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
|
t.Fatalf("three-day alias override = %#v, want metadata override", overrides[report.ThreeDay])
|
||||||
}
|
}
|
||||||
@@ -235,6 +248,84 @@ reports:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateReportModuleKeysWithoutMutatingOptions(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
rawOptions := map[string]any{
|
||||||
|
"sections": []any{"short_term"},
|
||||||
|
}
|
||||||
|
cfg.Reports = map[string]ReportConfig{
|
||||||
|
"daily": {
|
||||||
|
DeterministicModules: []ModuleConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.AreaForecastDiscussion, Options: rawOptions},
|
||||||
|
},
|
||||||
|
deterministicModulesSet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := cfg.Reports["daily"].DeterministicModules[1].Options.(map[string]any)
|
||||||
|
if !ok || !reflect.DeepEqual(got, rawOptions) {
|
||||||
|
t.Fatalf("Options after Validate = %#v, want original raw map", cfg.Reports["daily"].DeterministicModules[1].Options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reports map[string]ReportConfig
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "DuplicateAlias",
|
||||||
|
reports: map[string]ReportConfig{
|
||||||
|
"daily": {},
|
||||||
|
"daily_today": {},
|
||||||
|
},
|
||||||
|
wantErr: "duplicates report override",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UnknownReport",
|
||||||
|
reports: map[string]ReportConfig{
|
||||||
|
"moon": {},
|
||||||
|
},
|
||||||
|
wantErr: "reports.moon",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Reports = tt.reports
|
||||||
|
err := Validate(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want report key error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %q, want %q", err.Error(), tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverridesRejectsInvalidReportKeys(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Reports = map[string]ReportConfig{
|
||||||
|
"moon": {
|
||||||
|
DeterministicModules: []ModuleConfigItem{{ID: module.Metadata}},
|
||||||
|
deterministicModulesSet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := cfg.ReportModuleOverrides()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReportModuleOverrides() error = nil, want invalid report key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "reports.moon") {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %q, want report key context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -11,23 +11,24 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cfg Config) ReportModuleOverrides() map[report.ID][]module.ConfigItem {
|
func (cfg Config) ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error) {
|
||||||
overrides := map[report.ID][]module.ConfigItem{}
|
overrides := map[report.ID][]module.ConfigItem{}
|
||||||
|
seenReports := map[report.ID]string{}
|
||||||
for key, reportCfg := range cfg.Reports {
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
id, err := report.IDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if previous, ok := seenReports[id]; ok {
|
||||||
|
return nil, fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
|
}
|
||||||
|
seenReports[id] = key
|
||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesSet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id, err := report.IDForConfigKey(key)
|
overrides[id] = moduleItemsFromConfig(reportCfg.DeterministicModules)
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
|
||||||
for _, item := range reportCfg.DeterministicModules {
|
|
||||||
items = append(items, module.ConfigItem{ID: item.ID, Options: item.Options})
|
|
||||||
}
|
|
||||||
overrides[id] = items
|
|
||||||
}
|
}
|
||||||
return overrides
|
return overrides, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeReportModules(cfg *Config) error {
|
func normalizeReportModules(cfg *Config) error {
|
||||||
@@ -55,15 +56,14 @@ func normalizeReportModules(cfg *Config) error {
|
|||||||
if !reportCfg.deterministicModulesSet {
|
if !reportCfg.deterministicModulesSet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
|
||||||
for i, rawItem := range reportCfg.DeterministicModules {
|
for i, rawItem := range reportCfg.DeterministicModules {
|
||||||
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
options, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, rawItem.Options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
||||||
}
|
}
|
||||||
reportCfg.DeterministicModules[i].Options = options
|
reportCfg.DeterministicModules[i].Options = options
|
||||||
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
|
||||||
}
|
}
|
||||||
|
items := moduleItemsFromConfig(reportCfg.DeterministicModules)
|
||||||
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
||||||
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,58 @@ func normalizeReportModules(cfg *Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateReportModules(cfg Config) error {
|
||||||
|
if cfg.Reports == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
moduleRegistry, err := briefing.DefaultModuleRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize module registry: %w", err)
|
||||||
|
}
|
||||||
|
reportRegistry := report.DefaultRegistry()
|
||||||
|
seenReports := map[report.ID]string{}
|
||||||
|
for key, reportCfg := range cfg.Reports {
|
||||||
|
reportID, err := report.IDForConfigKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if previous, ok := seenReports[reportID]; ok {
|
||||||
|
return fmt.Errorf("reports.%s duplicates report override %q", key, previous)
|
||||||
|
}
|
||||||
|
seenReports[reportID] = key
|
||||||
|
if _, err := reportRegistry.Lookup(reportID); err != nil {
|
||||||
|
return fmt.Errorf("reports.%s: %w", key, err)
|
||||||
|
}
|
||||||
|
if !reportCfg.deterministicModulesSet {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items := make([]module.ConfigItem, 0, len(reportCfg.DeterministicModules))
|
||||||
|
for i, rawItem := range reportCfg.DeterministicModules {
|
||||||
|
options := rawItem.Options
|
||||||
|
if options != nil {
|
||||||
|
normalized, err := normalizeModuleOptions(moduleRegistry, rawItem.ID, options)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reports.%s.deterministic_modules[%d]: %w", key, i, err)
|
||||||
|
}
|
||||||
|
options = normalized
|
||||||
|
}
|
||||||
|
items = append(items, module.ConfigItem{ID: rawItem.ID, Options: options})
|
||||||
|
}
|
||||||
|
if err := moduleRegistry.ValidateComposition(reportID, items); err != nil {
|
||||||
|
return fmt.Errorf("reports.%s.deterministic_modules: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleItemsFromConfig(items []ModuleConfigItem) []module.ConfigItem {
|
||||||
|
out := make([]module.ConfigItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
out = append(out, module.ConfigItem{ID: item.ID, Options: item.Options})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw any) (any, error) {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -83,6 +135,9 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
|
|||||||
if definition.DefaultOptions == nil {
|
if definition.DefaultOptions == nil {
|
||||||
return nil, fmt.Errorf("module %q does not accept options", id)
|
return nil, fmt.Errorf("module %q does not accept options", id)
|
||||||
}
|
}
|
||||||
|
if err := definition.ValidateOptions(raw); err == nil {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
normalized, err := decodeKnownOptions(raw, optionType)
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Validate(cfg Config) error {
|
func Validate(cfg Config) error {
|
||||||
if err := normalizeReportModules(&cfg); err != nil {
|
if err := validateReportModules(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.BaseURL != "" {
|
if cfg.WeatherAPI.BaseURL != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user