diff --git a/README.md b/README.md index d873e9e..d54ab95 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 0b795ae..6049f16 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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`. diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index c63a714..a06d58e 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -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` diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index ae0494e..3fe5499 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -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: diff --git a/docs/operations.md b/docs/operations.md index 7f6def5..26a024e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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/ .briefing.json .metadata.json + storm/ + YYYY-MM-DD/ + .briefing.json + .metadata.json data-packages/ daily/ YYYY-MM-DD/ @@ -54,6 +60,9 @@ workspace/ weekend/ YYYY-MM-DD/ .data_package.json + storm/ + YYYY-MM-DD/ + .data_package.json preflight/ daily/ YYYY-MM-DD/ @@ -64,6 +73,9 @@ workspace/ weekend/ YYYY-MM-DD/ .render.json + storm/ + YYYY-MM-DD/ + .render.json reports/ daily/ .md @@ -71,11 +83,14 @@ workspace/ .md weekend/ .md + storm/ + .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. diff --git a/internal/app/app.go b/internal/app/app.go index cacc35a..afb8629 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 744388e..cf9ac23 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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" diff --git a/internal/briefing/package.go b/internal/briefing/package.go index e8e502b..49e3994 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -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 { diff --git a/internal/briefing/storm.go b/internal/briefing/storm.go new file mode 100644 index 0000000..c02b967 --- /dev/null +++ b/internal/briefing/storm.go @@ -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...) +} diff --git a/internal/briefing/storm_test.go b/internal/briefing/storm_test.go new file mode 100644 index 0000000..b71a248 --- /dev/null +++ b/internal/briefing/storm_test.go @@ -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) + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index a4d8cb1..d9a5ddf 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -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)) } } diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index 9eb9c65..e56e738 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -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 diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index bfcbe5a..cba7399 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -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 {