Compare commits
7 Commits
26e6f33cde
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 63749a9572 | |||
| 9ff90d33fc | |||
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 |
50
.woodpecker/release.yml
Normal file
50
.woodpecker/release.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
- name: build-release-assets
|
||||
image: golang:1.25
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
version="$CI_COMMIT_TAG"
|
||||
dist="dist"
|
||||
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
|
||||
|
||||
rm -rf "$dist"
|
||||
mkdir -p "$dist"
|
||||
|
||||
build_binary() {
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
suffix="$3"
|
||||
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
|
||||
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
|
||||
-o "$output" "$pkg"
|
||||
}
|
||||
|
||||
build_binary linux amd64 ""
|
||||
build_binary linux arm64 ""
|
||||
build_binary darwin amd64 ""
|
||||
build_binary darwin arm64 ""
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
- name: publish-release
|
||||
image: woodpeckerci/plugin-release
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: GITEA_RELEASE_TOKEN
|
||||
files:
|
||||
- dist/weatherreporter-*
|
||||
checksum: sha256
|
||||
checksum-file: SHA256SUMS
|
||||
checksum-flatten: true
|
||||
file-exists: skip
|
||||
overwrite: false
|
||||
prerelease: false
|
||||
@@ -51,6 +51,19 @@ config test suite.
|
||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||
|
||||
### `location`
|
||||
|
||||
`location` is descriptive prompt context included in briefing metadata and
|
||||
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||
multiple configured forecast locations.
|
||||
|
||||
- `id`: short local identifier. Default: `home`.
|
||||
- `name`: human-readable location name. Default: `Brentwood`.
|
||||
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||
|
||||
The prompt-facing location object also includes `timezone`, derived from the
|
||||
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||
|
||||
### `missing_source`
|
||||
|
||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||
|
||||
@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
|
||||
}
|
||||
```
|
||||
|
||||
`data: null` is treated as a missing source. Missing optional sources follow the
|
||||
configured missing-source policy. Missing hourly forecast data fails bundle
|
||||
fetching because hourly periods are required for report generation.
|
||||
For most sources, `data: null` is treated as a missing source. Missing optional
|
||||
sources follow the configured missing-source policy. Missing hourly forecast
|
||||
data fails bundle fetching because hourly periods are required for report
|
||||
generation.
|
||||
|
||||
`/alerts/active` is the exception: a successful response with `data: null`
|
||||
means the endpoint was checked and there are no current active alerts. The
|
||||
adapter records a non-missing alerts source and an empty alert run.
|
||||
|
||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||
endpoint context in returned errors. Decode errors include source context when
|
||||
@@ -48,7 +53,8 @@ The adapter sends these query parameters:
|
||||
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast, and
|
||||
discussion requests
|
||||
|
||||
Alerts do not receive `precision` or `tz`.
|
||||
Alerts do not receive `precision` or `tz`. Weather story requests receive only
|
||||
`format=json`.
|
||||
|
||||
## Endpoints Used
|
||||
|
||||
@@ -60,6 +66,7 @@ The adapter fetches these endpoints once per bundle:
|
||||
- `/forecast/narrative`
|
||||
- `/alerts/active`
|
||||
- `/discussion`
|
||||
- `/weatherstories/latest`
|
||||
|
||||
`weatherreporter` does not call day-slice forecast endpoints or discussion
|
||||
subsection endpoints. Report-period selection and daypart summarization happen
|
||||
@@ -81,10 +88,11 @@ source-specific `missing_source.sources` policy:
|
||||
- `narrative` for `/forecast/narrative`
|
||||
- `alerts` for `/alerts/active`
|
||||
- `discussion` for `/discussion`
|
||||
- `weather_story` for `/weatherstories/latest`
|
||||
|
||||
The adapter also creates missing stub source records for `daily` and
|
||||
`weather_story` because those source slots exist in the internal bundle but are
|
||||
not fetched from the Weather API.
|
||||
The adapter also creates a missing stub source record for `daily` because that
|
||||
source slot exists in the internal bundle but is not fetched from the Weather
|
||||
API.
|
||||
|
||||
Policy behavior:
|
||||
|
||||
@@ -92,9 +100,14 @@ Policy behavior:
|
||||
- `warn`: omit the source data, add a warning, and continue
|
||||
- `none`: omit the source data and continue without a warning
|
||||
|
||||
For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
||||
follows the relevant error path, but explicit `data: null` is not a
|
||||
missing-source condition.
|
||||
|
||||
## Source Identity
|
||||
|
||||
For non-null source payloads, the adapter records:
|
||||
For source payloads accepted into the bundle, including the explicit `null`
|
||||
alerts payload, the adapter records:
|
||||
|
||||
- source name
|
||||
- endpoint path
|
||||
@@ -115,7 +128,9 @@ types in `internal/forecast/bundle.go`, including:
|
||||
- current condition values
|
||||
- forecast run metadata and `periods`
|
||||
- active alert run data
|
||||
- discussion metadata, key messages, and short/long-term sections
|
||||
- discussion metadata, key messages, and short/long-term section text
|
||||
- latest weather story title, description, timing, priority, order, alt text,
|
||||
and download URL
|
||||
|
||||
The adapter intentionally keeps upstream transport and envelope details inside
|
||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||
|
||||
@@ -16,12 +16,16 @@ Inputs:
|
||||
- resolved report definition, generation time, timezone, and valid period
|
||||
- forecast bundle with source provenance and warnings
|
||||
- derived daily or period summaries where required
|
||||
- configured units and timezone
|
||||
- configured units, timezone, and descriptive location context
|
||||
|
||||
Outputs:
|
||||
|
||||
- `briefing.Package` with common metadata and one report-specific content
|
||||
object for Daily, 3-Day, Weekend, or Storm Report
|
||||
- optional `currentConditions` prompt context from normalized
|
||||
`/conditions/current` data when available
|
||||
- optional structured `weatherStory` context on report-specific briefing
|
||||
objects when `/weatherstories/latest` is available
|
||||
- optional JSON file written by `briefing.Save`
|
||||
|
||||
## Boundaries
|
||||
@@ -34,6 +38,10 @@ Outputs:
|
||||
|
||||
The package receives configured units and timezone from the app layer. Daypart
|
||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||
Configured `location` values are prompt context only; Weather API
|
||||
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||
source only; observation station and timestamp fields remain provenance.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
@@ -74,6 +82,6 @@ Inspect:
|
||||
|
||||
- Briefings contain structured weather facts and source context.
|
||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||
provenance, source hashes, and source warnings.
|
||||
provenance, source hashes, source warnings, and configured prompt location.
|
||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||
boundary.
|
||||
|
||||
@@ -17,7 +17,12 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||
briefing content, Recent Changes, and source warnings
|
||||
briefing content, Recent Changes, and source warnings. Briefing content
|
||||
includes configured location context, current conditions when available,
|
||||
structured weather story context when available, discussion key messages, and
|
||||
short/long-term AFD narratives when the Weather API provides them.
|
||||
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||
`YYYY-MM-DD` in the effective report timezone.
|
||||
- optional JSON file written by `promptinput.Save`
|
||||
|
||||
## Boundaries
|
||||
@@ -28,8 +33,8 @@ Outputs:
|
||||
|
||||
## Config Fields Used
|
||||
|
||||
None directly. Config-derived values are already present in briefing metadata
|
||||
before this package runs.
|
||||
None directly. Config-derived values, including timezone and prompt location
|
||||
context, are already present in briefing metadata before this package runs.
|
||||
|
||||
## External Adapters Used
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ valid-period start date for JSON artifacts, and the RunID.
|
||||
```
|
||||
|
||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||
package, preflight, and report paths. Report listing walks metadata files under
|
||||
the snapshots directory.
|
||||
package, preflight, report paths, and configured prompt location. Report
|
||||
listing walks metadata files under the snapshots directory.
|
||||
|
||||
## Prior Lookup
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ This document describes Weather API ingestion into `forecast.Bundle`.
|
||||
|
||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||
derivation and briefing builders.
|
||||
derivation and briefing builders. Briefing builders expose normalized current
|
||||
conditions and weather story context when those sources are available.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -19,9 +20,9 @@ Inputs:
|
||||
Outputs:
|
||||
|
||||
- `forecast.Bundle` with observation, current conditions, hourly forecast,
|
||||
narrative forecast, active alerts, discussion, source records, and source
|
||||
warnings
|
||||
- stub source records for daily forecast and weather story source slots
|
||||
narrative forecast, active alerts, discussion, latest weather story, source
|
||||
records, and source warnings
|
||||
- stub source record for the daily forecast source slot
|
||||
- optional saved bundle JSON through app fetch helpers
|
||||
|
||||
## Boundaries
|
||||
@@ -53,8 +54,9 @@ contract used by this project.
|
||||
|
||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||
source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for
|
||||
inspection.
|
||||
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||
represent a checked empty active-alert list, not a missing source.
|
||||
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||
|
||||
## Skip And Resume Behavior
|
||||
|
||||
@@ -69,6 +71,8 @@ data is required and cannot be skipped.
|
||||
endpoint context.
|
||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||
- Optional and stub sources follow missing-source policy.
|
||||
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||
alert run.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ evaluation remains deferred.
|
||||
Proposed direction:
|
||||
|
||||
1. detect candidate events deterministically from alerts, forecast discussion,
|
||||
weather story context when available, hourly thresholds, and material
|
||||
forecast changes;
|
||||
weather story context, hourly thresholds, and material forecast changes;
|
||||
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
|
||||
3. persist storm lifecycle state;
|
||||
4. generate or update Storm Reports only when a meaningful event is present;
|
||||
|
||||
@@ -6,6 +6,11 @@ weather_api:
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
|
||||
location:
|
||||
id: home
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
|
||||
@@ -109,10 +109,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
|
||||
if err := builder.fetchDiscussion(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||
if err := builder.fetchWeatherStory(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
|
||||
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -202,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||
}
|
||||
if isJSONNull(raw) {
|
||||
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
var alerts forecast.AlertRun
|
||||
if err := decodeSource(raw, &alerts); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
@@ -241,6 +246,27 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
|
||||
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw == nil {
|
||||
return b.handleMissing(&source, "NWS weather story data is missing", false)
|
||||
}
|
||||
var story forecast.WeatherStory
|
||||
if err := decodeSource(raw, &story); err != nil {
|
||||
return b.handleMalformed(&source, err, false)
|
||||
}
|
||||
if !story.StartTime.IsZero() {
|
||||
source.IssuedAt = &story.StartTime
|
||||
}
|
||||
source.UpdatedAt = story.UpdatedAt
|
||||
b.bundle.WeatherStory = &story
|
||||
b.addSource(source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *bundleBuilder) addStub(sourceName string, message string) error {
|
||||
source := forecast.Source{
|
||||
Name: sourceName,
|
||||
@@ -301,6 +327,8 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
||||
type queryOptions struct {
|
||||
precision bool
|
||||
timezone bool
|
||||
allowNull bool
|
||||
omitUnits bool
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
@@ -339,7 +367,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
Query: queryMap(reqURL.Query()),
|
||||
FetchedAt: c.now(),
|
||||
}
|
||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
||||
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||
source.Missing = true
|
||||
return nil, source, nil
|
||||
}
|
||||
@@ -351,12 +379,18 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
||||
return env.Data, source, nil
|
||||
}
|
||||
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||
}
|
||||
|
||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||
reqURL := *c.baseURL
|
||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||
query := reqURL.Query()
|
||||
query.Set("format", c.format)
|
||||
query.Set("units", c.units)
|
||||
if !opts.omitUnits {
|
||||
query.Set("units", c.units)
|
||||
}
|
||||
if opts.precision {
|
||||
query.Set("precision", strconv.Itoa(c.precision))
|
||||
}
|
||||
|
||||
@@ -43,11 +43,23 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
||||
}
|
||||
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
|
||||
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
|
||||
}
|
||||
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||
}
|
||||
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
|
||||
}
|
||||
if bundle.WeatherStory.UpdatedAt == nil {
|
||||
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
|
||||
}
|
||||
if len(bundle.Sources) != 8 {
|
||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||
}
|
||||
if len(bundle.Warnings) != 2 {
|
||||
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
|
||||
if len(bundle.Warnings) != 1 {
|
||||
t.Fatalf("Warnings length = %d, want daily warning", len(bundle.Warnings))
|
||||
}
|
||||
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
|
||||
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
|
||||
@@ -55,6 +67,9 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
||||
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
|
||||
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
|
||||
}
|
||||
if !containsPath(requested, "/weatherstories/latest") {
|
||||
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
@@ -68,8 +83,17 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, rawURL := range requested {
|
||||
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
||||
if !strings.Contains(rawURL, "format=json") {
|
||||
t.Fatalf("request %q missing format=json", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/weatherstories/") {
|
||||
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
|
||||
t.Fatalf("weather story request %q should use format only", rawURL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rawURL, "units=us") {
|
||||
t.Fatalf("request %q missing units=us", rawURL)
|
||||
}
|
||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
@@ -93,6 +117,16 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
|
||||
if observation.DataSHA256 != want {
|
||||
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
|
||||
}
|
||||
story := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if story.Endpoint != "/weatherstories/latest" {
|
||||
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
|
||||
}
|
||||
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
|
||||
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
|
||||
}
|
||||
if story.IssuedAt == nil || story.UpdatedAt == nil {
|
||||
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsActionable(t *testing.T) {
|
||||
@@ -125,6 +159,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", nil)
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.Alerts == nil {
|
||||
t.Fatal("Alerts = nil, want checked empty alert run")
|
||||
}
|
||||
if len(bundle.Alerts.Alerts) != 0 {
|
||||
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "alerts")
|
||||
if source.Missing {
|
||||
t.Fatalf("alerts source Missing = true, want false")
|
||||
}
|
||||
if source.DataSHA256 == "" {
|
||||
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Source == "alerts" {
|
||||
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -133,7 +197,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||
wantWarns int
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
|
||||
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 2, wantSource: true},
|
||||
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
|
||||
{name: "error", policy: config.MissingSourceError, wantErr: true},
|
||||
}
|
||||
@@ -194,6 +258,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
if bundle.WeatherStory != nil {
|
||||
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 {
|
||||
t.Fatalf("weather_story source = %#v, want missing source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
|
||||
server := fixtureServer(t, map[string]handlerOverride{
|
||||
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
|
||||
}, nil)
|
||||
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
|
||||
"weather_story": config.MissingSourceWarn,
|
||||
})
|
||||
|
||||
bundle, err := client.FetchBundle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
source := sourceByName(t, bundle.Sources, "weather_story")
|
||||
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
|
||||
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
@@ -260,12 +363,13 @@ type handlerOverride struct {
|
||||
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
|
||||
t.Helper()
|
||||
fixtures := map[string]string{
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/observations": "observations.json",
|
||||
"/conditions/current": "current.json",
|
||||
"/forecast/hourly": "hourly.json",
|
||||
"/forecast/narrative": "narrative.json",
|
||||
"/alerts/active": "alerts.json",
|
||||
"/discussion": "discussion.json",
|
||||
"/weatherstories/latest": "weather_story.json",
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requested != nil {
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
"Warmer temperatures this weekend."
|
||||
],
|
||||
"shortTerm": {
|
||||
"title": "Short Term",
|
||||
"narrative": "A weak boundary may trigger isolated showers."
|
||||
"qualifier": "(Through This Evening)",
|
||||
"text": "A weak boundary may trigger isolated showers."
|
||||
},
|
||||
"longTerm": {
|
||||
"qualifier": "(This Weekend)",
|
||||
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
14
internal/adapters/weatherapi/testdata/weather_story.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": {
|
||||
"officeId": "LSX",
|
||||
"startTime": "2026-05-30T08:46:00Z",
|
||||
"endTime": "2026-05-31T11:00:00Z",
|
||||
"updatedAt": "2026-05-30T09:00:34Z",
|
||||
"title": "Several Chances for Rain Through Monday",
|
||||
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
|
||||
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
|
||||
"priority": false,
|
||||
"order": 1,
|
||||
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summary)
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||
@@ -534,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
@@ -541,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
}, summaries)
|
||||
case report.Storm:
|
||||
return briefing.BuildStorm(briefing.BuildContext{
|
||||
@@ -548,12 +551,26 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
Location: briefingLocation(req.Config),
|
||||
})
|
||||
default:
|
||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||
location := briefing.LocationContext{
|
||||
ID: cfg.Location.ID,
|
||||
Name: cfg.Location.Name,
|
||||
Region: cfg.Location.Region,
|
||||
Timezone: cfg.WeatherAPI.Timezone,
|
||||
}
|
||||
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
|
||||
return nil
|
||||
}
|
||||
return &location
|
||||
}
|
||||
|
||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||
return state.NewFilesystemStore(cfg.Workspace)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -33,7 +34,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
||||
case "/alerts/active":
|
||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||
case "/discussion":
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -58,6 +61,9 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
||||
if !strings.Contains(string(data), `"product": "hourly"`) {
|
||||
t.Fatalf("saved bundle missing hourly product:\n%s", string(data))
|
||||
}
|
||||
if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) {
|
||||
t.Fatalf("saved bundle missing weather story title:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
@@ -195,6 +201,32 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||
}
|
||||
var savedDataPackage struct {
|
||||
Report struct {
|
||||
CurrentLocalDate string `json:"currentLocalDate"`
|
||||
} `json:"report"`
|
||||
Briefing briefing.Package `json:"briefing"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
|
||||
t.Fatalf("decode data package: %v", err)
|
||||
}
|
||||
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
||||
}
|
||||
location := savedDataPackage.Briefing.Metadata.Location
|
||||
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("data package location = %#v, want configured prompt location", location)
|
||||
}
|
||||
current := savedDataPackage.Briefing.CurrentConditions
|
||||
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
||||
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
||||
}
|
||||
if savedDataPackage.Briefing.Daily == nil || savedDataPackage.Briefing.Daily.WeatherStory == nil || savedDataPackage.Briefing.Daily.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Daily)
|
||||
}
|
||||
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||
}
|
||||
preflight, err := os.ReadFile(result.PreflightPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read preflight: %v", err)
|
||||
@@ -873,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
case "/observations":
|
||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||
case "/conditions/current":
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
||||
case "/forecast/hourly":
|
||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||
case "/forecast/narrative":
|
||||
@@ -881,7 +913,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
||||
case "/alerts/active":
|
||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
||||
case "/discussion":
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
@@ -57,8 +58,17 @@ type DiscussionContext struct {
|
||||
}
|
||||
|
||||
type WeatherStoryContext struct {
|
||||
Available bool `json:"available"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
OfficeID string `json:"officeId,omitempty"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"altText,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
|
||||
@@ -68,19 +78,18 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
||||
if summary == nil {
|
||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Daily: &Daily{
|
||||
BottomLine: buildBottomLine(summary),
|
||||
Dayparts: summary.Dayparts,
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
NarrativePeriods: summary.NarrativePeriods,
|
||||
Discussion: buildDiscussion(summary.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
ForecastSummaryDate: summary.Date,
|
||||
},
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Daily = &Daily{
|
||||
BottomLine: buildBottomLine(summary),
|
||||
Dayparts: summary.Dayparts,
|
||||
RelevantAlerts: summary.AlertOverlaps,
|
||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||
NarrativePeriods: summary.NarrativePeriods,
|
||||
Discussion: buildDiscussion(summary.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
ForecastSummaryDate: summary.Date,
|
||||
}
|
||||
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||
}
|
||||
@@ -250,19 +259,40 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
||||
KeyMessages: discussion.KeyMessages,
|
||||
}
|
||||
if discussion.ShortTerm != nil {
|
||||
ctx.ShortTerm = discussion.ShortTerm.Narrative
|
||||
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||
}
|
||||
if discussion.LongTerm != nil {
|
||||
ctx.LongTerm = discussion.LongTerm.Narrative
|
||||
ctx.LongTerm = discussion.LongTerm.Text
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
|
||||
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
|
||||
if bundle == nil || bundle.WeatherStory == nil {
|
||||
return nil
|
||||
}
|
||||
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
|
||||
story := bundle.WeatherStory
|
||||
return &WeatherStoryContext{
|
||||
Available: true,
|
||||
OfficeID: story.OfficeID,
|
||||
StartTime: story.StartTime,
|
||||
EndTime: story.EndTime,
|
||||
UpdatedAt: copyTime(story.UpdatedAt),
|
||||
Title: story.Title,
|
||||
Description: story.Description,
|
||||
AltText: story.AltText,
|
||||
Priority: story.Priority,
|
||||
Order: story.Order,
|
||||
DownloadURL: story.DownloadURL,
|
||||
}
|
||||
}
|
||||
|
||||
func copyTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
|
||||
@@ -15,6 +15,19 @@ import (
|
||||
|
||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
bundle := loadBundleFixture(t)
|
||||
currentIsDay := true
|
||||
currentTemp := 75.9
|
||||
currentFeelsLike := 76.1
|
||||
currentHumidity := 56.0
|
||||
currentWind := 10.7
|
||||
bundle.Current = &forecast.Current{
|
||||
ConditionText: "Partly cloudy",
|
||||
IsDay: ¤tIsDay,
|
||||
TemperatureF: ¤tTemp,
|
||||
ApparentTemperatureF: ¤tFeelsLike,
|
||||
RelativeHumidityPercent: ¤tHumidity,
|
||||
WindSpeedMph: ¤tWind,
|
||||
}
|
||||
bundle.Sources[0].DataSHA256 = "abc123"
|
||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||
location := mustLocation(t)
|
||||
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
Bundle: bundle,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
}, summary)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDaily() error = %v", err)
|
||||
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||
}
|
||||
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||
}
|
||||
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||
}
|
||||
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||
}
|
||||
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||
}
|
||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||
}
|
||||
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||
}},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
Alerts: &forecast.AlertRun{},
|
||||
Sources: []forecast.Source{
|
||||
{Name: "hourly", FetchedAt: time.Now()},
|
||||
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||
},
|
||||
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||
}
|
||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||
if err != nil {
|
||||
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||
}
|
||||
if pkg.CurrentConditions != nil {
|
||||
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||
}
|
||||
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||
}
|
||||
if pkg.Metadata.Alerts == nil {
|
||||
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||
}
|
||||
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||
}
|
||||
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal alert metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), `"missing"`) {
|
||||
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||
|
||||
@@ -14,11 +14,12 @@ import (
|
||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
Storm *Storm `json:"storm,omitempty"`
|
||||
Metadata Metadata `json:"metadata"`
|
||||
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
Storm *Storm `json:"storm,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
@@ -31,10 +32,34 @@ type Metadata struct {
|
||||
Units string `json:"units"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
type LocationContext struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentConditionsContext struct {
|
||||
ConditionText string `json:"conditionText,omitempty"`
|
||||
IsDay *bool `json:"isDay,omitempty"`
|
||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
||||
TemperatureF *float64 `json:"temperatureF,omitempty"`
|
||||
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
|
||||
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
|
||||
DewpointC *float64 `json:"dewpointC,omitempty"`
|
||||
DewpointF *float64 `json:"dewpointF,omitempty"`
|
||||
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
||||
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
||||
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
||||
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
||||
}
|
||||
|
||||
type SourceMetadata struct {
|
||||
@@ -48,11 +73,19 @@ type SourceMetadata struct {
|
||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type AlertStatus struct {
|
||||
Checked bool `json:"checked"`
|
||||
ActiveCount int `json:"activeCount"`
|
||||
RelevantCount int `json:"relevantCount"`
|
||||
Missing bool `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
type BuildContext struct {
|
||||
Resolved report.Resolved
|
||||
Bundle *forecast.Bundle
|
||||
Units string
|
||||
Timezone string
|
||||
Location *LocationContext
|
||||
}
|
||||
|
||||
func BuildMetadata(ctx BuildContext) Metadata {
|
||||
@@ -68,13 +101,82 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
||||
Units: ctx.Units,
|
||||
Timezone: ctx.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(ctx.Location),
|
||||
SourceLocationID: sourceLocationID,
|
||||
SourceLocation: sourceLocation,
|
||||
Sources: sourceMetadata(ctx.Bundle),
|
||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||
Alerts: alertStatus(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func buildPackage(ctx BuildContext) Package {
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
CurrentConditions: currentConditions(ctx.Bundle),
|
||||
}
|
||||
}
|
||||
|
||||
func copyLocation(location *LocationContext) *LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *location
|
||||
return &copied
|
||||
}
|
||||
|
||||
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||
if bundle == nil || bundle.Current == nil {
|
||||
return nil
|
||||
}
|
||||
current := bundle.Current
|
||||
context := CurrentConditionsContext{
|
||||
ConditionText: current.ConditionText,
|
||||
IsDay: copyBool(current.IsDay),
|
||||
TemperatureC: copyFloat(current.TemperatureC),
|
||||
TemperatureF: copyFloat(current.TemperatureF),
|
||||
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||
DewpointC: copyFloat(current.DewpointC),
|
||||
DewpointF: copyFloat(current.DewpointF),
|
||||
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||
}
|
||||
if context.ConditionText == "" &&
|
||||
context.IsDay == nil &&
|
||||
context.TemperatureC == nil &&
|
||||
context.TemperatureF == nil &&
|
||||
context.ApparentTemperatureC == nil &&
|
||||
context.ApparentTemperatureF == nil &&
|
||||
context.DewpointC == nil &&
|
||||
context.DewpointF == nil &&
|
||||
context.RelativeHumidityPercent == nil &&
|
||||
context.WindSpeedKmh == nil &&
|
||||
context.WindSpeedMph == nil &&
|
||||
context.WindDirectionDegrees == nil {
|
||||
return nil
|
||||
}
|
||||
return &context
|
||||
}
|
||||
|
||||
func copyBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func copyFloat(value *float64) *float64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *value
|
||||
return &copied
|
||||
}
|
||||
|
||||
func Save(path string, pkg Package) error {
|
||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||
return fmt.Errorf("save briefing package: %w", err)
|
||||
@@ -124,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
||||
return bundle.Warnings
|
||||
}
|
||||
|
||||
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
status := &AlertStatus{}
|
||||
if bundle.Alerts != nil {
|
||||
status.Checked = true
|
||||
status.ActiveCount = len(bundle.Alerts.Alerts)
|
||||
}
|
||||
for _, source := range bundle.Sources {
|
||||
if source.Name == "alerts" && source.Missing {
|
||||
status.Missing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !status.Checked && !status.Missing {
|
||||
return nil
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func setRelevantAlertCount(metadata *Metadata, count int) {
|
||||
if metadata.Alerts == nil {
|
||||
if count == 0 {
|
||||
return
|
||||
}
|
||||
metadata.Alerts = &AlertStatus{}
|
||||
}
|
||||
metadata.Alerts.RelevantCount = count
|
||||
}
|
||||
|
||||
func variantForReport(id report.ID) string {
|
||||
switch id {
|
||||
case report.DailyToday:
|
||||
|
||||
@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
return Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Storm: storm,
|
||||
}, nil
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Storm = storm
|
||||
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||
@@ -158,12 +158,16 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
||||
}
|
||||
if bundle.Discussion != nil {
|
||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
|
||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||
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.")
|
||||
if bundle.WeatherStory != nil {
|
||||
if bundle.WeatherStory.Title != "" {
|
||||
items = appendUnique(items, "Weather story: "+bundle.WeatherStory.Title+".")
|
||||
} else {
|
||||
items = appendUnique(items, "Weather story source is available.")
|
||||
}
|
||||
}
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
|
||||
@@ -44,9 +44,22 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
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()}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Storms may intensify quickly."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||
},
|
||||
WeatherStory: &forecast.WeatherStory{
|
||||
OfficeID: "LSX",
|
||||
StartTime: mustParse("2026-05-29T06:00:00Z"),
|
||||
EndTime: mustParse("2026-05-29T18:00:00Z"),
|
||||
Title: "Storm Risk",
|
||||
Description: "Strong storms are possible.",
|
||||
AltText: "Weather story graphic showing storm risk.",
|
||||
Order: 1,
|
||||
},
|
||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||
}
|
||||
|
||||
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
|
||||
@@ -75,6 +88,15 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
||||
if pkg.Storm.WeatherStory == nil {
|
||||
t.Fatal("WeatherStory = nil, want available story context")
|
||||
}
|
||||
if pkg.Storm.WeatherStory.Title != "Storm Risk" || pkg.Storm.WeatherStory.Description != "Strong storms are possible." {
|
||||
t.Fatalf("WeatherStory = %#v, want structured story context", pkg.Storm.WeatherStory)
|
||||
}
|
||||
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||
}
|
||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||
}
|
||||
|
||||
@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
ThreeDay: &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.ThreeDay = &ThreeDay{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
day := buildOutlookDay(summary)
|
||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,12 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Unsettled stretch."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
@@ -80,4 +85,10 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Weekend: &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
pkg := buildPackage(ctx)
|
||||
pkg.Weekend = &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
||||
Discussion: &forecast.Discussion{
|
||||
Product: "discussion",
|
||||
KeyMessages: []string{"Timing may shift."},
|
||||
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
||||
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -85,4 +90,10 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||
}
|
||||
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
"generate", "daily",
|
||||
"--config", configPath,
|
||||
"--date", "2026-05-29",
|
||||
"--tz", "UTC",
|
||||
"--out", outPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
@@ -463,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
||||
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||
}
|
||||
var decoded struct {
|
||||
Briefing struct {
|
||||
Metadata struct {
|
||||
Location struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"location"`
|
||||
} `json:"metadata"`
|
||||
} `json:"briefing"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode data package: %v", err)
|
||||
}
|
||||
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
||||
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
||||
}
|
||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob preflight: %v", err)
|
||||
@@ -794,6 +813,8 @@ func dailyServer(t *testing.T) *httptest.Server {
|
||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||
case "/discussion":
|
||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
||||
case "/weatherstories/latest":
|
||||
_, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
|
||||
type Config struct {
|
||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||
Location LocationConfig `yaml:"location"`
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
@@ -30,6 +31,12 @@ type WeatherAPIConfig struct {
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
type LocationConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Region string `yaml:"region"`
|
||||
}
|
||||
|
||||
type MissingSourceConfig struct {
|
||||
Default MissingSourcePolicy `yaml:"default"`
|
||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.WeatherAPI.Format != "json" {
|
||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||
}
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||
}
|
||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||
}
|
||||
@@ -43,6 +46,9 @@ func TestLoadExampleConfig(t *testing.T) {
|
||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
||||
}
|
||||
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||
@@ -63,6 +69,9 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||
if cfg.Workspace.Root != "workspace" {
|
||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||
}
|
||||
if cfg.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,11 @@ func Defaults() Config {
|
||||
Timezone: "America/Chicago",
|
||||
Format: "json",
|
||||
},
|
||||
Location: LocationConfig{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
},
|
||||
MissingSource: MissingSourceConfig{
|
||||
Default: MissingSourceWarn,
|
||||
Sources: map[string]MissingSourcePolicy{},
|
||||
|
||||
@@ -149,13 +149,20 @@ type Discussion struct {
|
||||
}
|
||||
|
||||
type DiscussionSection struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Narrative string `json:"narrative,omitempty"`
|
||||
Qualifier string `json:"qualifier,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
}
|
||||
|
||||
type WeatherStory struct {
|
||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
OfficeID string `json:"officeId,omitempty"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AltText string `json:"altText,omitempty"`
|
||||
Priority bool `json:"priority"`
|
||||
Order int `json:"order"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
10
internal/forecast/testdata/daily_bundle.json
vendored
10
internal/forecast/testdata/daily_bundle.json
vendored
@@ -54,7 +54,15 @@
|
||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||
"keyMessages": [
|
||||
"Storms are most likely during the morning."
|
||||
]
|
||||
],
|
||||
"shortTerm": {
|
||||
"qualifier": "(Through This Evening)",
|
||||
"text": "Morning showers taper as a weak boundary shifts east."
|
||||
},
|
||||
"longTerm": {
|
||||
"qualifier": "(This Weekend)",
|
||||
"text": "Warmer and more humid conditions return with periodic rain chances."
|
||||
}
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
|
||||
@@ -25,12 +25,13 @@ type Package struct {
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
ID report.ID `json:"id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
ID report.ID `json:"id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
CurrentLocalDate string `json:"currentLocalDate"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
}
|
||||
|
||||
type RecentChanges struct {
|
||||
@@ -42,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
|
||||
}
|
||||
|
||||
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
items := make([]changes.Change, len(recentChanges))
|
||||
copy(items, recentChanges)
|
||||
if items == nil {
|
||||
@@ -51,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
||||
SchemaVersion: SchemaVersion,
|
||||
RunID: briefingPackage.Metadata.RunID,
|
||||
Report: Report{
|
||||
ID: briefingPackage.Metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
PromptID: briefingPackage.Metadata.PromptID,
|
||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||
Timezone: briefingPackage.Metadata.Timezone,
|
||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||
ID: briefingPackage.Metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
PromptID: briefingPackage.Metadata.PromptID,
|
||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||
Timezone: briefingPackage.Metadata.Timezone,
|
||||
CurrentLocalDate: localDate,
|
||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||
},
|
||||
Briefing: briefingPackage,
|
||||
RecentChanges: RecentChanges{Items: items},
|
||||
@@ -68,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
|
||||
location, err := timeutil.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
|
||||
}
|
||||
return generatedAt.In(location).Format(timeutil.DateLayout), nil
|
||||
}
|
||||
|
||||
func Validate(pkg Package) error {
|
||||
if pkg.SchemaVersion == "" {
|
||||
return fmt.Errorf("schemaVersion is required")
|
||||
@@ -87,6 +101,9 @@ func Validate(pkg Package) error {
|
||||
if pkg.Report.Timezone == "" {
|
||||
return fmt.Errorf("report.timezone is required")
|
||||
}
|
||||
if pkg.Report.CurrentLocalDate == "" {
|
||||
return fmt.Errorf("report.currentLocalDate is required")
|
||||
}
|
||||
if !pkg.Report.ValidPeriod.IsValid() {
|
||||
return fmt.Errorf("report.validPeriod must be valid")
|
||||
}
|
||||
|
||||
@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
||||
if pkg.Report.PromptID != "weather.daily_report" {
|
||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||
}
|
||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||
}
|
||||
if pkg.Briefing.Daily == nil {
|
||||
t.Fatal("Briefing.Daily = nil")
|
||||
}
|
||||
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
|
||||
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
|
||||
}
|
||||
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
|
||||
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
|
||||
}
|
||||
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
||||
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||
briefingPackage.Metadata.Timezone = "America/Chicago"
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.Timezone = "Not/AZone"
|
||||
|
||||
_, err := Build(briefingPackage)
|
||||
if err == nil {
|
||||
t.Fatal("Build() error = nil, want invalid timezone error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "report timezone") {
|
||||
t.Fatalf("error = %q, want report timezone context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresFields(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
if err != nil {
|
||||
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
pkg.Report.CurrentLocalDate = ""
|
||||
|
||||
err = Validate(pkg)
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want required field error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "currentLocalDate") {
|
||||
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
|
||||
GeneratedAt: generatedAt,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
Location: &briefing.LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
ValidPeriod: timeutil.Period{
|
||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
CurrentConditions: &briefing.CurrentConditionsContext{
|
||||
ConditionText: "Partly cloudy",
|
||||
},
|
||||
Daily: &briefing.Daily{
|
||||
ForecastSummaryDate: "2026-05-29",
|
||||
},
|
||||
|
||||
@@ -122,6 +122,9 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
||||
if decoded.RenderedReportPath != renderedReportPath {
|
||||
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
||||
}
|
||||
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
|
||||
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
|
||||
}
|
||||
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||
}
|
||||
@@ -437,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
GeneratedAt: resolved.GeneratedAt,
|
||||
Units: "us",
|
||||
Timezone: resolved.Timezone,
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
Location: &briefing.LocationContext{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
Timezone: resolved.Timezone,
|
||||
},
|
||||
ValidPeriod: resolved.ValidPeriod,
|
||||
},
|
||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Metadata struct {
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Timezone string `json:"timezone"`
|
||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||
@@ -43,6 +44,7 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
||||
GeneratedAt: metadata.GeneratedAt,
|
||||
Timezone: metadata.Timezone,
|
||||
ValidPeriod: metadata.ValidPeriod,
|
||||
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||
Sources: briefingPackage.Metadata.Sources,
|
||||
@@ -53,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
||||
RenderedReportPath: paths.RenderedReport,
|
||||
}
|
||||
}
|
||||
|
||||
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||
if location == nil {
|
||||
return nil
|
||||
}
|
||||
copied := *location
|
||||
return &copied
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user