Clean up and normalize prompt output modules
This commit is contained in:
@@ -64,13 +64,18 @@ subset of discussion fields.
|
||||
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
|
||||
such as risk label, severity rank, valid times, source URL, image URL, and
|
||||
whether the outlook contains the configured location. It does not emit GeoJSON
|
||||
geometry.
|
||||
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
|
||||
outlook contains the configured location. It does not emit GeoJSON geometry,
|
||||
source URL, expiration time, or severity rank.
|
||||
|
||||
Prompt-facing module intervals use friendly local `period_begins` and
|
||||
`period_ends` labels. Canonical report metadata, source provenance,
|
||||
`issued_at`, `updated_at`, and point-in-time fields remain separate.
|
||||
|
||||
`spc_convective_discussion` uses the same derived report-period outlooks and
|
||||
discussion records. It is omitted unless at least one retained outlook has
|
||||
severity rank `3` or higher and matching discussion text exists.
|
||||
discussion records. It is omitted unless at least one retained categorical
|
||||
outlook for the same SPC day has severity rank `3` or higher and matching
|
||||
discussion text exists.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
|
||||
@@ -78,14 +78,17 @@ validation.
|
||||
- `outlooks`
|
||||
|
||||
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
|
||||
`severity_rank`, `valid_start`, `valid_end`, `issued_at`, `expires_at`,
|
||||
`contains_location`, `source_url`, and `image_url`. It omits GeoJSON geometry.
|
||||
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
|
||||
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
|
||||
severity rank.
|
||||
|
||||
`spc_convective_discussion` emits a narrative stanza only when a retained
|
||||
report-period outlook has severity rank `3` or higher and matching discussion
|
||||
text is available. Its output includes `included_because` and `discussions`;
|
||||
each discussion may include `day`, `headline`, `summary`, `discussion`, and
|
||||
`updated_at`.
|
||||
report-period categorical outlook has severity rank `3` or higher and matching
|
||||
discussion text is available. Its output includes `included_because` and
|
||||
`discussions`; each discussion may include `day`, `period_begins`,
|
||||
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
|
||||
Discussions are included only for SPC days whose retained categorical outlooks
|
||||
meet the severity threshold.
|
||||
|
||||
## Boundaries
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
|
||||
presentation concern only: module snapshots remain flat, and loaded
|
||||
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
|
||||
Within each category, stanza order follows the module snapshot output order.
|
||||
Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||
labels; canonical report metadata and source timestamps remain structured
|
||||
timestamps where applicable.
|
||||
|
||||
Current categories are:
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ plan.
|
||||
- Do not depend on `/outlooks/convective/active` or
|
||||
`/outlooks/convective/location` for initial behavior.
|
||||
- Include SPC discussion text only when at least one retained report-period
|
||||
outlook has `severity_rank >= 3`.
|
||||
categorical outlook has `severity_rank >= 3`.
|
||||
- Define the discussion threshold as an internal constant, initially `3`, not a
|
||||
config field.
|
||||
|
||||
@@ -305,13 +305,10 @@ Implementation:
|
||||
- `outlook_type`;
|
||||
- `label`;
|
||||
- `label_text`;
|
||||
- `severity_rank`;
|
||||
- `valid_start`;
|
||||
- `valid_end`;
|
||||
- `period_begins`;
|
||||
- `period_ends`;
|
||||
- `issued_at`;
|
||||
- `expires_at`;
|
||||
- `contains_location`;
|
||||
- `source_url`;
|
||||
- `image_url`.
|
||||
- Use human-readable local time helpers consistent with current modules.
|
||||
- Do not emit GeoJSON geometry.
|
||||
@@ -359,22 +356,25 @@ Implementation:
|
||||
- Define a package-private constant near the module, for example:
|
||||
`defaultSPCConvectiveDiscussionMinimumSeverityRank = 3`.
|
||||
- Build from derived retained outlooks and discussions.
|
||||
- Include discussion text only when at least one retained outlook has
|
||||
- Include discussion text only when at least one retained categorical outlook
|
||||
has
|
||||
`severityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank`.
|
||||
- When the threshold is not met, return `nil` output so the stanza is omitted.
|
||||
- When threshold is met, include discussions for retained outlook days with:
|
||||
- `day`;
|
||||
- `period_begins`;
|
||||
- `period_ends`;
|
||||
- `headline`;
|
||||
- `summary`;
|
||||
- `discussion`;
|
||||
- `updated_at`.
|
||||
- Include a concise reason field such as:
|
||||
`included_because: "severity_rank >= 3"`.
|
||||
`included_because: "categorical severity_rank >= 3"`.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Slight Risk or higher retained outlooks include matching discussion records
|
||||
when available.
|
||||
- Slight Risk or higher retained categorical outlooks include matching
|
||||
discussion records when available.
|
||||
- Lower-risk retained outlooks still appear in `spc_convective_outlooks` but
|
||||
do not emit `spc_convective_discussion`.
|
||||
- Missing discussion text omits the stanza without failing report generation.
|
||||
@@ -542,7 +542,8 @@ Manual review:
|
||||
- Confirm no module builder performs Weather API calls.
|
||||
- Confirm prompt YAML omits GeoJSON geometry.
|
||||
- Confirm checked-empty outlook data is not represented as missing data.
|
||||
- Confirm SPC discussion text appears only at severity rank `3` or higher.
|
||||
- Confirm SPC discussion text appears only for categorical outlooks at severity
|
||||
rank `3` or higher.
|
||||
- Confirm public CLI syntax, output paths, distributor upload behavior, and
|
||||
Scriptorium argv remain unchanged.
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ Important response semantics:
|
||||
- Place `spc_convective_outlooks` under `applicable_risk_products`.
|
||||
- Place `spc_convective_discussion` under `narrative_products`, immediately
|
||||
after `area_forecast_discussion` in report module order when both are present.
|
||||
- Include SPC outlook discussion text only when at least one retained outlook
|
||||
for the report valid period has `severity_rank >= 3`.
|
||||
- Include SPC outlook discussion text only when at least one retained
|
||||
categorical outlook for the report valid period has `severity_rank >= 3`.
|
||||
- Define that threshold as an internal constant so it can be adjusted later
|
||||
without searching through module code.
|
||||
- Treat a non-null run with empty arrays as checked empty data, not missing
|
||||
@@ -104,11 +104,9 @@ briefing:
|
||||
outlook_type: categorical
|
||||
label: SLGT
|
||||
label_text: Slight Risk
|
||||
severity_rank: 3
|
||||
valid_start: "2026-06-12 at 8:00 AM"
|
||||
valid_end: "2026-06-13 at 7:00 AM"
|
||||
period_begins: "2026-06-12 at 8:00 AM"
|
||||
period_ends: "2026-06-13 at 7:00 AM"
|
||||
contains_location: true
|
||||
source_url: "https://..."
|
||||
image_url: "https://..."
|
||||
```
|
||||
|
||||
@@ -119,9 +117,11 @@ briefing:
|
||||
narrative_products:
|
||||
area_forecast_discussion: {}
|
||||
spc_convective_discussion:
|
||||
included_because: "severity_rank >= 3"
|
||||
included_because: "categorical severity_rank >= 3"
|
||||
discussions:
|
||||
- day: 1
|
||||
period_begins: "2026-06-12 at 8:00 AM"
|
||||
period_ends: "2026-06-13 at 7:00 AM"
|
||||
headline: "Severe storms possible"
|
||||
summary: "Scattered severe storms are possible."
|
||||
discussion: "SPC discussion text."
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -204,6 +205,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
|
||||
t.Fatal("data package metadata stanza missing")
|
||||
}
|
||||
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
|
||||
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
|
||||
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
|
||||
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
|
||||
@@ -274,15 +276,21 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
" spc_convective_outlooks:",
|
||||
" spc_convective_discussion:",
|
||||
" included_because: severity_rank >= 3",
|
||||
" included_because: categorical severity_rank >= 3",
|
||||
" label_text: Slight Risk",
|
||||
" severity_rank: 3",
|
||||
" period_begins:",
|
||||
" period_ends:",
|
||||
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("data package missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
|
||||
}
|
||||
}
|
||||
|
||||
riskIndex := strings.Index(text, " applicable_risk_products:")
|
||||
alertIndex := strings.Index(text, " alert_digest:")
|
||||
@@ -304,6 +312,7 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
||||
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
|
||||
t.Fatal("loaded package missing spc_convective_outlooks stanza")
|
||||
}
|
||||
@@ -324,9 +333,14 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
|
||||
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
|
||||
}
|
||||
text := string(readDataPackageForTest(t, result))
|
||||
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") || !strings.Contains(text, " severity_rank: 2") {
|
||||
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") {
|
||||
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
|
||||
}
|
||||
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
|
||||
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
|
||||
}
|
||||
@@ -1470,6 +1484,34 @@ func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
|
||||
t.Helper()
|
||||
for name, value := range values {
|
||||
if name == "metadata" {
|
||||
continue
|
||||
}
|
||||
assertNoStaleIntervalKeys(t, "briefing."+name, value)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
switch key {
|
||||
case "start_time", "end_time", "period", "start", "end":
|
||||
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
|
||||
}
|
||||
assertNoStaleIntervalKeys(t, path+"."+key, child)
|
||||
}
|
||||
case []any:
|
||||
for i, child := range typed {
|
||||
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPathsExist(t *testing.T, paths ...string) {
|
||||
t.Helper()
|
||||
for _, path := range paths {
|
||||
@@ -1515,9 +1557,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
|
||||
}},
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
"morning": map[string]any{
|
||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||
"temp_range_f": "50-58",
|
||||
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
|
||||
"period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||
"period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
|
||||
"temp_range_f": "50-58",
|
||||
},
|
||||
}},
|
||||
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
|
||||
@@ -1539,7 +1582,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
|
||||
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
|
||||
date + "_morning": map[string]any{
|
||||
"date": date,
|
||||
"period": date + " at 6:00 AM to " + date + " at 10:00 AM",
|
||||
"period_begins": date + " at 6:00 AM",
|
||||
"period_ends": date + " at 10:00 AM",
|
||||
"temp_range_f": "50-58",
|
||||
"max_pop_percent": precip,
|
||||
"max_pop_time": "6 AM",
|
||||
|
||||
@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
|
||||
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
|
||||
@@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
t.Fatalf("Marshal hourly forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
@@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
|
||||
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText)
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
|
||||
}
|
||||
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime)
|
||||
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
|
||||
}
|
||||
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
|
||||
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
|
||||
@@ -127,7 +127,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
t.Fatalf("Marshal narrative forecast: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
@@ -135,8 +135,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
if strings.Contains(jsonText, "wind_direction_degrees") {
|
||||
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) {
|
||||
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText)
|
||||
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
|
||||
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
|
||||
}
|
||||
if strings.Contains(jsonText, "Tomorrow night") {
|
||||
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
|
||||
@@ -285,13 +285,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
|
||||
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
|
||||
}
|
||||
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
|
||||
}
|
||||
data, err := json.Marshal(storyOutput.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal weather story: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "download_url") {
|
||||
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
|
||||
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
|
||||
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
type DerivedDaypartSummaryModule struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
@@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
|
||||
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||
value := DerivedDaypartSummaryModule{
|
||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||
Period: friendlyPeriodLabel(daypart.Period, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||
TempRangeF: rangeLabel(daypart.Temperature),
|
||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||
DominantCondition: daypart.DominantCondition,
|
||||
|
||||
@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
if len(rainy.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
|
||||
}
|
||||
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
||||
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
|
||||
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
|
||||
}
|
||||
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
||||
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
|
||||
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
@@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
|
||||
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
|
||||
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
|
||||
}
|
||||
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
|
||||
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
|
||||
}
|
||||
@@ -150,8 +153,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
||||
}
|
||||
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period)
|
||||
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
|
||||
}
|
||||
afternoon := value["afternoon"]
|
||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||
@@ -166,12 +169,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
t.Fatalf("marshal daypart summaries: %v", err)
|
||||
}
|
||||
jsonText := string(data)
|
||||
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
|
||||
if !strings.Contains(jsonText, field) {
|
||||
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
|
||||
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +194,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
|
||||
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
|
||||
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
|
||||
}
|
||||
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
|
||||
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
|
||||
}
|
||||
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
|
||||
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
|
||||
}
|
||||
|
||||
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
|
||||
}
|
||||
|
||||
type HourlyForecastPeriod struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
ConditionCode *int `json:"condition_code,omitempty"`
|
||||
@@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
|
||||
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
|
||||
out := make([]HourlyForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, HourlyForecastPeriod{
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
Name: period.Name,
|
||||
IsDay: copyBool(period.IsDay),
|
||||
ConditionCode: copyInt(period.ConditionCode),
|
||||
|
||||
@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
|
||||
return clockLabel(value.Time, timezone)
|
||||
}
|
||||
|
||||
func periodClockLabel(period timeutil.Period, timezone string) string {
|
||||
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone)
|
||||
return friendlyDateTimeLabel(period.Start, timezone)
|
||||
}
|
||||
|
||||
func friendlyPeriodLabel(period timeutil.Period, timezone string) string {
|
||||
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
|
||||
if !period.IsValid() {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone)
|
||||
return friendlyDateTimeLabel(period.End, timezone)
|
||||
}
|
||||
|
||||
func friendlyDateTimeLabel(value time.Time, timezone string) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
|
||||
|
||||
type NarrativeForecastPeriod struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IsDay *bool `json:"is_day,omitempty"`
|
||||
TextDescription string `json:"text_description,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
|
||||
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
|
||||
out := make([]NarrativeForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
|
||||
out = append(out, NarrativeForecastPeriod{
|
||||
Name: period.Name,
|
||||
StartTime: friendlyDateTimeLabel(period.StartTime, timezone),
|
||||
EndTime: friendlyDateTimeLabel(period.EndTime, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
|
||||
IsDay: copyBool(period.IsDay),
|
||||
TextDescription: period.TextDescription,
|
||||
TemperatureC: copyFloat(period.TemperatureC),
|
||||
|
||||
@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
|
||||
}
|
||||
|
||||
type OutdoorWindowModule struct {
|
||||
Daypart string `json:"daypart"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
Daypart string `json:"daypart"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
|
||||
value := OutdoorWindowsModule{
|
||||
Best: outdoorWindowValue(windows.Best),
|
||||
Worst: outdoorWindowValue(windows.Worst),
|
||||
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
|
||||
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
|
||||
}
|
||||
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
|
||||
}
|
||||
|
||||
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule {
|
||||
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
|
||||
if window == nil {
|
||||
return nil
|
||||
}
|
||||
return &OutdoorWindowModule{
|
||||
Daypart: window.Daypart,
|
||||
Start: window.Start,
|
||||
End: window.End,
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
Daypart: window.Daypart,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
|
||||
Reasons: append([]string(nil), window.Reasons...),
|
||||
Score: window.Score,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
|
||||
}
|
||||
|
||||
type PrecipitationWindowModule struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end,omitempty"`
|
||||
PeriodBegins string `json:"period_begins"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
}
|
||||
@@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
|
||||
}
|
||||
for _, window := range timing.PrecipitationWindows {
|
||||
item := PrecipitationWindowModule{
|
||||
Start: clockLabel(window.Start, timezone),
|
||||
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
|
||||
}
|
||||
if window.End != nil {
|
||||
item.End = clockLabel(*window.End, timezone)
|
||||
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
|
||||
}
|
||||
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
|
||||
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
|
||||
const spcCategoricalOutlookType = "categorical"
|
||||
|
||||
type SPCConvectiveDiscussionModule struct {
|
||||
IncludedBecause string `json:"included_because"`
|
||||
@@ -15,56 +17,77 @@ type SPCConvectiveDiscussionModule struct {
|
||||
}
|
||||
|
||||
type SPCConvectiveDiscussionRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Discussion string `json:"discussion,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) {
|
||||
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
|
||||
if len(qualifyingPeriods) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value := SPCConvectiveDiscussionModule{
|
||||
IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
|
||||
Discussions: records,
|
||||
}
|
||||
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
|
||||
}
|
||||
|
||||
func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool {
|
||||
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
|
||||
periods := map[int]timeutil.Period{}
|
||||
for _, outlook := range outlooks {
|
||||
if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||
return true
|
||||
if outlook.OutlookType != spcCategoricalOutlookType {
|
||||
continue
|
||||
}
|
||||
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
|
||||
continue
|
||||
}
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
if existing, ok := periods[outlook.Day]; ok {
|
||||
if outlookPeriod.Start.Before(existing.Start) {
|
||||
existing.Start = outlookPeriod.Start
|
||||
}
|
||||
if outlookPeriod.End.After(existing.End) {
|
||||
existing.End = outlookPeriod.End
|
||||
}
|
||||
periods[outlook.Day] = existing
|
||||
continue
|
||||
}
|
||||
periods[outlook.Day] = outlookPeriod
|
||||
}
|
||||
return false
|
||||
return periods
|
||||
}
|
||||
|
||||
func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||
retainedDays := map[int]struct{}{}
|
||||
for _, outlook := range outlooks {
|
||||
retainedDays[outlook.Day] = struct{}{}
|
||||
}
|
||||
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
|
||||
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
|
||||
for _, discussion := range discussions {
|
||||
if _, ok := retainedDays[discussion.Day]; !ok {
|
||||
period, ok := qualifyingPeriods[discussion.Day]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if discussion.Discussion == "" {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveDiscussionRecord{
|
||||
Day: discussion.Day,
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||
Day: discussion.Day,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
|
||||
Headline: discussion.Headline,
|
||||
Summary: discussion.Summary,
|
||||
Discussion: discussion.Discussion,
|
||||
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
|
||||
})
|
||||
}
|
||||
return records
|
||||
|
||||
@@ -48,13 +48,16 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin
|
||||
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
|
||||
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
|
||||
}
|
||||
discussion := value.Discussions[0]
|
||||
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
|
||||
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
|
||||
}
|
||||
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
|
||||
}
|
||||
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
|
||||
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
|
||||
}
|
||||
@@ -63,11 +66,14 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} {
|
||||
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `"period":`) {
|
||||
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
|
||||
@@ -86,6 +92,162 @@ func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 30
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank30 := 30
|
||||
ctx := testModuleContext()
|
||||
categorical := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
wind := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-wind-30",
|
||||
Day: 1,
|
||||
OutlookType: "wind",
|
||||
Label: "30%",
|
||||
LabelText: "30% Wind Risk",
|
||||
SeverityRank: &rank30,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank2 := 2
|
||||
rank4 := 4
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
|
||||
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
|
||||
day1Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-marginal",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "MRGL",
|
||||
LabelText: "Marginal Risk",
|
||||
SeverityRank: &rank2,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
}
|
||||
day2Outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank4,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
|
||||
if len(value.Discussions) != 1 {
|
||||
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
|
||||
}
|
||||
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
|
||||
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
|
||||
}
|
||||
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
|
||||
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
rank := 5
|
||||
ctx := testModuleContext()
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day2-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
|
||||
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output != nil {
|
||||
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
@@ -22,13 +23,10 @@ type SPCConvectiveOutlookRecord struct {
|
||||
OutlookType string `json:"outlook_type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
SeverityRank *int `json:"severity_rank,omitempty"`
|
||||
ValidStart string `json:"valid_start,omitempty"`
|
||||
ValidEnd string `json:"valid_end,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContainsLocation bool `json:"contains_location"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
@@ -50,26 +48,27 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output,
|
||||
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||
}
|
||||
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone)
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
|
||||
value.OutlookCount = len(value.Outlooks)
|
||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord {
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
|
||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
|
||||
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
|
||||
continue
|
||||
}
|
||||
records = append(records, SPCConvectiveOutlookRecord{
|
||||
Day: outlook.Day,
|
||||
OutlookType: outlook.OutlookType,
|
||||
Label: outlook.Label,
|
||||
LabelText: outlook.LabelText,
|
||||
SeverityRank: copyInt(outlook.SeverityRank),
|
||||
ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone),
|
||||
ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone),
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
|
||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||
ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone),
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,32 +65,60 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.SeverityRank == nil || *got.SeverityRank != 3 {
|
||||
t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank)
|
||||
}
|
||||
if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" {
|
||||
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
}
|
||||
if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and source/image URLs", got)
|
||||
if !got.ContainsLocation || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and image URL", got)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} {
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} {
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 5
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "tomorrow-enhanced",
|
||||
Day: 2,
|
||||
OutlookType: "categorical",
|
||||
Label: "ENH",
|
||||
LabelText: "Enhanced Risk",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
|
||||
ContainsLocation: true,
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type OutdoorWindows struct {
|
||||
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
|
||||
|
||||
type OutdoorWindow struct {
|
||||
Daypart string
|
||||
Start string
|
||||
End string
|
||||
Period timeutil.Period
|
||||
Reasons []string
|
||||
Score float64
|
||||
}
|
||||
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
}
|
||||
return OutdoorWindow{
|
||||
Daypart: daypart.Name,
|
||||
Start: daypart.Period.Start.Format("15:04"),
|
||||
End: daypart.Period.End.Format("15:04"),
|
||||
Period: daypart.Period,
|
||||
Reasons: dedupe(reasons),
|
||||
Score: math.Round(score*10) / 10,
|
||||
}
|
||||
|
||||
@@ -4,20 +4,21 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type WeatherStoryModule struct {
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"office_id,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"alt_text,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
if story == nil {
|
||||
return nil, nil
|
||||
}
|
||||
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
|
||||
value := WeatherStoryModule{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
StartTime: story.StartTime,
|
||||
EndTime: story.EndTime,
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
|
||||
PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
|
||||
|
||||
type daypartSummaryStanza struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
Period string `json:"period,omitempty"`
|
||||
PeriodBegins string `json:"period_begins,omitempty"`
|
||||
PeriodEnds string `json:"period_ends,omitempty"`
|
||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||
|
||||
@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
|
||||
DailyPrecipitationProbability: &precip,
|
||||
}},
|
||||
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow},
|
||||
"morning": {
|
||||
Date: "2026-05-29",
|
||||
PeriodBegins: "2026-05-29 at 6:00 AM",
|
||||
PeriodEnds: "2026-05-29 at 10:00 AM",
|
||||
TempRangeF: "60-70",
|
||||
Snow: snow,
|
||||
},
|
||||
}},
|
||||
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
|
||||
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},
|
||||
|
||||
@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
|
||||
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
|
||||
date + "_morning": {
|
||||
Date: date,
|
||||
Period: date + " at 6:00 AM to " + date + " at 10:00 AM",
|
||||
PeriodBegins: date + " at 6:00 AM",
|
||||
PeriodEnds: date + " at 10:00 AM",
|
||||
TempRangeF: tempRange,
|
||||
MaxPopPercent: &precip,
|
||||
MaxPopTime: precipTime,
|
||||
|
||||
Reference in New Issue
Block a user