Add manual Storm Report generation

This commit is contained in:
2026-05-29 18:28:25 +00:00
parent 108a1618f6
commit 18fe82f441
13 changed files with 531 additions and 28 deletions

View File

@@ -4,7 +4,7 @@
reports from normalized forecast data.
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
Outlook, and Weekend Outlook Markdown reports through `scriptorium`, with
Outlook, Weekend Outlook, and manual Storm Report Markdown reports through `scriptorium`, with
inspectable briefing, prompt input, preflight, report, and metadata artifacts
under the configured workspace.
@@ -15,6 +15,7 @@ weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Documentation

View File

@@ -2,11 +2,10 @@
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
`weatherreporter generate storm`,
`weatherreporter run morning`, and `weatherreporter run evening` currently
write Markdown reports through `scriptorium`, after writing managed preparation
artifacts and running `scriptorium render` as a preflight check. Unsupported
report generation still resolves configuration, report definitions, and valid
periods, then returns a not-implemented error.
artifacts and running `scriptorium render` as a preflight check.
## Shortest Useful Command
@@ -35,6 +34,12 @@ weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
```
For a focused manual Storm Report:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Command Overview
```text
@@ -47,15 +52,16 @@ weatherreporter run morning
weatherreporter run evening
```
`generate daily`, `generate tomorrow`, `generate three-day`, and
`generate weekend` write a briefing snapshot, prompt input data package, render
preflight output, Markdown report, and metadata file under the configured
workspace. `run evening` generates the Tomorrow Planning Brief. `run morning`
generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on
Sunday. Run commands continue remaining reports after an independent report
failure, print a JSON aggregate summary to stdout, write compact report status
logs to stderr, and return nonzero when any report failed. Other `generate`
commands resolve one report request and stop before report generation.
`generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
input data package, render preflight output, Markdown report, and metadata file
under the configured workspace. `generate storm` requires explicit `--start`
and `--end` bounds for the event window. `run evening` generates the Tomorrow
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
Weekend Outlook except on Sunday. Run commands continue remaining reports after
an independent report failure, print a JSON aggregate summary to stdout, write
compact report status logs to stderr, and return nonzero when any report
failed.
## Flags
@@ -63,7 +69,7 @@ commands resolve one report request and stop before report generation.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, and `generate weekend`; reserved for later generated report output on other `generate` commands.
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.

View File

@@ -6,7 +6,8 @@ This document describes the implemented briefing package boundary.
`internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements Daily
Today, Daily Tomorrow, 3-Day Outlook, and Weekend Outlook briefing content.
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
briefing content.
## Inputs and Outputs
@@ -45,6 +46,11 @@ Output:
- Weekend content uses the same daily outlook summaries and adds planning
inputs for best outdoor windows, worst weather windows, rain/storm timing,
comfort concerns, and confidence or uncertainty context.
- Storm content uses the explicit event window and includes event headline
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
confidence and uncertainty inputs, watch items, active alerts, relevant
hourly and narrative forecast periods, and available discussion or weather
story context.
- Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior
@@ -55,6 +61,8 @@ Output:
one derived daily summary in the outlook period.
- Weekend briefing construction requires a Weekend report definition and at
least one derived daily summary in the weekend period.
- Storm briefing construction requires a Storm Report definition and a forecast
bundle.
- Save failures include path and operation context.
## Tests
@@ -64,6 +72,7 @@ Inspect:
- `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`

View File

@@ -11,8 +11,8 @@ This document describes the implemented prompt input package boundary.
Input:
- `briefing.Package` containing Daily-family, 3-Day Outlook, or Weekend Outlook
content
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook,
or Storm Report content
Output:

View File

@@ -9,6 +9,7 @@ weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
weatherreporter run morning
weatherreporter run evening
```
@@ -18,7 +19,8 @@ period, build the prompt input data package, run `scriptorium render`, run
`scriptorium run`, and write inspectable artifacts under the configured
workspace. The evening run resolves only the Tomorrow Planning Brief. The
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday.
except on Sunday. Storm Report generation is manual and uses the explicit
`--start` and `--end` bounds as its valid period.
Scheduled run commands print a JSON aggregate summary to stdout and compact
per-report status lines to stderr. If one report fails, remaining independent
@@ -44,6 +46,10 @@ workspace/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
storm/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
@@ -54,6 +60,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.data_package.json
storm/
YYYY-MM-DD/
<run_id>.data_package.json
preflight/
daily/
YYYY-MM-DD/
@@ -64,6 +73,9 @@ workspace/
weekend/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
reports/
daily/
<run_id>.md
@@ -71,11 +83,14 @@ workspace/
<run_id>.md
weekend/
<run_id>.md
storm/
<run_id>.md
```
The Markdown report is written to a RunID-managed report path. When `--out` is
provided to `generate daily`, `generate tomorrow`, `generate three-day`, or
`generate weekend`, the managed report is also copied to that path.
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, or `generate storm`, the managed report is also copied to
that path.
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
copies using each report definition's default filename, such as `daily.md`,
@@ -123,6 +138,9 @@ Weekend Outlook generation compares against a prior compatible Weekend briefing
snapshot for the same weekend window when one exists. Friday evening and
Saturday runs may narrow the valid start while keeping the same Monday endpoint.
Storm Report generation currently leaves Recent Changes empty. Its explicit
event window is still recorded in briefing and metadata artifacts.
When no prior comparable snapshot exists, or no configured threshold is crossed,
the Recent Changes list is empty.

View File

@@ -265,7 +265,7 @@ func batchOutputPath(outputDir string, definition report.Definition) string {
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
@@ -572,6 +572,13 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
})
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}

View File

@@ -673,6 +673,59 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
}
}
func TestGenerateStormReportWritesReport(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportStorm,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T10:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Storm Report\n",
}
outputPath := filepath.Join(t.TempDir(), "storm.md")
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
OutputPath: outputPath,
Renderer: renderer,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
if result.Briefing.Storm == nil {
t.Fatal("Storm = nil")
}
if renderer.renderRequest.PromptID != "weather.storm_report" {
t.Fatalf("render PromptID = %q, want weather.storm_report", renderer.renderRequest.PromptID)
}
if len(result.Briefing.Storm.HourlyPeriods) == 0 || len(result.Briefing.Storm.NarrativePeriods) == 0 {
t.Fatalf("selected source periods hourly/narrative = %d/%d, want relevant periods", len(result.Briefing.Storm.HourlyPeriods), len(result.Briefing.Storm.NarrativePeriods))
}
if _, err := os.Stat(outputPath); err != nil {
t.Fatalf("expected requested report output %q: %v", outputPath, err)
}
data, err := os.ReadFile(result.DataPackagePath)
if err != nil {
t.Fatalf("read data package: %v", err)
}
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
t.Fatalf("data package missing storm content:\n%s", string(data))
}
}
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"

View File

@@ -20,6 +20,7 @@ type Package struct {
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct {

200
internal/briefing/storm.go Normal file
View File

@@ -0,0 +1,200 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Storm struct {
TimingWindow timeutil.Period `json:"timingWindow"`
EventHeadlines []string `json:"eventHeadlines,omitempty"`
Hazards []string `json:"hazards,omitempty"`
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
func BuildStorm(ctx BuildContext) (Package, error) {
if ctx.Resolved.Definition.ID != report.Storm {
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
}
if ctx.Bundle == nil {
return Package{}, fmt.Errorf("forecast bundle is required")
}
period := ctx.Resolved.ValidPeriod
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
summary := forecast.SummarizeDaypart("storm window", period, hourly)
summary.AlertOverlaps = alerts
storm := &Storm{
TimingWindow: period,
EventHeadlines: stormHeadlines(alerts),
Hazards: stormHazards(alerts, summary),
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
RelevantAlerts: alerts,
HourlyPeriods: hourly,
DailyPeriods: daily,
NarrativePeriods: narrative,
WindowSummary: summary,
Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
return Package{
Metadata: BuildMetadata(ctx),
Storm: storm,
}, nil
}
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
var headlines []string
for _, alert := range alerts {
if alert.Headline != "" {
headlines = appendUnique(headlines, alert.Headline)
continue
}
if alert.Event != "" {
headlines = appendUnique(headlines, alert.Event)
}
}
if len(headlines) == 0 {
return []string{"No active alert headline overlaps the selected storm window."}
}
return headlines
}
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
hazards := map[string]struct{}{}
for _, alert := range alerts {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
for _, hazard := range hazardsForIndicators(summary.Indicators) {
hazards[hazard] = struct{}{}
}
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
hazards["precipitation"] = struct{}{}
}
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
hazards["wind"] = struct{}{}
}
out := sortedSet(hazards)
if len(out) == 0 {
return []string{"No storm-specific hazard signal stands out in the selected source data."}
}
return out
}
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
var items []string
if summary.DominantCondition != "" {
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
}
if summary.PeakWindGust != nil {
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
}
for _, period := range narrative {
if period.TextDescription != "" {
items = append(items, "Narrative guidance: "+period.TextDescription)
break
}
}
if len(items) == 0 && len(hourly) > 0 {
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
}
if len(items) == 0 {
items = append(items, "No active storm signal is evident from the selected forecast window.")
}
return items
}
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
var items []string
for _, alert := range alerts {
label := alert.Event
if label == "" {
label = alert.Headline
}
if label != "" {
items = appendUnique(items, "Alert scenario to consider: "+label+".")
}
}
if summary.Indicators.Thunder {
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
}
if summary.Indicators.Wind {
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
}
if summary.Indicators.Snow || summary.Indicators.Ice {
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
}
if len(items) == 0 {
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
}
return items
}
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
var items []string
if bundle == nil {
return []string{"No source bundle was available for confidence context."}
}
if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.")
}
}
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
items = appendUnique(items, "Weather story source is available.")
}
for _, warning := range bundle.Warnings {
if warning.Code != "" {
items = appendUnique(items, "Source warning: "+warning.Code+".")
}
}
if len(items) == 0 {
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
}
return items
}
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
var items []string
if len(alerts) > 0 {
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, "Watch precipitation timing and probability trends.")
}
if summary.PeakWindGust != nil {
items = append(items, "Watch wind gust trends.")
}
if bundle != nil && bundle.Discussion != nil {
items = append(items, "Watch the next forecast discussion update for confidence changes.")
}
if len(items) == 0 {
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
}
return appendUnique(nil, items...)
}

View File

@@ -0,0 +1,147 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestStormBriefingWithActiveAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
precip := 80.0
gust := 42.0
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
TextDescription: "Severe thunderstorms and gusty wind",
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}}},
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Storms likely.",
}}},
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Damaging wind possible in stronger storms.",
}}},
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}},
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if pkg.Metadata.ReportID != report.Storm {
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
}
if pkg.Storm == nil {
t.Fatal("Storm = nil")
}
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
}
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
}
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
}
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
}
if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context")
}
if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
}
}
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
Alerts: &forecast.AlertRun{},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
}
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
}
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
}
if len(pkg.Storm.MostLikelyScenario) == 0 {
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
}
}
func TestStormBriefingQuietWindow(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
}
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
}
}

View File

@@ -58,17 +58,51 @@ func TestRunUnknownCommand(t *testing.T) {
}
}
func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
func TestRunGenerateStormWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "storm.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{"generate", "storm", "--units", "metric", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want app not implemented error")
err := runner.Run(context.Background(), []string{
"generate", "storm",
"--config", configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(err.Error(), "generate is not implemented") {
t.Fatalf("Run() error = %q, want app not implemented error", err.Error())
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "storm", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
t.Fatalf("data package output missing storm content:\n%s", string(data))
}
}

View File

@@ -107,7 +107,7 @@ func Validate(pkg Package) error {
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
}
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil {
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
return fmt.Errorf("briefing report content is required")
}
return nil

View File

@@ -98,6 +98,33 @@ func TestBuildWeekendDataPackage(t *testing.T) {
}
}
func TestBuildStormDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
briefingPackage.Metadata.ReportID = report.Storm
briefingPackage.Metadata.PromptID = "weather.storm_report"
briefingPackage.Daily = nil
briefingPackage.Storm = &briefing.Storm{
TimingWindow: briefingPackage.Metadata.ValidPeriod,
Hazards: []string{"Thunderstorms"},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Storm {
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
}
if pkg.Report.PromptID != "weather.storm_report" {
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
}
if pkg.Briefing.Storm == nil {
t.Fatal("Briefing.Storm = nil")
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {