Compare commits
8 Commits
0b1423c90d
...
d425132ae7
| Author | SHA1 | Date | |
|---|---|---|---|
| d425132ae7 | |||
| 925d351341 | |||
| c4107490df | |||
| b38230bc35 | |||
| 1f5f9964e0 | |||
| b0d8c6983d | |||
| 55f0180599 | |||
| 5284033fb8 |
12
docs/cli.md
12
docs/cli.md
@@ -21,6 +21,7 @@ the managed Markdown report after final metadata is saved.
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
@@ -37,8 +38,11 @@ weatherreporter inspect sources [--config PATH] RUN_ID
|
||||
`generate` commands write a JSON module snapshot, YAML data package, preflight
|
||||
artifact, managed Markdown report, and metadata under the configured workspace.
|
||||
`--out` writes an extra Markdown copy for the operator; distributor
|
||||
notification uses the managed report path, not the extra copy. `generate storm`
|
||||
requires explicit event-window bounds with `--start` and `--end`.
|
||||
notification uses the managed report path, not the extra copy. `generate
|
||||
near-term` uses the current generation time, covers the next six hours in the
|
||||
effective report timezone, and does not accept date or event window flags.
|
||||
`generate storm` requires explicit event-window bounds with `--start` and
|
||||
`--end`.
|
||||
|
||||
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Planning Brief. Batch
|
||||
@@ -50,6 +54,9 @@ notification is enabled, batch summaries and status lines include notification
|
||||
status, accepted distributor run ID, or notification error fields for each
|
||||
attempted report.
|
||||
|
||||
Near-Term Report generation is explicit only; it is not included in `run
|
||||
morning` or `run evening`.
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
|
||||
@@ -76,6 +83,7 @@ are no distributor-specific CLI flags.
|
||||
|
||||
```sh
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate near-term --out ./near-term.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
|
||||
|
||||
@@ -181,9 +181,10 @@ snapshot exists and a threshold is crossed.
|
||||
`reports` optionally overrides the ordered deterministic modules declared by
|
||||
report definitions. Omit a report entry to use its default module order.
|
||||
|
||||
Supported report keys are `daily`, `tomorrow`, `three_day`, `weekend`, and
|
||||
`storm`. Canonical report IDs such as `daily_today` and `daily_tomorrow` are
|
||||
also accepted.
|
||||
Supported report keys are `daily`, `tomorrow`, `near_term`, `near-term`,
|
||||
`three_day`, `weekend`, and `storm`. Canonical report IDs such as
|
||||
`daily_today` and `daily_tomorrow` are also accepted. `near_term` and
|
||||
`near-term` are aliases for the same Near-Term Report override.
|
||||
|
||||
Each report entry supports:
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ Outputs:
|
||||
Every registered composition entry has a builder. Unknown or unimplemented
|
||||
module IDs fail validation instead of being skipped.
|
||||
|
||||
Near-Term Report supports source and valid-period modules that operate over its
|
||||
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
|
||||
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
|
||||
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
|
||||
It does not support daily/daypart-only modules such as
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
|
||||
`tomorrow_planning`.
|
||||
|
||||
Prompt-facing module values use local, human-readable date and time labels
|
||||
where the LLM is expected to reason about report content. Canonical timestamps
|
||||
remain in report metadata, source provenance, and integration artifacts.
|
||||
@@ -59,7 +67,8 @@ builders run. Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
|
||||
`area_forecast_discussion` uses optional `sections` configuration to include a
|
||||
subset of discussion fields.
|
||||
subset of discussion fields. Near-Term Report defaults this module to
|
||||
`key_messages` and `short_term`.
|
||||
|
||||
`spc_convective_outlooks` uses collected SPC run metadata and derived
|
||||
report-period outlooks. It emits `checked: true` for a successfully fetched
|
||||
|
||||
@@ -27,6 +27,12 @@ Outputs:
|
||||
report-period SPC convective outlooks and discussions, daily summaries,
|
||||
daypart summaries, and Storm Report window summary
|
||||
|
||||
Near-Term Report uses the generic valid-period hourly and narrative selection
|
||||
for its rolling six-hour window. Its derived facts include precipitation timing
|
||||
from the selected hourly periods, alert overlaps for the six-hour period, and
|
||||
SPC outlooks/discussions overlapping that period. It does not build daily
|
||||
summaries, daypart summaries, or a storm-window summary.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- This package owns fact assembly and reusable deterministic derivation for a
|
||||
@@ -62,6 +68,8 @@ and inspection.
|
||||
- Invalid timezone names return an error.
|
||||
- Missing required hourly forecast data returns the underlying forecast
|
||||
derivation error for reports that require daily summaries.
|
||||
- Near-Term Report can derive its default module facts without daily or
|
||||
daypart summaries.
|
||||
- Missing optional narrative, alert, discussion, daily, or weather story data
|
||||
produces empty or nil derived fields.
|
||||
- Missing optional SPC convective outlook data produces a nil collected field.
|
||||
|
||||
@@ -47,6 +47,24 @@ The registry recognizes these IDs:
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
## Near-Term Composition
|
||||
|
||||
The default Near-Term Report module order is:
|
||||
|
||||
1. `metadata`
|
||||
2. `current_conditions`
|
||||
3. `hourly_forecast`
|
||||
4. `precip_timing`
|
||||
5. `alert_digest`
|
||||
6. `spc_convective_outlooks`
|
||||
7. `area_forecast_discussion`
|
||||
8. `spc_convective_discussion`
|
||||
9. `weather_story`
|
||||
|
||||
Near-Term Report does not include daily or daypart summary modules by default.
|
||||
Its `area_forecast_discussion` item is configured to include only
|
||||
`key_messages` and `short_term`.
|
||||
|
||||
## Options
|
||||
|
||||
Most modules use an empty options struct, including
|
||||
|
||||
@@ -68,6 +68,12 @@ Prompt-facing module intervals use local `period_begins` and `period_ends`
|
||||
labels; canonical report metadata and source timestamps remain structured
|
||||
timestamps where applicable.
|
||||
|
||||
Near-Term Report uses the same package schema and categories. Its default
|
||||
package includes `precip_timing` under `derived_summaries`, alert and SPC
|
||||
outlooks under `applicable_risk_products`, AFD/SPC discussion/weather story
|
||||
under `narrative_products`, and current/hourly data under `raw_data`. It does
|
||||
not include daily or daypart summary stanzas.
|
||||
|
||||
Current categories are:
|
||||
|
||||
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
|
||||
@@ -106,6 +112,7 @@ None.
|
||||
## Skip And Resume Behavior
|
||||
|
||||
None. Recent Changes is always present as an `items` list and may be empty.
|
||||
Near-Term Report currently writes an empty `items` list.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ Each report definition declares:
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
|
||||
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
|
||||
| Near-Term Report | `near_term` | `weather.near_term_report` | `near-term` | `near-term.md` | Near-Term Report |
|
||||
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
|
||||
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
|
||||
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
|
||||
@@ -42,6 +43,9 @@ All report definitions are eligible for generation.
|
||||
- Daily Today covers the selected local civil day, or the current local civil
|
||||
day when no date override is supplied.
|
||||
- Daily Tomorrow covers the next local civil day from generation time.
|
||||
- Near-Term Report covers the half-open six-hour period from generation time in
|
||||
the effective report timezone. The duration is an internal report constant,
|
||||
not a configuration field.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
@@ -64,7 +68,15 @@ IDs, then uses the registry for report policy.
|
||||
## Config Fields Used
|
||||
|
||||
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
|
||||
output path copying uses batch output names from report definitions.
|
||||
output path copying uses batch output names from report definitions. Report
|
||||
module overrides can use the `near_term` or `near-term` keys for Near-Term
|
||||
Report.
|
||||
|
||||
## Batch Membership
|
||||
|
||||
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. Evening batches include Daily Tomorrow. Near-Term Report is not part
|
||||
of a scheduled batch.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Generation commands:
|
||||
```text
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate near-term
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
@@ -25,6 +26,10 @@ Markdown report after `scriptorium run` succeeds and final metadata is saved.
|
||||
`--out PATH` writes an extra Markdown copy for the current generated report; it
|
||||
is not used as the distributor upload source.
|
||||
|
||||
`generate near-term` is an explicit generation command. It covers the six-hour
|
||||
rolling period from generation time in the effective report timezone and is not
|
||||
part of scheduled morning or evening batches.
|
||||
|
||||
Batch commands:
|
||||
|
||||
```text
|
||||
@@ -62,6 +67,10 @@ workspace/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
near-term/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
<run_id>.metadata.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.modules.json
|
||||
@@ -76,6 +85,9 @@ workspace/
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
near-term/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.yaml
|
||||
@@ -89,6 +101,9 @@ workspace/
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
near-term/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
@@ -102,6 +117,9 @@ workspace/
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
near-term/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
storm/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.distributor.json
|
||||
@@ -112,6 +130,8 @@ workspace/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
near-term/
|
||||
<run_id>.md
|
||||
storm/
|
||||
<run_id>.md
|
||||
```
|
||||
@@ -218,8 +238,8 @@ Markdown or YAML text.
|
||||
Daily Today and Daily Tomorrow can compare with each other when they cover the
|
||||
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
|
||||
snapshots for the same valid local date. Weekend Outlook compares with prior
|
||||
compatible Weekend snapshots for the same weekend window. Storm Report leaves
|
||||
Recent Changes empty.
|
||||
compatible Weekend snapshots for the same weekend window. Near-Term Report and
|
||||
Storm Report leave Recent Changes empty.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
`recentChanges.items` is empty.
|
||||
|
||||
@@ -30,6 +30,7 @@ type ReportKind string
|
||||
const (
|
||||
ReportDaily ReportKind = "daily"
|
||||
ReportTomorrow ReportKind = "tomorrow"
|
||||
ReportNearTerm ReportKind = "near-term"
|
||||
ReportThreeDay ReportKind = "three-day"
|
||||
ReportWeekend ReportKind = "weekend"
|
||||
ReportStorm ReportKind = "storm"
|
||||
@@ -386,6 +387,8 @@ func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
||||
return report.DailyToday, nil
|
||||
case ReportTomorrow:
|
||||
return report.DailyTomorrow, nil
|
||||
case ReportNearTerm:
|
||||
return report.NearTerm, nil
|
||||
case ReportThreeDay:
|
||||
return report.ThreeDay, nil
|
||||
case ReportWeekend:
|
||||
|
||||
@@ -349,6 +349,187 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNearTermReportWritesExpectedArtifacts(t *testing.T) {
|
||||
server := nearTermBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportNearTerm,
|
||||
}, mustParse("2026-05-29T08:30:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
outputPath := filepath.Join(t.TempDir(), "near-term.md")
|
||||
renderer := successfulRenderer("# Near-Term Report\n")
|
||||
|
||||
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.Metadata.ReportID != report.NearTerm || result.Metadata.PromptID != "weather.near_term_report" {
|
||||
t.Fatalf("metadata report/prompt = %q/%q, want near-term", result.Metadata.ReportID, result.Metadata.PromptID)
|
||||
}
|
||||
if got := result.Metadata.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-29T08:30:00-05:00" {
|
||||
t.Fatalf("valid period start = %s, want rolling window start", got)
|
||||
}
|
||||
if got := result.Metadata.ValidPeriod.End.Format(time.RFC3339); got != "2026-05-29T14:30:00-05:00" {
|
||||
t.Fatalf("valid period end = %s, want six-hour rolling window end", got)
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.near_term_report" || renderer.runRequest.PromptID != "weather.near_term_report" {
|
||||
t.Fatalf("renderer prompt IDs = %q/%q, want near-term prompt", renderer.renderRequest.PromptID, renderer.runRequest.PromptID)
|
||||
}
|
||||
if !strings.Contains(result.ReportPath, filepath.Join("reports", "near-term")) {
|
||||
t.Fatalf("ReportPath = %q, want near-term artifact group", result.ReportPath)
|
||||
}
|
||||
if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "near-term", "2026-05-29")) {
|
||||
t.Fatalf("DataPackagePath = %q, want near-term artifact group", result.DataPackagePath)
|
||||
}
|
||||
assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.ReportPath, result.MetadataPath, outputPath)
|
||||
if result.OutputPath != outputPath {
|
||||
t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath)
|
||||
}
|
||||
copiedReport, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read output copy: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(copiedReport), "# Near-Term Report") {
|
||||
t.Fatalf("output copy missing rendered report:\n%s", string(copiedReport))
|
||||
}
|
||||
|
||||
wantModules := []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.HourlyForecast,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
}
|
||||
if got := snapshotModuleIDs(result.ModuleSnapshot); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(wantModules), ",") {
|
||||
t.Fatalf("module snapshot IDs = %#v, want %#v", got, wantModules)
|
||||
}
|
||||
|
||||
hourly, ok, err := module.StanzaValue[briefing.HourlyForecastModule](result.ModuleSnapshot, "hourly_forecast")
|
||||
if err != nil {
|
||||
t.Fatalf("decode hourly forecast: %v", err)
|
||||
}
|
||||
if !ok || len(hourly.Periods) != 5 {
|
||||
t.Fatalf("hourly forecast = %#v, want five overlapping near-term periods", hourly)
|
||||
}
|
||||
hourlyJSON := mustMarshalString(t, hourly)
|
||||
if !strings.Contains(hourlyJSON, "Showers entering the area") || !strings.Contains(hourlyJSON, "Heavy rain") {
|
||||
t.Fatalf("hourly forecast missing selected near-term periods:\n%s", hourlyJSON)
|
||||
}
|
||||
if strings.Contains(hourlyJSON, "Before-window storms") || strings.Contains(hourlyJSON, "After-window rain") {
|
||||
t.Fatalf("hourly forecast contains periods outside valid window:\n%s", hourlyJSON)
|
||||
}
|
||||
|
||||
precip, ok, err := module.StanzaValue[briefing.PrecipTimingModule](result.ModuleSnapshot, "precip_timing")
|
||||
if err != nil {
|
||||
t.Fatalf("decode precip timing: %v", err)
|
||||
}
|
||||
if !ok || precip.MaxPopPercent == nil || *precip.MaxPopPercent != 80 || len(precip.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("precip timing = %#v, want near-term precipitation windows", precip)
|
||||
}
|
||||
if precip.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || precip.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" {
|
||||
t.Fatalf("first precip window = %#v, want first selected hour only", precip.PrecipitationWindows[0])
|
||||
}
|
||||
if precip.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 10:00 AM" || precip.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 12:00 PM" {
|
||||
t.Fatalf("second precip window = %#v, want late-morning near-term rain", precip.PrecipitationWindows[1])
|
||||
}
|
||||
|
||||
alerts, ok, err := module.StanzaValue[briefing.AlertDigestModule](result.ModuleSnapshot, "alert_digest")
|
||||
if err != nil {
|
||||
t.Fatalf("decode alert digest: %v", err)
|
||||
}
|
||||
if !ok || !alerts.Checked || alerts.ActiveCount != 3 || alerts.RelevantCount != 1 || len(alerts.Relevant) != 1 || alerts.Relevant[0].Event != "Flood Watch" {
|
||||
t.Fatalf("alert digest = %#v, want only near-term alert overlap relevant", alerts)
|
||||
}
|
||||
|
||||
outlooks, ok, err := module.StanzaValue[briefing.SPCConvectiveOutlooksModule](result.ModuleSnapshot, "spc_convective_outlooks")
|
||||
if err != nil {
|
||||
t.Fatalf("decode SPC outlooks: %v", err)
|
||||
}
|
||||
if !ok || !outlooks.Checked || outlooks.OutlookCount != 1 || len(outlooks.Outlooks) != 1 || outlooks.Outlooks[0].Label != "SLGT" {
|
||||
t.Fatalf("SPC outlooks = %#v, want one overlapping near-term outlook", outlooks)
|
||||
}
|
||||
discussion, ok, err := module.StanzaValue[briefing.SPCConvectiveDiscussionModule](result.ModuleSnapshot, "spc_convective_discussion")
|
||||
if err != nil {
|
||||
t.Fatalf("decode SPC discussion: %v", err)
|
||||
}
|
||||
if !ok || len(discussion.Discussions) != 1 || discussion.Discussions[0].Headline != "Near-term severe storms" {
|
||||
t.Fatalf("SPC discussion = %#v, want discussion for retained overlapping outlook", discussion)
|
||||
}
|
||||
|
||||
afd, ok, err := module.StanzaValue[briefing.AreaForecastDiscussionModule](result.ModuleSnapshot, "area_forecast_discussion")
|
||||
if err != nil {
|
||||
t.Fatalf("decode AFD: %v", err)
|
||||
}
|
||||
if !ok || len(afd.KeyMessages) != 1 || afd.ShortTerm != "Short-term AFD narrative for near-term report." {
|
||||
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||
}
|
||||
if afd.Product != "" || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want near-term defaults to omit product and long term", afd)
|
||||
}
|
||||
|
||||
if result.PriorSnapshot != nil || len(result.RecentChanges) != 0 || len(result.DataPackage.RecentChanges.Items) != 0 {
|
||||
t.Fatalf("prior=%#v recent=%#v package=%#v, want no rolling-window comparison output", result.PriorSnapshot, result.RecentChanges, result.DataPackage.RecentChanges.Items)
|
||||
}
|
||||
data := readDataPackageForTest(t, result)
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
"id: near_term",
|
||||
"prompt_id: weather.near_term_report",
|
||||
"valid_period:",
|
||||
"recent_changes:",
|
||||
" items: []",
|
||||
" alert_digest:",
|
||||
" spc_convective_outlooks:",
|
||||
" precip_timing:",
|
||||
" area_forecast_discussion:",
|
||||
" spc_convective_discussion:",
|
||||
" weather_story:",
|
||||
" current_conditions:",
|
||||
" hourly_forecast:",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("data package missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"validPeriod:", "Before-window storms", "After-window rain", "Evening Advisory", "Day 2 outlook", "Long-term AFD narrative for near-term report."} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("data package contains %q, want near-term filtered/canonical output:\n%s", omitted, text)
|
||||
}
|
||||
}
|
||||
riskIndex := strings.Index(text, " applicable_risk_products:")
|
||||
derivedIndex := strings.Index(text, " derived_summaries:")
|
||||
narrativeIndex := strings.Index(text, " narrative_products:")
|
||||
rawIndex := strings.Index(text, " raw_data:")
|
||||
alertIndex := strings.Index(text, " alert_digest:")
|
||||
precipIndex := strings.Index(text, " precip_timing:")
|
||||
afdIndex := strings.Index(text, " area_forecast_discussion:")
|
||||
currentIndex := strings.Index(text, " current_conditions:")
|
||||
hourlyIndex := strings.Index(text, " hourly_forecast:")
|
||||
if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || precipIndex < 0 || afdIndex < 0 || currentIndex < 0 || hourlyIndex < 0 ||
|
||||
!(riskIndex < alertIndex && alertIndex < derivedIndex && derivedIndex < precipIndex && precipIndex < narrativeIndex && narrativeIndex < afdIndex && afdIndex < rawIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) {
|
||||
t.Fatalf("data package category order is wrong:\n%s", text)
|
||||
}
|
||||
loaded, err := promptinput.LoadYAML(data)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAML() error = %v", err)
|
||||
}
|
||||
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
|
||||
}
|
||||
|
||||
func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyTestConfig(t, server)
|
||||
@@ -1162,23 +1343,59 @@ func TestInspectMissingMetadata(t *testing.T) {
|
||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
now := mustParse("2026-05-29T18:00:00-05:00")
|
||||
now := mustParse("2026-05-29T08:00:00-05:00")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind ReportKind
|
||||
wantID report.ID
|
||||
wantPrompt string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
requestDate time.Time
|
||||
}{
|
||||
{
|
||||
name: "tomorrow",
|
||||
kind: ReportTomorrow,
|
||||
wantID: report.DailyTomorrow,
|
||||
wantPrompt: "weather.daily_report",
|
||||
wantStart: "2026-05-30T00:00:00-05:00",
|
||||
wantEnd: "2026-05-31T00:00:00-05:00",
|
||||
requestDate: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "near-term",
|
||||
kind: ReportNearTerm,
|
||||
wantID: report.NearTerm,
|
||||
wantPrompt: "weather.near_term_report",
|
||||
wantStart: "2026-05-29T08:00:00-05:00",
|
||||
wantEnd: "2026-05-29T14:00:00-05:00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resolved, err := ResolveGenerate(GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportTomorrow,
|
||||
Report: tt.kind,
|
||||
Date: tt.requestDate,
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
if resolved.Definition.ID != report.DailyTomorrow {
|
||||
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
|
||||
if resolved.Definition.ID != tt.wantID {
|
||||
t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID)
|
||||
}
|
||||
if resolved.Definition.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
||||
if resolved.Definition.PromptID != tt.wantPrompt {
|
||||
t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt)
|
||||
}
|
||||
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
|
||||
t.Fatalf("valid start date = %s, want 2026-05-30", got)
|
||||
if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart {
|
||||
t.Fatalf("valid start = %s, want %s", got, tt.wantStart)
|
||||
}
|
||||
if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd {
|
||||
t.Fatalf("valid end = %s, want %s", got, tt.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,6 +1438,23 @@ func moduleIDsForTest(ids []module.ID) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func snapshotModuleIDs(snapshot module.Snapshot) []module.ID {
|
||||
ids := make([]module.ID, 0, len(snapshot.Outputs))
|
||||
for _, output := range snapshot.Outputs {
|
||||
ids = append(ids, output.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func mustMarshalString(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal value: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse)
|
||||
@@ -1254,6 +1488,34 @@ func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse st
|
||||
return server
|
||||
}
|
||||
|
||||
func nearTermBundleServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/observations":
|
||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T13:20:00Z","conditionCode":3}}`))
|
||||
case "/conditions/current":
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`))
|
||||
case "/forecast/hourly":
|
||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`))
|
||||
case "/forecast/narrative":
|
||||
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
|
||||
case "/alerts/active":
|
||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
|
||||
case "/discussion":
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for near-term report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for near-term report."}}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Near-Term Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/near-term"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-near-term","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"Near-term severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the near-term window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`
|
||||
|
||||
const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}`
|
||||
|
||||
@@ -97,6 +97,21 @@ func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourlyForecastModuleBuildsForNearTerm(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[HourlyForecastModule](t, output)
|
||||
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
|
||||
t.Fatalf("HourlyForecast = %#v, want near-term hourly period", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
@@ -320,6 +335,34 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaForecastDiscussionModuleUsesNearTermDefaultSections(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||
var item module.ConfigItem
|
||||
for _, candidate := range ctx.Resolved.Definition.Modules {
|
||||
if candidate.ID == module.AreaForecastDiscussion {
|
||||
item = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Fatal("near-term default modules missing area_forecast_discussion")
|
||||
}
|
||||
|
||||
output, err := registry.BuildModule(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
afd := moduleValue[AreaForecastDiscussionModule](t, output)
|
||||
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
|
||||
t.Fatalf("AFD = %#v, want key messages and short term", afd)
|
||||
}
|
||||
if afd.Product != "" || afd.LongTerm != "" {
|
||||
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
|
||||
}
|
||||
}
|
||||
|
||||
func testModuleContext() ModuleContext {
|
||||
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
|
||||
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
|
||||
|
||||
@@ -136,6 +136,22 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.NearTerm)
|
||||
ctx.Derived.DailySummaries = nil
|
||||
ctx.Derived.DaypartSummaries = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[PrecipTimingModule](t, output)
|
||||
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := derivedModuleContext(report.DailyToday)
|
||||
|
||||
@@ -249,7 +249,7 @@ func (d ModuleDefinition) ValidateOptions(options any) error {
|
||||
}
|
||||
|
||||
func defaultModuleDefinitions() []ModuleDefinition {
|
||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
||||
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm, report.ThreeDay, report.Weekend, report.Storm}
|
||||
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
||||
return []ModuleDefinition{
|
||||
{
|
||||
@@ -286,7 +286,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
DefaultOptions: module.HourlyForecastOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
||||
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.NearTerm},
|
||||
MissingData: module.MissingDataOmit,
|
||||
Builder: buildHourlyForecastModule,
|
||||
},
|
||||
@@ -312,7 +312,7 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
ID: module.PrecipTiming,
|
||||
StanzaName: "precip_timing",
|
||||
DefaultOptions: module.PrecipTimingOptions{},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildPrecipTimingModule,
|
||||
|
||||
@@ -54,6 +54,27 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearTermDefaultModuleOptions(t *testing.T) {
|
||||
definition := report.DefaultRegistry().MustLookup(report.NearTerm)
|
||||
var found bool
|
||||
for _, item := range definition.Modules {
|
||||
if item.ID != module.AreaForecastDiscussion {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
|
||||
if !ok {
|
||||
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
|
||||
}
|
||||
if strings.Join(options.Sections, ",") != "key_messages,short_term" {
|
||||
t.Fatalf("AFD sections = %#v, want key messages and short term", options.Sections)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("near-term default modules missing area_forecast_discussion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
err := registry.ValidateComposition(report.DailyToday, []module.ConfigItem{{ID: module.ID("unknown")}})
|
||||
@@ -91,6 +112,24 @@ func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsNearTermIncompatibleModules(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
for _, id := range []module.ID{
|
||||
module.NarrativeForecast,
|
||||
module.DerivedDailySummary,
|
||||
module.DerivedDaypartSummaries,
|
||||
module.OutdoorWindows,
|
||||
module.TomorrowPlanning,
|
||||
} {
|
||||
t.Run(string(id), func(t *testing.T) {
|
||||
err := registry.ValidateComposition(report.NearTerm, []module.ConfigItem{{ID: id}})
|
||||
if err == nil || !strings.Contains(err.Error(), `not compatible with report "near_term"`) {
|
||||
t.Fatalf("ValidateComposition() error = %v, want incompatible near-term module", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
|
||||
_, err := NewModuleRegistry([]ModuleDefinition{
|
||||
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
weatherreporter --help
|
||||
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
|
||||
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate near-term [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
|
||||
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
|
||||
@@ -386,6 +387,8 @@ func reportKind(value string) (app.ReportKind, bool) {
|
||||
return app.ReportDaily, true
|
||||
case string(app.ReportTomorrow):
|
||||
return app.ReportTomorrow, true
|
||||
case string(app.ReportNearTerm):
|
||||
return app.ReportNearTerm, true
|
||||
case string(app.ReportThreeDay):
|
||||
return app.ReportThreeDay, true
|
||||
case string(app.ReportWeekend):
|
||||
|
||||
@@ -28,6 +28,9 @@ func TestRunHelpLongFlag(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "generate daily") {
|
||||
t.Fatalf("help output missing generate command:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "weatherreporter generate near-term") {
|
||||
t.Fatalf("help output missing near-term generate command:\n%s", stdout.String())
|
||||
}
|
||||
removedInspectCommand := "inspect " + "briefing"
|
||||
if !strings.Contains(stdout.String(), "inspect modules") || strings.Contains(stdout.String(), removedInspectCommand) {
|
||||
t.Fatalf("help output has wrong inspect commands:\n%s", stdout.String())
|
||||
@@ -809,6 +812,7 @@ func TestResolveGenerateCommands(t *testing.T) {
|
||||
}{
|
||||
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
|
||||
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
|
||||
{name: "near-term", args: []string{"near-term"}, want: app.ReportNearTerm},
|
||||
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
|
||||
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
|
||||
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
|
||||
@@ -827,6 +831,53 @@ func TestResolveGenerateCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateNearTermAppliesSharedFlags(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
configPath := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("weather_api:\n units: metric\n timezone: UTC\n"), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
req, err := runner.resolveGenerate([]string{"near-term", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./near-term.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveGenerate() error = %v", err)
|
||||
}
|
||||
|
||||
if req.Report != app.ReportNearTerm {
|
||||
t.Fatalf("Report = %q, want near-term", req.Report)
|
||||
}
|
||||
if req.Config.WeatherAPI.Units != "us" {
|
||||
t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units)
|
||||
}
|
||||
if req.Config.WeatherAPI.Timezone != "America/Chicago" {
|
||||
t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone)
|
||||
}
|
||||
if req.OutputPath != "./near-term.md" {
|
||||
t.Fatalf("OutputPath = %q, want ./near-term.md", req.OutputPath)
|
||||
}
|
||||
if !req.Date.IsZero() || !req.StormStart.IsZero() || !req.StormEnd.IsZero() {
|
||||
t.Fatalf("date/storm bounds = %s/%s/%s, want unset for near-term", req.Date, req.StormStart, req.StormEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateNearTermRejectsDateAndStormBounds(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"near-term", "--date", "2026-05-29"},
|
||||
{"near-term", "--start", "2026-05-29T18:00"},
|
||||
{"near-term", "--end", "2026-05-29T20:00"},
|
||||
} {
|
||||
_, err := runner.resolveGenerate(args)
|
||||
if err == nil {
|
||||
t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "flag provided but not defined") {
|
||||
t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
|
||||
@@ -161,6 +161,45 @@ reports:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNearTermReportModuleOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "UnderscoreAlias", key: "near_term"},
|
||||
{name: "HyphenAlias", key: "near-term"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
reports:
|
||||
`+tt.key+`:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
- hourly_forecast
|
||||
- precip_timing
|
||||
- alert_digest
|
||||
`)
|
||||
|
||||
cfg, err := LoadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
overrides := cfg.ReportModuleOverrides()
|
||||
items := overrides[report.NearTerm]
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("near-term override length = %d, want 4", len(items))
|
||||
}
|
||||
if items[0].ID != module.Metadata ||
|
||||
items[1].ID != module.HourlyForecast ||
|
||||
items[2].ID != module.PrecipTiming ||
|
||||
items[3].ID != module.AlertDigest {
|
||||
t.Fatalf("near-term override = %#v, want configured module order", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportModuleOverrideValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -244,6 +283,29 @@ reports:
|
||||
`,
|
||||
wantErr: "duplicates report override",
|
||||
},
|
||||
{
|
||||
name: "DuplicateNearTermAlias",
|
||||
yaml: `
|
||||
reports:
|
||||
near_term:
|
||||
deterministic_modules:
|
||||
- metadata
|
||||
near-term:
|
||||
deterministic_modules:
|
||||
- current_conditions
|
||||
`,
|
||||
wantErr: "duplicates report override",
|
||||
},
|
||||
{
|
||||
name: "NearTermIncompatibleDailyModule",
|
||||
yaml: `
|
||||
reports:
|
||||
near_term:
|
||||
deterministic_modules:
|
||||
- derived_daily_summary
|
||||
`,
|
||||
wantErr: `not compatible with report "near_term"`,
|
||||
},
|
||||
{
|
||||
name: "UnknownReportField",
|
||||
yaml: `
|
||||
|
||||
@@ -113,6 +113,8 @@ func reportIDForConfigKey(key string) (report.ID, error) {
|
||||
return report.DailyToday, nil
|
||||
case "tomorrow", "daily_tomorrow":
|
||||
return report.DailyTomorrow, nil
|
||||
case "near_term":
|
||||
return report.NearTerm, nil
|
||||
case "three_day", "three_day_outlook":
|
||||
return report.ThreeDay, nil
|
||||
case "weekend", "weekend_outlook":
|
||||
|
||||
@@ -114,6 +114,7 @@ func BuildDerived(req BuildDerivedRequest) (DerivedFacts, error) {
|
||||
derived.PrecipTiming = forecast.BuildPrecipTiming(derived.ValidPeriodHourlyPeriods)
|
||||
|
||||
switch req.Resolved.Definition.ID {
|
||||
case report.NearTerm:
|
||||
case report.DailyToday, report.DailyTomorrow:
|
||||
summary, err := forecast.BuildDailySummary(bundle, period.Start, location, req.Dayparts)
|
||||
if err != nil {
|
||||
|
||||
@@ -152,6 +152,63 @@ func TestBuildDerivedWeekendAndTomorrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedNearTermUsesRollingWindowFacts(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:30:00-05:00"), location)
|
||||
bundle := testBundle(location)
|
||||
bundle.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{
|
||||
json.RawMessage(`{"event":"Expired Advisory","headline":"Ends at start","severity":"Minor","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
|
||||
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"}`),
|
||||
json.RawMessage(`{"event":"Evening Advisory","headline":"Starts at end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}`),
|
||||
}}
|
||||
bundle.SPCConvectiveOutlooks = testConvectiveOutlookRun(location)
|
||||
|
||||
derived, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(bundle),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDerived() error = %v", err)
|
||||
}
|
||||
|
||||
if len(derived.ValidPeriodHourlyPeriods) != 4 {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods length = %d, want 4 rolling-window hours", len(derived.ValidPeriodHourlyPeriods))
|
||||
}
|
||||
if derived.ValidPeriodHourlyPeriods[0].StartTime.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" ||
|
||||
derived.ValidPeriodHourlyPeriods[3].StartTime.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||
t.Fatalf("ValidPeriodHourlyPeriods = %#v, want only hours overlapping 8:30 AM-2:30 PM", derived.ValidPeriodHourlyPeriods)
|
||||
}
|
||||
if len(derived.ValidPeriodNarrativePeriods) != 1 {
|
||||
t.Fatalf("ValidPeriodNarrativePeriods length = %d, want overlapping narrative period", len(derived.ValidPeriodNarrativePeriods))
|
||||
}
|
||||
if len(derived.DailySummaries) != 0 || len(derived.DaypartSummaries) != 0 || derived.StormWindowSummary != nil {
|
||||
t.Fatalf("near-term summaries daily=%#v daypart=%#v storm=%#v, want none", derived.DailySummaries, derived.DaypartSummaries, derived.StormWindowSummary)
|
||||
}
|
||||
if derived.PrecipTiming.FirstPrecipitation == nil || derived.PrecipTiming.FirstPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T08:00:00-05:00" {
|
||||
t.Fatalf("PrecipTiming.FirstPrecipitation = %#v, want first selected rainy hour", derived.PrecipTiming.FirstPrecipitation)
|
||||
}
|
||||
if derived.PrecipTiming.LastPrecipitation == nil || derived.PrecipTiming.LastPrecipitation.Time.Format(time.RFC3339) != "2026-05-29T14:00:00-05:00" {
|
||||
t.Fatalf("PrecipTiming.LastPrecipitation = %#v, want final selected rainy window end", derived.PrecipTiming.LastPrecipitation)
|
||||
}
|
||||
if len(derived.PrecipTiming.PrecipitationWindows) != 2 {
|
||||
t.Fatalf("PrecipTiming.PrecipitationWindows = %#v, want two near-term windows", derived.PrecipTiming.PrecipitationWindows)
|
||||
}
|
||||
if len(derived.AlertOverlaps) != 1 || derived.AlertOverlaps[0].Event != "Flood Watch" {
|
||||
t.Fatalf("AlertOverlaps = %#v, want only alert overlapping near-term period", derived.AlertOverlaps)
|
||||
}
|
||||
if derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T14:30:00-05:00" {
|
||||
t.Fatalf("Alert overlap end = %s, want clipped to near-term end", derived.AlertOverlaps[0].Overlap.End.Format(time.RFC3339))
|
||||
}
|
||||
if got, want := outlookIDs(derived.SPCConvectiveOutlooks), []string{"fri-high", "fri-storm", "fri-low", "fri-missing-rank", "fri-probabilistic"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("SPCConvectiveOutlooks IDs = %#v, want %#v", got, want)
|
||||
}
|
||||
if got, want := discussionHeadlines(derived.SPCConvectiveDiscussions), []string{"day1 early", "day1 late"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("SPCConvectiveDiscussions = %#v, want retained discussions for overlapping SPC day %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedStormBuildsWindowSummary(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveStormForTest(t, location)
|
||||
@@ -247,6 +304,22 @@ func TestBuildDerivedSelectsSPCConvectiveOutlooksByValidPeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedUnsupportedReportReturnsActionableError(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.NearTerm, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
resolved.Definition.ID = report.ID("future_report")
|
||||
|
||||
_, err := BuildDerived(BuildDerivedRequest{
|
||||
Resolved: resolved,
|
||||
Timezone: location.String(),
|
||||
Dayparts: testDayparts(),
|
||||
Collected: BuildCollected(testBundle(location)),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), `derived facts are not implemented for report "future_report"`) {
|
||||
t.Fatalf("BuildDerived() error = %v, want actionable unsupported report error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDerivedSPCConvectiveOutlooksDistinguishesMissingAndCheckedEmpty(t *testing.T) {
|
||||
location := testLocation()
|
||||
resolved := resolveForTest(t, report.DailyToday, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
|
||||
@@ -14,6 +14,7 @@ type ID string
|
||||
const (
|
||||
DailyToday ID = "daily_today"
|
||||
DailyTomorrow ID = "daily_tomorrow"
|
||||
NearTerm ID = "near_term"
|
||||
ThreeDay ID = "three_day"
|
||||
Weekend ID = "weekend"
|
||||
Storm ID = "storm"
|
||||
@@ -25,6 +26,7 @@ const (
|
||||
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
||||
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
||||
CompareRollingWindow ComparisonStrategy = "rolling_window"
|
||||
)
|
||||
|
||||
type Batch string
|
||||
|
||||
52
internal/report/near_term_report.go
Normal file
52
internal/report/near_term_report.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
const nearTermHours = 6
|
||||
|
||||
func nearTermDefinition() Definition {
|
||||
return Definition{
|
||||
ID: NearTerm,
|
||||
Name: "Near-Term Report",
|
||||
PromptID: "weather.near_term_report",
|
||||
ComparisonStrategy: CompareRollingWindow,
|
||||
ArtifactGroup: "near-term",
|
||||
BatchOutputName: "near-term.md",
|
||||
Generated: true,
|
||||
CompatiblePriorIDs: []ID{NearTerm},
|
||||
Modules: nearTermModules(),
|
||||
resolve: resolveNearTerm,
|
||||
}
|
||||
}
|
||||
|
||||
func nearTermModules() []module.ConfigItem {
|
||||
return []module.ConfigItem{
|
||||
{ID: module.Metadata},
|
||||
{ID: module.CurrentConditions},
|
||||
{ID: module.HourlyForecast},
|
||||
{ID: module.PrecipTiming},
|
||||
{ID: module.AlertDigest},
|
||||
{ID: module.SPCConvectiveOutlooks},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
Options: module.AreaForecastDiscussionOptions{
|
||||
Sections: []string{"key_messages", "short_term"},
|
||||
},
|
||||
},
|
||||
{ID: module.SPCConvectiveDiscussion},
|
||||
{ID: module.WeatherStory},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) {
|
||||
localNow := req.Now.In(req.Location)
|
||||
return timeutil.Period{
|
||||
Start: localNow,
|
||||
End: localNow.Add(nearTermHours * time.Hour),
|
||||
}, nil
|
||||
}
|
||||
@@ -58,6 +58,51 @@ func TestThreeDayPeriodCalculation(t *testing.T) {
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||
}
|
||||
|
||||
func TestNearTermLookupAndPeriodCalculation(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T05:15:00-05:00")
|
||||
|
||||
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Definition.ID != NearTerm {
|
||||
t.Fatalf("ID = %q, want near_term", resolved.Definition.ID)
|
||||
}
|
||||
if resolved.Definition.PromptID != "weather.near_term_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID)
|
||||
}
|
||||
if resolved.Definition.ComparisonStrategy != CompareRollingWindow {
|
||||
t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy)
|
||||
}
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00")
|
||||
}
|
||||
|
||||
func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("load location: %v", err)
|
||||
}
|
||||
now := mustParse("2026-05-29T10:15:00Z")
|
||||
|
||||
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00")
|
||||
}
|
||||
|
||||
func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
now := mustParse("2026-05-29T22:30:00-05:00")
|
||||
|
||||
resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00")
|
||||
}
|
||||
|
||||
func TestWeekendPeriodCalculation(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
tests := []struct {
|
||||
@@ -158,6 +203,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchesDoNotIncludeNearTerm(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
||||
|
||||
morning, err := DefaultRegistry().BatchReports(Morning, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(morning) error = %v", err)
|
||||
}
|
||||
evening, err := DefaultRegistry().BatchReports(Evening, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(evening) error = %v", err)
|
||||
}
|
||||
for _, resolved := range append(morning, evening...) {
|
||||
if resolved.Definition.ID == NearTerm {
|
||||
t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
||||
if err == nil {
|
||||
@@ -168,6 +232,14 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) {
|
||||
ids := resolvedDefinitionIDs(DefaultRegistry().All())
|
||||
want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"}
|
||||
if strings.Join(ids, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("All() ids = %#v, want %#v", ids, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||
for _, definition := range DefaultRegistry().All() {
|
||||
if definition.PromptID == "" {
|
||||
@@ -186,6 +258,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName string
|
||||
generated bool
|
||||
compatiblePriorIDs []ID
|
||||
comparisonStrategy ComparisonStrategy
|
||||
}{
|
||||
{
|
||||
id: DailyToday,
|
||||
@@ -193,6 +266,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "daily.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: DailyTomorrow,
|
||||
@@ -200,6 +274,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "tomorrow.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{DailyToday, DailyTomorrow},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: NearTerm,
|
||||
artifactGroup: "near-term",
|
||||
batchOutputName: "near-term.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{NearTerm},
|
||||
comparisonStrategy: CompareRollingWindow,
|
||||
},
|
||||
{
|
||||
id: ThreeDay,
|
||||
@@ -207,6 +290,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "three-day.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{ThreeDay},
|
||||
comparisonStrategy: CompareSameValidDate,
|
||||
},
|
||||
{
|
||||
id: Weekend,
|
||||
@@ -214,6 +298,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "weekend.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Weekend},
|
||||
comparisonStrategy: CompareWeekendWindow,
|
||||
},
|
||||
{
|
||||
id: Storm,
|
||||
@@ -221,6 +306,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
batchOutputName: "storm.md",
|
||||
generated: true,
|
||||
compatiblePriorIDs: []ID{Storm},
|
||||
comparisonStrategy: CompareExplicitWindow,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -240,6 +326,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) {
|
||||
if definition.Generated != tt.generated {
|
||||
t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated)
|
||||
}
|
||||
if definition.ComparisonStrategy != tt.comparisonStrategy {
|
||||
t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy)
|
||||
}
|
||||
if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) {
|
||||
t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs)
|
||||
}
|
||||
@@ -294,6 +383,20 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) {
|
||||
module.HourlyForecast,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: NearTerm,
|
||||
want: []module.ID{
|
||||
module.Metadata,
|
||||
module.CurrentConditions,
|
||||
module.HourlyForecast,
|
||||
module.PrecipTiming,
|
||||
module.AlertDigest,
|
||||
module.SPCConvectiveOutlooks,
|
||||
module.AreaForecastDiscussion,
|
||||
module.SPCConvectiveDiscussion,
|
||||
module.WeatherStory,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ThreeDay,
|
||||
want: []module.ID{
|
||||
@@ -412,6 +515,24 @@ func TestResolvedMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
metadata := resolved.Metadata()
|
||||
if metadata.ReportID != NearTerm {
|
||||
t.Fatalf("ReportID = %q, want near_term", metadata.ReportID)
|
||||
}
|
||||
if metadata.PromptID != "weather.near_term_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID)
|
||||
}
|
||||
if !strings.Contains(metadata.RunID, "near_term") {
|
||||
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||
t.Helper()
|
||||
if !period.IsValid() {
|
||||
@@ -433,6 +554,14 @@ func resolvedIDs(resolved []Resolved) []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
func resolvedDefinitionIDs(definitions []Definition) []string {
|
||||
ids := make([]string, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
ids = append(ids, string(definition.ID))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func mustLoadLocation(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation("America/Chicago")
|
||||
|
||||
@@ -14,6 +14,7 @@ func DefaultRegistry() Registry {
|
||||
definitions := []Definition{
|
||||
dailyTodayDefinition(),
|
||||
dailyTomorrowDefinition(),
|
||||
nearTermDefinition(),
|
||||
threeDayDefinition(),
|
||||
weekendDefinition(),
|
||||
stormDefinition(),
|
||||
@@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition {
|
||||
}
|
||||
|
||||
func (r Registry) All() []Definition {
|
||||
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
||||
ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm}
|
||||
out := make([]Definition, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if definition, ok := r.definitions[id]; ok {
|
||||
|
||||
@@ -305,6 +305,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00")
|
||||
savePriorMetadata(t, store, first, stateBriefingMetadata(first))
|
||||
|
||||
prior, err := store.FindPriorSnapshot(context.Background(), second)
|
||||
if err != nil {
|
||||
t.Fatalf("FindPriorSnapshot() error = %v", err)
|
||||
}
|
||||
if prior != nil {
|
||||
t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||
cfg := config.Defaults().Workspace
|
||||
cfg.Root = t.TempDir()
|
||||
@@ -390,6 +405,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveNearTermAt(t *testing.T, value string) report.Resolved {
|
||||
t.Helper()
|
||||
location, err := timeutil.LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
now, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata {
|
||||
return briefing.Metadata{
|
||||
RunID: resolved.Metadata().RunID,
|
||||
|
||||
Reference in New Issue
Block a user