Normalize briefing module options and weather stories
This commit is contained in:
@@ -39,14 +39,18 @@ missing sources, and prompt-safe SPC values omit geometry and other
|
|||||||
template-only or source details. The complete module composition is in
|
template-only or source details. The complete module composition is in
|
||||||
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
|
||||||
|
|
||||||
`area_forecast_discussion` accepts an optional typed section filter. Planning
|
`area_forecast_discussion` accepts an optional typed section filter. Accepted
|
||||||
modules are report-specific: `daily_planning` supports Daily,
|
typed option pointers are normalized to the declared value type before builder
|
||||||
|
execution. Planning modules are report-specific: `daily_planning` supports Daily,
|
||||||
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
|
||||||
|
|
||||||
## Missing data and boundaries
|
## Missing data and boundaries
|
||||||
|
|
||||||
Optional current conditions, narrative products, discussions, and weather
|
Optional current conditions, narrative products, discussions, and weather
|
||||||
stories may be omitted. Required derived modules fail when their declared facts
|
stories may be omitted. A weather story is usable only when it has non-blank
|
||||||
|
displayable content (title, description, alternate text, or download URL) or a
|
||||||
|
valid start/end period; otherwise collection applies its optional-source policy
|
||||||
|
and the module is omitted. Required derived modules fail when their declared facts
|
||||||
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
are unavailable. Empty alert and outlook runs can still produce checked-empty
|
||||||
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
modules. SPC discussion is omitted unless a retained categorical outlook meets
|
||||||
the package's severity criterion and matching discussion text exists.
|
the package's severity criterion and matching discussion text exists.
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ The registry declares these ordered default compositions:
|
|||||||
The only non-empty default option is the AFD section selection. It accepts a
|
The only non-empty default option is the AFD section selection. It accepts a
|
||||||
`sections` list; omitted or empty selects all available sections. Report
|
`sections` list; omitted or empty selects all available sections. Report
|
||||||
definitions may narrow it as shown above. Option shape and report compatibility
|
definitions may narrow it as shown above. Option shape and report compatibility
|
||||||
are validated by the briefing registry.
|
are validated by the briefing registry. Accepted typed option pointers are
|
||||||
|
canonicalized to the declared value type before a module builder receives them.
|
||||||
|
|
||||||
## Rich and prompt-facing values
|
## Rich and prompt-facing values
|
||||||
|
|
||||||
|
|||||||
@@ -305,16 +305,20 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
|||||||
|
|
||||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||||
var story weatherdata.WeatherStory
|
var story weatherdata.WeatherStory
|
||||||
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
|
request := sourceRequest{
|
||||||
name: config.MissingSourceWeatherStory,
|
name: config.MissingSourceWeatherStory,
|
||||||
endpoint: "/weatherstories/latest",
|
endpoint: "/weatherstories/latest",
|
||||||
query: queryOptions{omitUnits: true},
|
query: queryOptions{omitUnits: true},
|
||||||
missingMessage: "NWS weather story data is missing",
|
missingMessage: "NWS weather story data is missing",
|
||||||
}, &story)
|
}
|
||||||
|
fetched, ok, err := b.fetchDecodedSource(ctx, request, &story)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
source := fetched.source
|
source := fetched.source
|
||||||
|
if !story.HasUsableContent() {
|
||||||
|
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), request)
|
||||||
|
}
|
||||||
if !story.StartTime.IsZero() {
|
if !story.StartTime.IsZero() {
|
||||||
source.IssuedAt = &story.StartTime
|
source.IssuedAt = &story.StartTime
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,6 +768,44 @@ func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmptyWeatherStoryUsesPolicy(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
policy config.MissingSourcePolicy
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "warn", policy: config.MissingSourceWarn},
|
||||||
|
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||||
|
"weather_story": tt.policy,
|
||||||
|
})
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") {
|
||||||
|
t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.WeatherStory != nil {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory)
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||||
|
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||||
|
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextCancellation(t *testing.T) {
|
func TestContextCancellation(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
|
|||||||
@@ -462,19 +462,58 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
|||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := testModuleContext()
|
ctx := testModuleContext()
|
||||||
|
|
||||||
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
for _, tt := range []struct {
|
||||||
ID: module.AreaForecastDiscussion,
|
name string
|
||||||
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
|
options any
|
||||||
})
|
}{
|
||||||
|
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{
|
||||||
|
ID: module.AreaForecastDiscussion,
|
||||||
|
Options: tt.options,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||||
|
if afd.ShortTerm != "Showers increase this afternoon." {
|
||||||
|
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
||||||
|
}
|
||||||
|
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
||||||
|
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildModule() error = %v", err)
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
}
|
}
|
||||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
if output != nil {
|
||||||
if afd.ShortTerm != "Showers increase this afternoon." {
|
t.Fatalf("output = %#v, want omitted weather story", output)
|
||||||
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
|
|
||||||
}
|
}
|
||||||
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
|
}
|
||||||
t.Fatalf("AFD = %#v, want only short_term section", afd)
|
|
||||||
|
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
|
||||||
|
registry := MustDefaultModuleRegistry()
|
||||||
|
ctx := testModuleContext()
|
||||||
|
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
|
||||||
|
|
||||||
|
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildModule() error = %v", err)
|
||||||
|
}
|
||||||
|
story := moduleValue[WeatherStoryModule](t, output)
|
||||||
|
if !story.Available || story.Priority || story.Order != 0 {
|
||||||
|
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
||||||
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
if err := definition.ValidateOptions(item.Options); err != nil {
|
options, err := definition.CanonicalOptions(item.Options)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if definition.Builder == nil {
|
if definition.Builder == nil {
|
||||||
@@ -120,7 +121,6 @@ func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (
|
|||||||
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options := item.Options
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
options = definition.DefaultOptions
|
options = definition.DefaultOptions
|
||||||
}
|
}
|
||||||
@@ -264,6 +264,24 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
|||||||
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanonicalOptions validates options and converts an accepted typed pointer to its value form.
|
||||||
|
func (d ModuleDefinition) CanonicalOptions(options any) (any, error) {
|
||||||
|
if err := d.ValidateOptions(options); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if options == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value := reflect.ValueOf(options)
|
||||||
|
if value.Kind() != reflect.Pointer {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil, fmt.Errorf("module %q options must not be a nil pointer", d.ID)
|
||||||
|
}
|
||||||
|
return value.Elem().Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func defaultModuleDefinitions() []ModuleDefinition {
|
func defaultModuleDefinitions() []ModuleDefinition {
|
||||||
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
|
||||||
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
|
|||||||
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
|
||||||
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
{ID: module.Metadata, Options: module.MetadataOptions{}},
|
||||||
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
|
||||||
|
{ID: module.AreaForecastDiscussion, Options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ValidateComposition() error = %v", err)
|
t.Fatalf("ValidateComposition() error = %v", err)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type WeatherStoryModule struct {
|
|||||||
|
|
||||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||||
story := ctx.Collected.WeatherStory
|
story := ctx.Collected.WeatherStory
|
||||||
if story == nil {
|
if story == nil || !story.HasUsableContent() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||||
|
|||||||
@@ -563,6 +563,35 @@ func TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReportModuleOverridesCanonicalizesTypedPointerOptions(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
options := &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}
|
||||||
|
cfg.Reports = map[string]ReportConfig{
|
||||||
|
"daily": {
|
||||||
|
DeterministicModules: []ModuleConfigItem{
|
||||||
|
{ID: module.Metadata},
|
||||||
|
{ID: module.AreaForecastDiscussion, Options: options},
|
||||||
|
},
|
||||||
|
deterministicModulesSet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides, err := cfg.ReportModuleOverrides()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReportModuleOverrides() error = %v", err)
|
||||||
|
}
|
||||||
|
got, ok := overrides[report.Daily][1].Options.(module.AreaForecastDiscussionOptions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("override options type = %T, want AreaForecastDiscussionOptions", overrides[report.Daily][1].Options)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, *options) {
|
||||||
|
t.Fatalf("override options = %#v, want %#v", got, *options)
|
||||||
|
}
|
||||||
|
if cfg.Reports["daily"].DeterministicModules[1].Options != options {
|
||||||
|
t.Fatalf("config options = %#v, want original pointer %#v", cfg.Reports["daily"].DeterministicModules[1].Options, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
func TestValidateReportModuleAliasesDirectly(t *testing.T) {
|
||||||
retiredDailyKey := retiredDailyReportKeyForTest()
|
retiredDailyKey := retiredDailyReportKeyForTest()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func normalizeModuleOptions(registry briefing.ModuleRegistry, id module.ID, raw
|
|||||||
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 {
|
if err := definition.ValidateOptions(raw); err == nil {
|
||||||
return raw, nil
|
return definition.CanonicalOptions(raw)
|
||||||
}
|
}
|
||||||
optionType := reflect.TypeOf(definition.DefaultOptions)
|
optionType := reflect.TypeOf(definition.DefaultOptions)
|
||||||
normalized, err := decodeKnownOptions(raw, optionType)
|
normalized, err := decodeKnownOptions(raw, optionType)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package weatherdata
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -180,6 +181,15 @@ type WeatherStory struct {
|
|||||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasUsableContent reports whether the weather story contains displayable content or a valid period.
|
||||||
|
func (s WeatherStory) HasUsableContent() bool {
|
||||||
|
return strings.TrimSpace(s.Title) != "" ||
|
||||||
|
strings.TrimSpace(s.Description) != "" ||
|
||||||
|
strings.TrimSpace(s.AltText) != "" ||
|
||||||
|
strings.TrimSpace(s.DownloadURL) != "" ||
|
||||||
|
(!s.StartTime.IsZero() && !s.EndTime.IsZero() && s.EndTime.After(s.StartTime))
|
||||||
|
}
|
||||||
|
|
||||||
type ConvectiveOutlookRun struct {
|
type ConvectiveOutlookRun struct {
|
||||||
LocationID string `json:"locationId,omitempty"`
|
LocationID string `json:"locationId,omitempty"`
|
||||||
LocationName string `json:"locationName,omitempty"`
|
LocationName string `json:"locationName,omitempty"`
|
||||||
|
|||||||
@@ -117,6 +117,31 @@ func TestForecastPeriodHasValidPrecipitationProbability(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeatherStoryHasUsableContent(t *testing.T) {
|
||||||
|
start := mustParseBundleTime("2026-05-29T13:00:00Z")
|
||||||
|
end := mustParseBundleTime("2026-05-29T14:00:00Z")
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
story WeatherStory
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "empty"},
|
||||||
|
{name: "whitespace title", story: WeatherStory{Title: " \t "}},
|
||||||
|
{name: "title", story: WeatherStory{Title: "Rain Chances"}, want: true},
|
||||||
|
{name: "description", story: WeatherStory{Description: "Showers expected."}, want: true},
|
||||||
|
{name: "alternate text", story: WeatherStory{AltText: "Rain chances graphic"}, want: true},
|
||||||
|
{name: "download URL", story: WeatherStory{DownloadURL: "https://example.invalid/story.png"}, want: true},
|
||||||
|
{name: "valid period", story: WeatherStory{StartTime: start, EndTime: end}, want: true},
|
||||||
|
{name: "reversed period", story: WeatherStory{StartTime: end, EndTime: start}},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.story.HasUsableContent(); got != tt.want {
|
||||||
|
t.Fatalf("HasUsableContent() = %t, want %t", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustParseBundleTime(value string) time.Time {
|
func mustParseBundleTime(value string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, value)
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user