Compare commits
11 Commits
8089f62806
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 63749a9572 | |||
| 9ff90d33fc | |||
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 | |||
| 26e6f33cde | |||
| 1bc0739d31 | |||
| 8476dab844 | |||
| 745992886c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Compiled application binary
|
||||
# Compiled application binary and testing workspace
|
||||
/weatherreporter
|
||||
/workspace
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
|
||||
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
|
||||
@@ -45,12 +45,25 @@ config test suite.
|
||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `precision`: numeric precision query value. Default: `1`.
|
||||
- `units`: Weather API units query value. Default: `us`.
|
||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `Chicago`.
|
||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||
|
||||
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`.
|
||||
@@ -88,7 +101,7 @@ Each entry has:
|
||||
- `end`
|
||||
|
||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
||||
afternoon, and evening.
|
||||
midday, afternoon, and evening.
|
||||
|
||||
### `recent_change`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,553 +0,0 @@
|
||||
# Code Quality And Deduplication Audit
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
Overall code quality is good. The implementation is small, direct, and aligned
|
||||
with the documented architecture: CLI parsing is isolated in `internal/cli`,
|
||||
configuration is centralized in `internal/config`, report definitions live in
|
||||
`internal/report`, external systems sit behind adapters, and most domain logic
|
||||
is covered by focused tests.
|
||||
|
||||
The codebase appears ready for a limited cleanup pass before the next major
|
||||
release. I did not find a major architectural risk that requires a broad
|
||||
rewrite.
|
||||
|
||||
Top three refactoring targets:
|
||||
|
||||
1. Centralize atomic file and JSON artifact writes. Similar write patterns now
|
||||
exist in state, briefing, prompt input, Weather API bundle saving,
|
||||
Scriptorium render saving, and report-copy code.
|
||||
2. Move report artifact naming, grouping, and compatibility policy closer to
|
||||
the report registry. Related decisions are split across `internal/report`,
|
||||
`internal/state`, and `internal/app`.
|
||||
3. Reduce repeated CLI/app inspection and config-loading scaffolding. The
|
||||
inspect commands repeat parse/load/call/write patterns, and app inspection
|
||||
repeats store and metadata lookup flows.
|
||||
|
||||
Recommended cleanup should be incremental and behavior-preserving. Avoid a
|
||||
generic workflow engine, plugin layer, CLI redesign, or broad storage rewrite.
|
||||
|
||||
## 2. Repository map reviewed
|
||||
|
||||
Reviewed documentation and policy:
|
||||
|
||||
- `README.md`
|
||||
- `docs/policy/architecture.md`
|
||||
- `docs/policy/development.md`
|
||||
- `docs/config.md`
|
||||
- `docs/cli.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/troubleshooting.md`
|
||||
- `docs/internal/*`
|
||||
- `docs/integrations/*`
|
||||
- `docs/roadmap/*`
|
||||
- `examples/config.yml`
|
||||
- `examples/minimal-config.yml`
|
||||
|
||||
Reviewed implementation packages:
|
||||
|
||||
- `cmd/weatherreporter`
|
||||
- `internal/cli`
|
||||
- `internal/config`
|
||||
- `internal/app`
|
||||
- `internal/report`
|
||||
- `internal/forecast`
|
||||
- `internal/briefing`
|
||||
- `internal/promptinput`
|
||||
- `internal/changes`
|
||||
- `internal/state`
|
||||
- `internal/timeutil`
|
||||
- `internal/adapters/weatherapi`
|
||||
- `internal/adapters/scriptorium`
|
||||
|
||||
Reviewed major execution paths:
|
||||
|
||||
- `weatherreporter generate daily`
|
||||
- `weatherreporter generate tomorrow`
|
||||
- `weatherreporter generate three-day`
|
||||
- `weatherreporter generate weekend`
|
||||
- `weatherreporter generate storm`
|
||||
- `weatherreporter run morning`
|
||||
- `weatherreporter run evening`
|
||||
- `weatherreporter inspect reports`
|
||||
- `weatherreporter inspect metadata`
|
||||
- `weatherreporter inspect briefing`
|
||||
- `weatherreporter inspect data-package`
|
||||
- `weatherreporter inspect prior`
|
||||
- `weatherreporter inspect sources`
|
||||
|
||||
Reviewed tests:
|
||||
|
||||
- CLI parser and workflow tests under `internal/cli`
|
||||
- config/default/example tests under `internal/config`
|
||||
- app workflow tests under `internal/app`
|
||||
- adapter tests under `internal/adapters/*`
|
||||
- state path and prior-snapshot tests under `internal/state`
|
||||
- report period tests under `internal/report`
|
||||
- package-level tests for forecast, briefing, prompt input, and changes
|
||||
|
||||
Areas not present in this repository: `internal/stage`, `internal/modules`,
|
||||
`internal/validators`, `internal/storage`, `internal/artifacts`,
|
||||
`internal/manifest`, `internal/schema`, `pkg`, remote storage, object-store
|
||||
keys, and persistent progress manifests.
|
||||
|
||||
## 3. High-confidence deduplication opportunities
|
||||
|
||||
### Centralize atomic file and JSON artifact writes
|
||||
|
||||
- Affected files/packages: `internal/state/filesystem.go`,
|
||||
`internal/briefing/package.go`, `internal/promptinput/package.go`,
|
||||
`internal/adapters/weatherapi/client.go`,
|
||||
`internal/adapters/scriptorium/runner.go`, `internal/app/app.go`.
|
||||
- Duplicated or near-duplicated behavior: multiple functions marshal JSON,
|
||||
create parent directories, create temp files in the target directory, write,
|
||||
close, rename, and defer temp-file cleanup. `copyFileAtomic` repeats the
|
||||
same write path for Markdown report copies. `scriptorium.SaveRenderResult`
|
||||
duplicates state preflight saving and does not appear to be used by the app.
|
||||
- Why it matters: atomic write behavior is part of the state and recovery
|
||||
contract. A future bug fix around permissions, fsync behavior, temp-file
|
||||
cleanup, Windows rename behavior, or error context would need to be applied
|
||||
in several places.
|
||||
- Recommended refactor: add a small internal file helper, likely under
|
||||
`internal/state` if kept state-specific or a narrow `internal/fileutil`
|
||||
package if used by adapters too. Provide helpers such as `WriteFileAtomic`,
|
||||
`WriteJSONAtomic`, and possibly `CopyFileAtomic`. Then remove unused
|
||||
duplicate save helpers or route them through the shared helper.
|
||||
- Suggested tests: keep existing state, app, adapter, briefing, and prompt input
|
||||
save tests. Add one focused helper test for parent-directory creation,
|
||||
overwrite behavior, and temp-file cleanup on write errors if the helper is in
|
||||
a new package.
|
||||
- Risk level: low. This is a behavior-preserving mechanical cleanup if error
|
||||
messages are kept compatible where tests assert them.
|
||||
|
||||
### Centralize report artifact grouping, naming, and compatibility policy
|
||||
|
||||
- Affected files/packages: `internal/report`, `internal/state/filesystem.go`,
|
||||
`internal/app/app.go`, state and app tests.
|
||||
- Duplicated or near-duplicated behavior: `internal/report` owns report IDs,
|
||||
prompt IDs, default output names, batches, and comparison strategies.
|
||||
`internal/state` separately maps report IDs to artifact groups such as
|
||||
`daily`, `three-day`, `weekend`, and `storm`. `internal/app` separately
|
||||
converts `DefaultOutputName` underscores to hyphens for batch output copies.
|
||||
`internal/state` also hardcodes compatible prior report matching instead of
|
||||
asking the report catalog.
|
||||
- Why it matters: adding or renaming a report requires updates in several
|
||||
packages. A bug in grouping or compatibility could affect state lookup,
|
||||
Recent Changes, managed paths, and batch output names.
|
||||
- Recommended refactor: extend `report.Definition` or add registry helpers for
|
||||
artifact group, managed output group, batch copy filename, and compatible
|
||||
prior report IDs. Keep filesystem path joining in `internal/state`, but move
|
||||
report identity policy out of state/app. Preserve current path strings and
|
||||
output filenames.
|
||||
- Suggested tests: strengthen `internal/report` tests to assert each definition
|
||||
exposes group, copy filename, and compatibility policy. Keep existing
|
||||
`internal/state` path tests and batch output tests as regression coverage.
|
||||
- Risk level: medium-low. The behavior is user-visible through artifact paths
|
||||
and `--out-dir` names, so preserve exact strings.
|
||||
|
||||
### Collapse repeated inspect command and app lookup flow
|
||||
|
||||
- Affected files/packages: `internal/cli/root.go`, `internal/app/inspect.go`,
|
||||
CLI and app inspect tests.
|
||||
- Duplicated or near-duplicated behavior: each inspect subcommand repeats flag
|
||||
parsing, `config.Load`, app call, error handling, and JSON writing. The app
|
||||
inspect functions repeatedly create the default store, load metadata by
|
||||
RunID, and then load or derive a specific result.
|
||||
- Why it matters: adding another inspect view or changing config-loading/error
|
||||
behavior would require edits in multiple cases. It also increases the chance
|
||||
that one inspect command gains different output or error behavior.
|
||||
- Recommended refactor: add a small inspect command table in `internal/cli`
|
||||
mapping command name to parser and handler. In `internal/app`, add an
|
||||
internal helper that returns the store, metadata, and metadata path for a
|
||||
RunID, then build briefing/data-package/prior/source views from that helper.
|
||||
- Suggested tests: keep the existing `TestRunInspectGeneratedArtifacts` and
|
||||
missing metadata tests. Add a focused table test that each registered inspect
|
||||
command accepts `--config` and rejects missing RunID where applicable.
|
||||
- Risk level: low. The refactor is local and should preserve public CLI output.
|
||||
|
||||
### Remove or resolve unused report output config surface
|
||||
|
||||
- Affected files/packages: `internal/config/config.go`,
|
||||
`internal/config/load.go`, `internal/config/defaults.go`,
|
||||
`internal/config/validate.go`, docs and examples if behavior changes.
|
||||
- Duplicated or near-duplicated behavior: `ReportOutputConfig` contains
|
||||
`OutputDir` and `Paths`, and `LoadOptions.Output` writes to
|
||||
`cfg.Reports.OutputDir`. Current command behavior uses `GenerateRequest`
|
||||
`OutputPath` and `BatchRequest.OutputDir`; docs correctly say `--out` and
|
||||
`--out-dir` control extra copies without changing config files. The report
|
||||
output config fields do not appear to drive implemented behavior.
|
||||
- Why it matters: unused config fields are a maintenance hazard. They invite
|
||||
future docs drift and make it unclear whether output policy belongs in config,
|
||||
CLI requests, report definitions, or state.
|
||||
- Recommended refactor: decide one behavior before release. Either remove the
|
||||
unused config surface and the `LoadOptions.Output` mutation, or implement
|
||||
`reports.output_dir`/`reports.paths` as real defaults for generated extra
|
||||
copies. Given current docs and CLI behavior, removal is the lower-risk option.
|
||||
- Suggested tests: config tests should assert only implemented config fields.
|
||||
CLI tests should continue to cover `--out` and `--out-dir`.
|
||||
- Risk level: medium. This touches config structures and may affect users if
|
||||
anyone has already copied old config fields, so pair the change with clear
|
||||
release notes if removed.
|
||||
|
||||
## 4. Medium-confidence opportunities
|
||||
|
||||
### Reduce Weather API source fetch boilerplate
|
||||
|
||||
- Affected files/packages: `internal/adapters/weatherapi/client.go`,
|
||||
weather adapter tests.
|
||||
- Duplicated or near-duplicated behavior: optional source fetch methods repeat
|
||||
request construction, missing handling, JSON decode, malformed-source policy,
|
||||
source timestamp assignment, bundle assignment, and source recording. Hourly
|
||||
is intentionally different because it is required and validates periods.
|
||||
- Why it matters: adding another Weather API source will likely copy this
|
||||
structure and may accidentally diverge on missing-source policy or provenance.
|
||||
- Recommended refactor: introduce a small source specification/helper for
|
||||
optional typed sources. Keep hourly special. Do not build a generic HTTP
|
||||
ingestion framework.
|
||||
- Suggested tests: keep existing endpoint/query/missing-source tests. Add one
|
||||
test that optional malformed data follows the configured missing-source
|
||||
policy across at least two source specs.
|
||||
- Risk level: medium. The current explicit functions are readable; only refactor
|
||||
if adding more source types or touching missing-source behavior.
|
||||
|
||||
### Share Scriptorium render/run execution scaffolding
|
||||
|
||||
- Affected files/packages: `internal/adapters/scriptorium/runner.go`,
|
||||
Scriptorium adapter tests.
|
||||
- Duplicated or near-duplicated behavior: `Render` and `Run` both validate
|
||||
common inputs, resolve binary/runner defaults, execute a command, copy
|
||||
stdout/stderr/truncation/exit fields, and turn nonzero exit codes into
|
||||
result-plus-error. Argument construction is already separated.
|
||||
- Why it matters: future changes to capture limits, redaction, timeout handling,
|
||||
or nonzero exit formatting could drift between render and run.
|
||||
- Recommended refactor: introduce a private `execute` helper returning the
|
||||
shared command result fields and preserving command-specific validation and
|
||||
result structs.
|
||||
- Suggested tests: existing render/run argv and nonzero exit tests should remain
|
||||
sufficient; add a timeout/non-exit error test if one is missing.
|
||||
- Risk level: low-medium. Keep the public adapter API and result JSON stable.
|
||||
|
||||
### Consolidate storm time parsing and validation
|
||||
|
||||
- Affected files/packages: `internal/cli/root.go`, `internal/report/period.go`,
|
||||
CLI and report period tests.
|
||||
- Duplicated or near-duplicated behavior: CLI validates `--start`, `--end`,
|
||||
parses both storm timestamps, and checks end after start. Report resolution
|
||||
also validates storm start/end and has a `ParseStormPeriod` helper.
|
||||
- Why it matters: error wording and accepted timestamp behavior could drift
|
||||
between CLI and report-level validation.
|
||||
- Recommended refactor: keep CLI-specific missing-flag errors in `internal/cli`,
|
||||
but delegate parse/order validation to one helper after both values are
|
||||
present.
|
||||
- Suggested tests: keep CLI tests for missing flags and RFC3339 parsing; keep
|
||||
report period tests for invalid bounds.
|
||||
- Risk level: low.
|
||||
|
||||
### Add a briefing-level weather signal helper, but keep prose local
|
||||
|
||||
- Affected files/packages: `internal/briefing/daily.go`,
|
||||
`internal/briefing/three_day.go`, `internal/briefing/weekend.go`,
|
||||
`internal/briefing/storm.go`.
|
||||
- Duplicated or near-duplicated behavior: several builders aggregate ranges,
|
||||
max precipitation, peak gusts, hazards, alert events, and risk labels from
|
||||
daypart summaries. The report-specific prose and planning notes are
|
||||
intentionally different.
|
||||
- Why it matters: threshold changes for "wind", "precipitation", or hazard
|
||||
labels may need multiple edits.
|
||||
- Recommended refactor: add a narrow unexported helper in `internal/briefing`
|
||||
for aggregating common signals from dayparts and alerts. Do not centralize
|
||||
report-specific language, planning sections, or prompt-facing structure.
|
||||
- Suggested tests: briefing tests should assert unchanged daily, 3-day, weekend,
|
||||
and storm package shape for representative fixtures.
|
||||
- Risk level: medium. This is useful only if kept small; over-consolidating
|
||||
report prose would make the builders harder to read.
|
||||
|
||||
### Clean up legacy daily-specific wrappers after generic report support
|
||||
|
||||
- Affected files/packages: `internal/app/app.go`, `internal/state/store.go`,
|
||||
`internal/state/filesystem.go`, app and state tests.
|
||||
- Duplicated or near-duplicated behavior: `DailyBriefingRequest`,
|
||||
`DailyReportRequest`, `GenerateDailyBriefing`, `GenerateDailyReport`,
|
||||
`BuildDailyBriefing`, `FindPriorDailySnapshot`, and `dailyRecentChanges`
|
||||
mostly alias or forward to generic report functions.
|
||||
- Why it matters: wrappers create two names for the same behavior and encourage
|
||||
future code to depend on the older daily-specific path.
|
||||
- Recommended refactor: remove unused wrappers or mark them as test-only
|
||||
migration targets, then update tests to call the generic functions directly.
|
||||
Keep any wrapper that is intentionally part of a public package contract, but
|
||||
this repository uses `internal/`, so that concern is limited.
|
||||
- Suggested tests: app and state tests should continue to cover daily behavior
|
||||
through generic generation and prior snapshot paths.
|
||||
- Risk level: low-medium. This is a small internal API cleanup, but it touches
|
||||
many test call sites.
|
||||
|
||||
### Reduce duplicated end-to-end test setup
|
||||
|
||||
- Affected files/packages: `internal/cli/root_test.go`,
|
||||
`internal/app/app_test.go`, adapter tests.
|
||||
- Duplicated or near-duplicated behavior: tests repeatedly create temp
|
||||
workspaces, write YAML config strings, start representative Weather API
|
||||
servers, create fake Scriptorium scripts, and glob managed artifact paths.
|
||||
- Why it matters: setup duplication makes behavior-preserving refactors noisier
|
||||
and increases the chance that new tests accidentally use a subtly different
|
||||
fixture.
|
||||
- Recommended refactor: add small package-local helpers for config writing,
|
||||
test server setup, fake Scriptorium setup, and artifact glob/assertion.
|
||||
Avoid a cross-package test framework unless duplication becomes painful in
|
||||
more packages.
|
||||
- Suggested tests: this is test-only; existing tests should pass unchanged in
|
||||
behavior.
|
||||
- Risk level: low.
|
||||
|
||||
## 5. Boundary and responsibility concerns
|
||||
|
||||
The main boundary concern is that `internal/state` imports the Scriptorium
|
||||
adapter package and exposes `SavePreflight(context.Context, report.Resolved,
|
||||
*scriptorium.RenderResult)` through `state.Store`. State needs to persist a
|
||||
preflight artifact, but it does not need to know that the artifact type comes
|
||||
from an external CLI adapter. This is a small leak across the documented adapter
|
||||
boundary.
|
||||
|
||||
Recommended home: keep subprocess result construction in
|
||||
`internal/adapters/scriptorium`; have `internal/app` convert or pass the result
|
||||
to state through a state-owned preflight artifact type, a generic JSON artifact
|
||||
writer, or an app-owned persistence helper. This keeps adapter result types from
|
||||
becoming part of the state interface.
|
||||
|
||||
The second boundary concern is report policy in state/app. `internal/state`
|
||||
should own filesystem layout mechanics, but report grouping and compatible
|
||||
prior report selection are report catalog decisions. `internal/app` should
|
||||
orchestrate batch copies, but batch copy filename policy should come from report
|
||||
definitions.
|
||||
|
||||
The third concern is unused config surface. `internal/config` owns
|
||||
`ReportOutputConfig`, but current command behavior does not consume it. Either
|
||||
the app should use it explicitly or the config fields should be removed before
|
||||
they become accidental public API.
|
||||
|
||||
## 6. Path, key, and naming construction review
|
||||
|
||||
Local managed artifact paths are mostly centralized in
|
||||
`FilesystemStore.Paths`, which is good. That function owns the paths for
|
||||
briefing snapshots, metadata, data packages, preflight output, and managed
|
||||
Markdown reports.
|
||||
|
||||
Cleanup targets:
|
||||
|
||||
- `reportGroup` is private to `internal/state`, while report identity policy is
|
||||
canonical in `internal/report`.
|
||||
- Batch extra-copy names are built in `internal/app` by replacing underscores in
|
||||
`DefaultOutputName`. This derives a public filename by convention rather than
|
||||
declaring it.
|
||||
- Metadata path resolution uses `metadataPathFromStored`, derived from
|
||||
`BriefingPath`. This works today, but it means metadata path identity is
|
||||
partially reconstructed from another artifact path instead of coming directly
|
||||
from `ArtifactPaths`.
|
||||
- Tests in CLI/app/state hard-code path fragments in many places. These are
|
||||
useful regression checks, but after report group/name helpers exist, tests
|
||||
should assert through the helper or explicitly state they are path-contract
|
||||
tests.
|
||||
|
||||
No remote keys, cache paths, lock files, schema paths, or object-store paths are
|
||||
implemented.
|
||||
|
||||
## 7. Resolution and catalog review
|
||||
|
||||
Report resolution is mostly consistent. `internal/report` owns report
|
||||
definitions, prompt IDs, valid-period resolution, batches, and comparison
|
||||
strategy declarations.
|
||||
|
||||
Places that should be brought closer to the catalog:
|
||||
|
||||
- command-to-report mapping in `internal/app` (`ReportKind` to `report.ID`);
|
||||
- command-to-batch mapping in `internal/app`;
|
||||
- artifact group mapping in `internal/state`;
|
||||
- compatible prior report matching in `internal/state`;
|
||||
- batch copy filename behavior in `internal/app`;
|
||||
- "generated report" eligibility in `internal/app`.
|
||||
|
||||
Recommendation: keep the public CLI command names in `internal/cli`/`app`, but
|
||||
let the report registry expose enough metadata that app and state do not need
|
||||
parallel switches over report IDs.
|
||||
|
||||
Prompt and profile resolution is clean. Prompt IDs are declared in
|
||||
`internal/report`; Scriptorium profile/config/binary/extra args stay in config
|
||||
and the Scriptorium adapter. Data package naming is consistently
|
||||
`data_package=<path>`.
|
||||
|
||||
Weather API source resolution is explicit but somewhat repetitive. A source
|
||||
catalog or source spec table could help if the number of upstream sources grows.
|
||||
|
||||
## 8. Config and command-loading review
|
||||
|
||||
Configuration loading is centralized in `internal/config`, and the documented
|
||||
precedence is reflected in code: CLI overrides, config file, built-in defaults.
|
||||
There is no duplicated YAML parsing or validation outside `internal/config`.
|
||||
|
||||
Command loading is consistent but repetitive:
|
||||
|
||||
- generate commands load config with `--config`, `--units`, `--tz`, and `--out`;
|
||||
- run commands load config with `--config`, `--units`, and `--tz`;
|
||||
- inspect commands load config with `--config` only.
|
||||
|
||||
The inspect difference appears intentional because inspect commands do not
|
||||
fetch weather data or generate reports. The generate/run repetition is modest,
|
||||
but a small helper for building `config.LoadOptions` would reduce drift if more
|
||||
shared flags are added.
|
||||
|
||||
The likely accidental issue is `LoadOptions.Output`: it mutates
|
||||
`cfg.Reports.OutputDir`, but current generation behavior separately uses
|
||||
`GenerateRequest.OutputPath`, and docs say output flags do not change config.
|
||||
This should be removed or given real semantics.
|
||||
|
||||
## 9. State, manifest, or progress handling review
|
||||
|
||||
The project has filesystem state but no manifest, checkpoint, resume, force, or
|
||||
dry-run system.
|
||||
|
||||
State handling is generally consistent:
|
||||
|
||||
- `FilesystemStore.Paths` centralizes managed artifact paths.
|
||||
- Store methods persist briefing, data package, preflight, metadata, and managed
|
||||
report path preparation.
|
||||
- `GenerateReport` persists metadata before returning render errors when a
|
||||
render result exists.
|
||||
- `RunBatchDetailed` continues independent reports and returns aggregate
|
||||
results for CLI summarization.
|
||||
- Inspection loads metadata by RunID and then follows metadata paths.
|
||||
|
||||
Cleanup targets:
|
||||
|
||||
- State prior-snapshot lookup uses filesystem discovery plus hardcoded report
|
||||
compatibility. This is currently acceptable, but compatibility policy should
|
||||
be registry-driven before adding more report types.
|
||||
- `LoadMetadataByRunID` scans all reports through `ListReports`. This is simple
|
||||
and fine at current scale, but it is the place to revisit if workspace size
|
||||
grows.
|
||||
- `SaveMetadata` reconstructs the metadata path from `BriefingPath`, while most
|
||||
other artifact paths come from `Paths`. Prefer carrying the metadata path
|
||||
explicitly to reduce path coupling.
|
||||
|
||||
No progress tracking drift exists because progress tracking is not implemented.
|
||||
|
||||
## 10. Refactors to avoid
|
||||
|
||||
Avoid these refactors for now:
|
||||
|
||||
- A generic workflow engine for generation stages. The current explicit
|
||||
orchestration is readable and well tested.
|
||||
- A plugin architecture for reports, sources, validators, or adapters. The
|
||||
current registry and adapter packages are enough.
|
||||
- A broad CLI redesign or Cobra migration. The standard-library CLI is adequate
|
||||
and documented.
|
||||
- A sweeping manifest or resume-system rewrite. There is no implemented resume
|
||||
behavior to consolidate yet.
|
||||
- A generic source ingestion framework for the Weather API. A small helper or
|
||||
spec table is enough if new sources are added.
|
||||
- Consolidating all report briefing prose. Similar thresholds can be factored,
|
||||
but report-specific prompt inputs should remain readable and explicit.
|
||||
- Moving all test helpers into a global test package. Prefer package-local
|
||||
helpers until duplication crosses package boundaries in a way that blocks
|
||||
refactoring.
|
||||
- Removing path-contract tests merely because they duplicate strings. Some
|
||||
hard-coded expected paths are valuable regression coverage.
|
||||
|
||||
## 11. Recommended implementation sequence
|
||||
|
||||
1. Path/key/naming helpers
|
||||
- Add report registry metadata or helpers for artifact group, batch copy
|
||||
filename, generated-report eligibility, and compatible prior report IDs.
|
||||
- Preserve existing paths and filenames.
|
||||
- Update state/app tests first or in the same commit.
|
||||
|
||||
2. Atomic file and JSON helpers
|
||||
- Introduce a narrow atomic file helper.
|
||||
- Route state, briefing, prompt input, Weather API bundle saving,
|
||||
Scriptorium preflight saving, and report-copy code through it.
|
||||
- Remove unused save helpers if they no longer have callers.
|
||||
|
||||
3. Config loading context
|
||||
- Remove or implement `ReportOutputConfig` and `LoadOptions.Output`.
|
||||
- Keep documented CLI output behavior stable.
|
||||
- Add config regression tests around examples and CLI output flags.
|
||||
|
||||
4. Command preflight/shared CLI parsing
|
||||
- Add small helpers for common config-load options and inspect command
|
||||
dispatch.
|
||||
- Keep command errors and JSON output stable.
|
||||
|
||||
5. Shared state lookup helpers
|
||||
- Add app or state helpers for "load metadata by RunID, then load artifact".
|
||||
- Keep inspect output unchanged.
|
||||
|
||||
6. Adapter workflow cleanup
|
||||
- Factor Scriptorium shared execution internals.
|
||||
- Consider a Weather API optional-source helper only if source work is
|
||||
already planned.
|
||||
|
||||
7. Formatting/reporting cleanup
|
||||
- Collapse `writeRunSummary` and `writeJSON` into one JSON writer helper.
|
||||
- Leave stderr run logs as-is unless operator output requirements change.
|
||||
|
||||
8. Test helper and fixture cleanup
|
||||
- Add package-local helpers for repeated CLI/app setup.
|
||||
- Keep fixtures small and deterministic.
|
||||
|
||||
9. Dead-code/legacy sweep
|
||||
- Remove daily-specific wrapper aliases and `FindPriorDailySnapshot` after
|
||||
tests call generic paths.
|
||||
- Re-run full tests and update internal docs if any package contracts change.
|
||||
|
||||
## 12. Test strategy
|
||||
|
||||
Tests to run before refactoring:
|
||||
|
||||
- `go test ./internal/report ./internal/state`
|
||||
- `go test ./internal/app ./internal/cli`
|
||||
- `go test ./internal/config`
|
||||
- `go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium`
|
||||
|
||||
Tests to add before or during cleanup:
|
||||
|
||||
- Report registry tests for artifact group, batch copy filename, generated
|
||||
eligibility, and compatible prior report IDs.
|
||||
- Atomic file helper tests for parent directory creation, overwrite behavior,
|
||||
and cleanup after write failure.
|
||||
- Config tests proving output flags do not mutate config, or tests defining the
|
||||
implemented behavior if `reports.output_dir` is kept.
|
||||
- CLI inspect table tests that cover `--config` parsing and missing RunID
|
||||
validation for each run-specific inspect command.
|
||||
- State tests that assert metadata path is taken from explicit artifact paths if
|
||||
`metadataPathFromStored` is removed.
|
||||
- Scriptorium adapter tests for shared nonzero exit and timeout handling after
|
||||
render/run execution is factored.
|
||||
|
||||
Tests that can accompany refactors:
|
||||
|
||||
- Weather API optional-source helper tests if source fetch boilerplate is
|
||||
factored.
|
||||
- Briefing fixture tests if common signal aggregation is factored.
|
||||
- Test-helper cleanup can rely on existing package tests if no production code
|
||||
changes.
|
||||
|
||||
## 13. Appendix: findings not worth acting on
|
||||
|
||||
- `internal/app.BuildBriefing` uses a switch over report IDs. This is acceptable
|
||||
because app orchestration needs to dispatch to report-specific builders. A
|
||||
registry of builder functions could be useful later, but it is not necessary
|
||||
for the current report count.
|
||||
- CLI command parsing is explicit and somewhat repetitive. Do not replace it
|
||||
with a framework. Small helpers are enough.
|
||||
- Daily, 3-day, weekend, and storm briefing builders have similar weather
|
||||
thresholds. Some signal aggregation can be shared, but report-specific
|
||||
sections and wording should stay local.
|
||||
- Weather API fetch methods are explicit. A helper is worthwhile only around
|
||||
repeated optional-source mechanics; endpoint-specific decode and timestamp
|
||||
behavior should remain clear.
|
||||
- Hard-coded expected artifact path fragments in tests duplicate path strings,
|
||||
but several of those tests intentionally protect the on-disk contract.
|
||||
- `LoadMetadataByRunID` scans filesystem metadata. That is acceptable at the
|
||||
current scale and should not be optimized without evidence of operational
|
||||
pain.
|
||||
- Batch stderr logs are simple text. Do not introduce a logging subsystem unless
|
||||
operator requirements become more complex.
|
||||
@@ -1,394 +0,0 @@
|
||||
# Cleanup Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the staged implementation plan for the code quality and
|
||||
deduplication cleanup identified in `docs/roadmap/audit.md`.
|
||||
|
||||
The target audience is an LLM coding agent. Implement each stage in order.
|
||||
Each stage should leave the repository buildable, tested, and behaviorally
|
||||
equivalent unless the stage explicitly removes unused public-facing surface.
|
||||
|
||||
## Cleanup Principles
|
||||
|
||||
- Preserve public CLI syntax and output behavior unless a stage explicitly says
|
||||
otherwise.
|
||||
- Preserve current managed artifact paths, report group names, RunID naming,
|
||||
and batch output copy filenames.
|
||||
- Keep domain policy in `internal/report`, `internal/forecast`,
|
||||
`internal/briefing`, and `internal/changes`, not in CLI or adapter packages.
|
||||
- Keep external system details behind adapter boundaries.
|
||||
- Prefer narrow, behavior-preserving helpers over broad framework-style
|
||||
abstractions.
|
||||
- Add or update focused tests in the package that owns the behavior being
|
||||
cleaned up.
|
||||
- Update non-roadmap documentation only after implemented behavior changes.
|
||||
- Do not revert unrelated worktree changes.
|
||||
|
||||
## Decisions Locked
|
||||
|
||||
- Add a narrow `internal/fileutil` package for reusable atomic file helpers.
|
||||
- Remove the unused `reports` config surface instead of implementing
|
||||
config-driven output defaults.
|
||||
- Move report artifact group, batch copy filename, generated-report eligibility,
|
||||
and compatible-prior policy into `internal/report`.
|
||||
- Keep public CLI command names and command-to-report mapping in `internal/app`
|
||||
for now.
|
||||
- Do not include Weather API source-spec refactoring in the main cleanup
|
||||
sequence.
|
||||
- Do not include broad briefing signal consolidation in the main cleanup
|
||||
sequence.
|
||||
- Do not introduce Cobra, a workflow engine, plugin system,
|
||||
manifest/resume/progress system, logging subsystem, or global test framework.
|
||||
- Treat the current unrelated deletion of `docs/roadmap/documentation.md` as
|
||||
out of scope for cleanup implementation. Do not restore or further modify it
|
||||
unless a later prompt explicitly asks for that.
|
||||
|
||||
## Stage 1: Report Catalog And Path Policy
|
||||
|
||||
Goal: make `internal/report` the canonical source for report identity policy.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Extend `report.Definition` with:
|
||||
- `ArtifactGroup string`
|
||||
- `BatchOutputName string`
|
||||
- `Generated bool`
|
||||
- `CompatiblePriorIDs []report.ID`
|
||||
- Populate current exact values:
|
||||
- Daily Today: artifact group `daily`, batch output `daily.md`, generated
|
||||
true, compatible with Daily Today and Daily Tomorrow.
|
||||
- Daily Tomorrow: artifact group `daily`, batch output `tomorrow.md`,
|
||||
generated true, compatible with Daily Today and Daily Tomorrow.
|
||||
- 3-Day: artifact group `three-day`, batch output `three-day.md`, generated
|
||||
true, compatible with 3-Day.
|
||||
- Weekend: artifact group `weekend`, batch output `weekend.md`, generated
|
||||
true, compatible with Weekend.
|
||||
- Storm: artifact group `storm`, batch output `storm.md`, generated true,
|
||||
compatible with Storm.
|
||||
- Add small methods or helpers in `internal/report` for compatibility checks if
|
||||
direct slice checks would duplicate logic in callers.
|
||||
- Update `internal/state` to use the resolved report definition's
|
||||
`ArtifactGroup` instead of private `reportGroup`.
|
||||
- Update prior snapshot lookup to use `CompatiblePriorIDs` instead of a
|
||||
state-owned compatibility switch.
|
||||
- Update `internal/app` to use `BatchOutputName` directly and remove
|
||||
underscore-to-hyphen derivation.
|
||||
- Update generated-report eligibility checks to use `Definition.Generated`.
|
||||
- Preserve all existing managed artifact paths and batch copy filenames.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add or update report registry tests that assert each definition's
|
||||
`ArtifactGroup`, `BatchOutputName`, `Generated`, and `CompatiblePriorIDs`.
|
||||
- Keep state path tests as path-contract tests and preserve their expected path
|
||||
strings.
|
||||
- Keep app batch output tests and preserve expected filenames such as
|
||||
`tomorrow.md`.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/report ./internal/state ./internal/app
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No report grouping switch remains in `internal/state`.
|
||||
- No batch output filename derivation by underscore replacement remains in
|
||||
`internal/app`.
|
||||
- Existing artifact paths and output copy names are unchanged.
|
||||
|
||||
## Stage 2: Atomic Artifact Writes And Adapter Boundary
|
||||
|
||||
Goal: centralize durable write mechanics and remove adapter type leakage from
|
||||
state.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `internal/fileutil` with:
|
||||
- `WriteFileAtomic(path string, data []byte) error`
|
||||
- `WriteJSONAtomic(path string, value any) error`
|
||||
- `CopyFileAtomic(source string, target string) error`
|
||||
- Implement helpers with the current behavior:
|
||||
- create parent directories with `0o755`;
|
||||
- create temp files in the target directory;
|
||||
- write, close, rename, and defer temp-file cleanup;
|
||||
- preserve useful path context in errors.
|
||||
- Route these through `internal/fileutil`:
|
||||
- state JSON writes;
|
||||
- briefing package save;
|
||||
- prompt input package save;
|
||||
- Weather API bundle save;
|
||||
- Scriptorium render result save if the helper remains;
|
||||
- generated report extra-copy writes.
|
||||
- Remove duplicate private atomic write helpers once callers are migrated.
|
||||
- Remove `scriptorium.SaveRenderResult` if no caller still needs it after the
|
||||
refactor.
|
||||
- Add `state.PreflightArtifact` with the same persisted JSON shape currently
|
||||
produced from `scriptorium.RenderResult`.
|
||||
- Change `state.Store.SavePreflight` to accept `state.PreflightArtifact`, not
|
||||
`*scriptorium.RenderResult`.
|
||||
- Convert `scriptorium.RenderResult` to `state.PreflightArtifact` in
|
||||
`internal/app` immediately before saving preflight output.
|
||||
- Keep subprocess result construction and interpretation in
|
||||
`internal/adapters/scriptorium`.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add `internal/fileutil` tests for parent directory creation, overwrite
|
||||
behavior, and cleanup/error behavior.
|
||||
- Keep state, app, adapter, briefing, and prompt input save tests.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/fileutil ./internal/state ./internal/app ./internal/briefing ./internal/promptinput ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Durable JSON and Markdown copy writes share one implementation.
|
||||
- `internal/state` no longer imports `internal/adapters/scriptorium`.
|
||||
- Persisted preflight JSON remains shape-compatible with current artifacts.
|
||||
|
||||
## Stage 3: Remove Unused Report Output Config
|
||||
|
||||
Goal: remove config fields that do not affect implemented behavior.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Remove these unused config surfaces:
|
||||
- `Config.Reports`
|
||||
- `ReportOutputConfig`
|
||||
- `LoadOptions.Output`
|
||||
- default report output config values;
|
||||
- `reports.output_dir` validation;
|
||||
- `reports.paths` map initialization.
|
||||
- Update CLI generation config loading so `--out` remains only
|
||||
`GenerateRequest.OutputPath`.
|
||||
- Keep `--out` and `--out-dir` behavior unchanged.
|
||||
- Update config tests and examples so they mention only implemented config
|
||||
fields.
|
||||
- Update documentation in the same stage if non-roadmap docs still mention the
|
||||
removed `reports` config surface.
|
||||
|
||||
Tests:
|
||||
|
||||
- Update config tests to assert config loading no longer has output-related
|
||||
config behavior.
|
||||
- Keep CLI tests for `--out` and `--out-dir`.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/config ./internal/cli ./internal/app
|
||||
go run ./cmd/weatherreporter --help
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No `ReportOutputConfig` or `LoadOptions.Output` symbols remain.
|
||||
- Example config files load through existing config tests.
|
||||
- CLI output-copy behavior remains command request behavior, not config behavior.
|
||||
|
||||
## Stage 4: Inspect Command And State Lookup Cleanup
|
||||
|
||||
Goal: remove repeated inspect scaffolding while preserving inspect output.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add a small inspect command table in `internal/cli`.
|
||||
- Keep `inspect reports` separate because it accepts `--limit` and does not
|
||||
require a RunID.
|
||||
- For run-specific inspect commands, centralize:
|
||||
- command name;
|
||||
- flag parsing;
|
||||
- config loading;
|
||||
- app handler invocation;
|
||||
- JSON output writing.
|
||||
- In `internal/app`, add an unexported helper that loads the default store and
|
||||
metadata for a RunID.
|
||||
- Use the app helper for metadata, briefing, data-package, prior, and sources
|
||||
inspection.
|
||||
- Collapse `writeRunSummary` and `writeJSON` into one JSON writer helper.
|
||||
- Preserve current JSON indentation and output shapes.
|
||||
|
||||
Tests:
|
||||
|
||||
- Keep `TestRunInspectGeneratedArtifacts`.
|
||||
- Keep missing metadata tests.
|
||||
- Add a table test proving run-specific inspect commands reject missing RunID
|
||||
and accept `--config`.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/cli ./internal/app ./internal/state
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Inspect command output is unchanged.
|
||||
- Config loading remains consistent across inspect commands.
|
||||
- Repeated store and metadata lookup code in app inspection paths is removed.
|
||||
|
||||
## Stage 5: Small Adapter And Parser Deduplication
|
||||
|
||||
Goal: reduce low-risk repeated validation and execution logic.
|
||||
|
||||
Implementation:
|
||||
|
||||
- In `internal/adapters/scriptorium`, add a private execution helper shared by
|
||||
`Render` and `Run`.
|
||||
- Keep command-specific request validation and result structs.
|
||||
- Preserve:
|
||||
- argv order;
|
||||
- result JSON fields;
|
||||
- stdout/stderr capture;
|
||||
- truncation fields;
|
||||
- timeout behavior;
|
||||
- nonzero exit behavior and error text.
|
||||
- In storm CLI parsing, keep CLI-specific missing `--start` and `--end` errors.
|
||||
- After both storm bounds are present, delegate timestamp parsing and
|
||||
end-after-start validation to `report.ParseStormPeriod`.
|
||||
- Do not introduce a generic Weather API ingestion framework in this stage.
|
||||
|
||||
Tests:
|
||||
|
||||
- Keep or add tests for Scriptorium render/run nonzero exits.
|
||||
- Keep storm tests for local timestamps, RFC3339 timestamps, missing flags, and
|
||||
invalid bounds.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/adapters/scriptorium ./internal/cli ./internal/report
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Scriptorium render/run behavior remains byte-for-byte compatible where tests
|
||||
assert argv or output shape.
|
||||
- Storm accepted timestamp formats and error behavior remain stable.
|
||||
|
||||
## Stage 6: Legacy Wrapper And Test Helper Cleanup
|
||||
|
||||
Goal: remove stale generic-vs-daily duplication and reduce noisy test setup.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Replace tests and internal callers of:
|
||||
- `GenerateDailyBriefing`
|
||||
- `GenerateDailyReport`
|
||||
- `BuildDailyBriefing`
|
||||
- `DailyBriefingRequest`
|
||||
- `DailyReportRequest`
|
||||
- Use generic functions and types instead:
|
||||
- `GenerateBriefing`
|
||||
- `GenerateReport`
|
||||
- `BuildBriefing`
|
||||
- `BriefingRequest`
|
||||
- `ReportRequest`
|
||||
- Remove `FindPriorDailySnapshot` from `state.Store` and
|
||||
`FilesystemStore` after tests use `FindPriorSnapshot`.
|
||||
- Remove `dailyRecentChanges` if no callers remain.
|
||||
- Add package-local test helpers in `internal/cli` and `internal/app` for:
|
||||
- writing test config files;
|
||||
- fake Scriptorium setup;
|
||||
- representative Weather API test server setup;
|
||||
- artifact glob and assertion helpers.
|
||||
- Do not create a cross-package test framework.
|
||||
|
||||
Tests:
|
||||
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/app ./internal/state ./internal/cli
|
||||
go test ./internal/...
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Daily-specific app/state wrapper symbols listed above are gone.
|
||||
- Daily behavior remains covered through generic report-generation paths.
|
||||
- Test helper extraction does not reduce workflow coverage.
|
||||
|
||||
## Stage 7: Documentation And Final Validation
|
||||
|
||||
Goal: align implemented documentation after cleanup.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Update non-roadmap docs only for behavior or internal contracts actually
|
||||
changed by stages 1 through 6.
|
||||
- Inspect and update, as needed:
|
||||
- `docs/config.md`
|
||||
- `docs/internal/state.md`
|
||||
- `docs/internal/scriptorium-adapter.md`
|
||||
- `docs/internal/report-registry.md`
|
||||
- `docs/policy/development.md`
|
||||
- relevant files under `docs/integrations/`
|
||||
- Keep future or deferred cleanup ideas only under `docs/roadmap/`.
|
||||
- Do not document deferred Weather API source-spec or briefing signal refactors
|
||||
as implemented.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run stale-symbol searches:
|
||||
|
||||
```bash
|
||||
rg -n "ReportOutputConfig|LoadOptions\\.Output|FindPriorDailySnapshot|GenerateDailyReport|DailyReportRequest|SaveRenderResult" .
|
||||
```
|
||||
|
||||
Review any matches manually. Matches under roadmap files may be acceptable
|
||||
because they describe planned or completed cleanup work.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Non-roadmap docs describe only implemented behavior.
|
||||
- Config examples still load through config tests.
|
||||
- CLI help remains accurate.
|
||||
- No removed production symbols remain outside tests or roadmap references.
|
||||
|
||||
## Deferred Refactors
|
||||
|
||||
Do not include these in the main cleanup sequence:
|
||||
|
||||
- Weather API optional-source spec/helper refactor.
|
||||
- Broad briefing weather-signal consolidation.
|
||||
- Generic workflow engine.
|
||||
- Plugin architecture.
|
||||
- Cobra migration.
|
||||
- Manifest/resume/progress system.
|
||||
- Global test helper package.
|
||||
- Logging subsystem.
|
||||
|
||||
These can be revisited only when new source types, report types, or operational
|
||||
requirements make the duplication materially more expensive.
|
||||
|
||||
## Global Validation Checklist
|
||||
|
||||
Run focused tests after each stage, then run full validation after Stage 7.
|
||||
|
||||
Required final checks:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Required manual checks:
|
||||
|
||||
- Public CLI syntax remains stable.
|
||||
- Managed artifact paths remain stable.
|
||||
- Batch output copy filenames remain stable.
|
||||
- `scriptorium` argv construction remains stable.
|
||||
- Weather API request query behavior remains stable.
|
||||
- Removed config fields are also removed from current-behavior docs and
|
||||
examples.
|
||||
- Roadmap files are the only docs that describe deferred cleanup work.
|
||||
- No unrelated worktree changes are reverted.
|
||||
@@ -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;
|
||||
@@ -49,3 +48,38 @@ These ideas are not current behavior:
|
||||
|
||||
Each item needs its own design note before implementation. Non-roadmap docs
|
||||
must not describe these as available behavior.
|
||||
|
||||
## Deferred: Cleanup Refactors
|
||||
|
||||
The initial cleanup pass intentionally left these refactors out because the
|
||||
current implementation does not yet make them worth the added abstraction.
|
||||
|
||||
Revisit these only when new source types, report types, operational
|
||||
requirements, or recurring maintenance costs make the duplication materially
|
||||
more expensive:
|
||||
|
||||
- Weather API optional-source specification/helper refactor: consider when
|
||||
additional Weather API sources make per-source fan-out, policy handling, and
|
||||
provenance wiring repetitive enough to obscure adapter behavior.
|
||||
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
||||
builders repeatedly derive the same weather signals and tests begin to need
|
||||
coordinated fixture updates.
|
||||
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||
future background workflows gain enough shared step semantics to justify a
|
||||
declared execution model.
|
||||
- Plugin architecture: defer until there is a concrete external extension
|
||||
contract and at least one implemented extension point.
|
||||
- Cobra migration: defer while the standard-library CLI remains small,
|
||||
explicit, and covered by parser tests.
|
||||
- Manifest, resume, or progress system: defer until operators need resumable
|
||||
runs, checkpoint recovery, or richer audit trails than the current durable
|
||||
artifacts and metadata provide.
|
||||
- Global test helper package: defer while package-local helpers keep tests
|
||||
clear; revisit only if setup duplication starts to hide behavior.
|
||||
- Logging subsystem: defer until there are recurring operator diagnostics that
|
||||
cannot be handled with current errors, metadata, inspection commands, and
|
||||
artifact output.
|
||||
|
||||
Any future implementation should preserve the existing public CLI, artifact
|
||||
paths, report identities, and adapter boundaries unless a separate roadmap
|
||||
explicitly changes them.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
base_url: https://weather.api.rakestrawhome.com/
|
||||
timeout: 15s
|
||||
precision: 1
|
||||
units: us
|
||||
timezone: Chicago
|
||||
timezone: "America/Chicago"
|
||||
format: json
|
||||
|
||||
location:
|
||||
id: home
|
||||
name: Brentwood
|
||||
region: St. Louis Metro
|
||||
|
||||
missing_source:
|
||||
default: warn
|
||||
sources:
|
||||
@@ -28,12 +33,15 @@ dayparts:
|
||||
end: "06:00"
|
||||
- name: morning
|
||||
start: "06:00"
|
||||
end: "12:00"
|
||||
end: "10:00"
|
||||
- name: midday
|
||||
start: "10:00"
|
||||
end: "15:00"
|
||||
- name: afternoon
|
||||
start: "12:00"
|
||||
end: "18:00"
|
||||
start: "15:00"
|
||||
end: "17:00"
|
||||
- name: evening
|
||||
start: "18:00"
|
||||
start: "17:00"
|
||||
end: "24:00"
|
||||
|
||||
recent_change:
|
||||
|
||||
@@ -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,11 +83,20 @@ 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=Chicago") {
|
||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -358,6 +390,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -472,6 +505,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -538,6 +572,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -613,6 +648,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
Metadata: priorPaths.Metadata,
|
||||
DataPackage: priorPaths.DataPackage,
|
||||
Preflight: priorPaths.Preflight,
|
||||
RenderedReport: priorPaths.RenderedReport,
|
||||
@@ -869,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":
|
||||
@@ -877,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)
|
||||
}
|
||||
@@ -176,9 +185,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, "Morning thunder could affect departure timing.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||
}
|
||||
@@ -203,9 +209,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||
}
|
||||
@@ -229,9 +232,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, "Overnight storms could change morning impacts.")
|
||||
}
|
||||
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||
}
|
||||
@@ -259,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 {
|
||||
@@ -293,10 +314,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
||||
score += float64(len(daypart.AlertOverlaps)) * 100
|
||||
reasons = append(reasons, "alert overlap")
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
score += 75
|
||||
reasons = append(reasons, "thunder risk")
|
||||
}
|
||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||
score += 25
|
||||
if daypart.Indicators.Heat {
|
||||
@@ -334,9 +351,6 @@ func bottomLineText(conditions []string, hazards []string) string {
|
||||
|
||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||
var hazards []string
|
||||
if indicators.Thunder {
|
||||
hazards = append(hazards, "thunder")
|
||||
}
|
||||
if indicators.Snow {
|
||||
hazards = append(hazards, "snow")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||
if pkg.Daily == nil {
|
||||
t.Fatal("Daily = nil")
|
||||
}
|
||||
if len(pkg.Daily.Dayparts) != 4 {
|
||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
||||
if len(pkg.Daily.Dayparts) != 5 {
|
||||
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||
}
|
||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||
@@ -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) {
|
||||
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||
Value: wind,
|
||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true},
|
||||
Indicators: forecast.Indicators{Snow: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -237,9 +293,10 @@ func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
||||
func defaultDayparts() []forecast.DaypartDefinition {
|
||||
return []forecast.DaypartDefinition{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -139,9 +139,6 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
|
||||
items = appendUnique(items, "Alert scenario to consider: "+label+".")
|
||||
}
|
||||
}
|
||||
if summary.Indicators.Thunder {
|
||||
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
|
||||
}
|
||||
if summary.Indicators.Wind {
|
||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||
}
|
||||
@@ -161,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
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
},
|
||||
},
|
||||
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",
|
||||
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||
}
|
||||
first := pkg.ThreeDay.Days[0]
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, label+" thunder risk is present.")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
Indicators: forecast.Indicators{Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -79,10 +84,16 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
previous bool
|
||||
current bool
|
||||
}{
|
||||
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
||||
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||
} {
|
||||
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||
out := forecast.Indicators{}
|
||||
for _, daypart := range dayparts {
|
||||
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||
}
|
||||
|
||||
@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
||||
|
||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
|
||||
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||
|
||||
changes, err := CompareDaily(previous, current, testThresholds())
|
||||
if err != nil {
|
||||
t.Fatalf("CompareDaily() error = %v", err)
|
||||
}
|
||||
if countType(changes, "thunder_risk_change") != 1 {
|
||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||
if countType(changes, "snow_risk_change") != 1 {
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
Value: currentPrecip,
|
||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
|
||||
@@ -51,16 +51,16 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||
}
|
||||
var foundPrecip bool
|
||||
var foundThunder bool
|
||||
var foundSnow bool
|
||||
for _, change := range changes {
|
||||
if change.Type == "outlook_precip_probability_change" {
|
||||
foundPrecip = true
|
||||
}
|
||||
if change.Type == "outlook_thunder_risk_change" {
|
||||
foundThunder = true
|
||||
if change.Type == "outlook_snow_risk_change" {
|
||||
foundSnow = true
|
||||
}
|
||||
}
|
||||
if !foundPrecip || !foundThunder {
|
||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
||||
if !foundPrecip || !foundSnow {
|
||||
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||
}}},
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
t.Fatal("changes length = 0, want weekend changes")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
||||
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.WeatherAPI.Units != "us" {
|
||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||
}
|
||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
||||
if cfg.WeatherAPI.Timezone != "America/Chicago" {
|
||||
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -34,8 +37,8 @@ func TestLoadExampleConfig(t *testing.T) {
|
||||
t.Fatalf("LoadFile() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
|
||||
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
|
||||
}
|
||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,9 +10,14 @@ func Defaults() Config {
|
||||
Timeout: 10 * time.Second,
|
||||
Precision: 1,
|
||||
Units: "us",
|
||||
Timezone: "Chicago",
|
||||
Timezone: "America/Chicago",
|
||||
Format: "json",
|
||||
},
|
||||
Location: LocationConfig{
|
||||
ID: "home",
|
||||
Name: "Brentwood",
|
||||
Region: "St. Louis Metro",
|
||||
},
|
||||
MissingSource: MissingSourceConfig{
|
||||
Default: MissingSourceWarn,
|
||||
Sources: map[string]MissingSourcePolicy{},
|
||||
@@ -30,9 +35,10 @@ func Defaults() Config {
|
||||
},
|
||||
Dayparts: []DaypartConfig{
|
||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
||||
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||
},
|
||||
RecentChange: RecentChangeConfig{
|
||||
TemperatureDegrees: 5,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -47,13 +47,12 @@ type TimedValue struct {
|
||||
}
|
||||
|
||||
type Indicators struct {
|
||||
Thunder bool `json:"thunder,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
Snow bool `json:"snow,omitempty"`
|
||||
Ice bool `json:"ice,omitempty"`
|
||||
Fog bool `json:"fog,omitempty"`
|
||||
Heat bool `json:"heat,omitempty"`
|
||||
Cold bool `json:"cold,omitempty"`
|
||||
Wind bool `json:"wind,omitempty"`
|
||||
}
|
||||
|
||||
type AlertOverlap struct {
|
||||
@@ -291,11 +290,10 @@ func sortedKeys(values map[string]struct{}) []string {
|
||||
func indicatorsForText(text string) Indicators {
|
||||
lower := strings.ToLower(text)
|
||||
return Indicators{
|
||||
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
|
||||
Snow: strings.Contains(lower, "snow"),
|
||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||
Fog: strings.Contains(lower, "fog"),
|
||||
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
||||
Snow: strings.Contains(lower, "snow"),
|
||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||
Fog: strings.Contains(lower, "fog"),
|
||||
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,13 +316,12 @@ func numericIndicators(period ForecastPeriod) Indicators {
|
||||
|
||||
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||
return Indicators{
|
||||
Thunder: left.Thunder || right.Thunder,
|
||||
Snow: left.Snow || right.Snow,
|
||||
Ice: left.Ice || right.Ice,
|
||||
Fog: left.Fog || right.Fog,
|
||||
Heat: left.Heat || right.Heat,
|
||||
Cold: left.Cold || right.Cold,
|
||||
Wind: left.Wind || right.Wind,
|
||||
Snow: left.Snow || right.Snow,
|
||||
Ice: left.Ice || right.Ice,
|
||||
Fog: left.Fog || right.Fog,
|
||||
Heat: left.Heat || right.Heat,
|
||||
Cold: left.Cold || right.Cold,
|
||||
Wind: left.Wind || right.Wind,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
||||
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
||||
}
|
||||
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
||||
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
|
||||
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
|
||||
}
|
||||
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
|
||||
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
|
||||
if !morning.Indicators.Wind {
|
||||
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
||||
}
|
||||
|
||||
afternoon := summary.Dayparts[2]
|
||||
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
||||
if len(summary.Dayparts) != 2 {
|
||||
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
||||
}
|
||||
if !summary.Dayparts[0].Indicators.Thunder {
|
||||
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
||||
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
||||
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
||||
}
|
||||
if len(summary.AlertOverlaps) != 1 {
|
||||
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
||||
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -38,7 +38,6 @@ type Definition struct {
|
||||
Name string
|
||||
PromptID string
|
||||
ComparisonStrategy ComparisonStrategy
|
||||
DefaultOutputName string
|
||||
ArtifactGroup string
|
||||
BatchOutputName string
|
||||
Generated bool
|
||||
|
||||
@@ -13,7 +13,6 @@ func DefaultRegistry() Registry {
|
||||
Name: "Daily Report",
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "daily.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "daily.md",
|
||||
Generated: true,
|
||||
@@ -26,7 +25,6 @@ func DefaultRegistry() Registry {
|
||||
Name: "Tomorrow Planning Brief",
|
||||
PromptID: "weather.daily_report",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "tomorrow.md",
|
||||
ArtifactGroup: "daily",
|
||||
BatchOutputName: "tomorrow.md",
|
||||
Generated: true,
|
||||
@@ -39,7 +37,6 @@ func DefaultRegistry() Registry {
|
||||
Name: "3-Day Outlook",
|
||||
PromptID: "weather.three_day_outlook",
|
||||
ComparisonStrategy: CompareSameValidDate,
|
||||
DefaultOutputName: "three_day.md",
|
||||
ArtifactGroup: "three-day",
|
||||
BatchOutputName: "three-day.md",
|
||||
Generated: true,
|
||||
@@ -52,7 +49,6 @@ func DefaultRegistry() Registry {
|
||||
Name: "Weekend Outlook",
|
||||
PromptID: "weather.weekend_outlook",
|
||||
ComparisonStrategy: CompareWeekendWindow,
|
||||
DefaultOutputName: "weekend.md",
|
||||
ArtifactGroup: "weekend",
|
||||
BatchOutputName: "weekend.md",
|
||||
Generated: true,
|
||||
@@ -65,7 +61,6 @@ func DefaultRegistry() Registry {
|
||||
Name: "Storm Report",
|
||||
PromptID: "weather.storm_report",
|
||||
ComparisonStrategy: CompareExplicitWindow,
|
||||
DefaultOutputName: "storm.md",
|
||||
ArtifactGroup: "storm",
|
||||
BatchOutputName: "storm.md",
|
||||
Generated: true,
|
||||
|
||||
@@ -154,14 +154,13 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
||||
if metadata.PreflightPath == "" {
|
||||
return "", fmt.Errorf("metadata preflight path is required")
|
||||
}
|
||||
path := metadataPathFromStored(metadata)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
||||
if metadata.MetadataPath == "" {
|
||||
return "", fmt.Errorf("metadata path is required")
|
||||
}
|
||||
if err := fileutil.WriteJSONAtomic(path, metadata); err != nil {
|
||||
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
return metadata.MetadataPath, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
@@ -366,14 +365,6 @@ func readJSON(path string, target any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func metadataPathFromStored(metadata Metadata) string {
|
||||
if metadata.BriefingPath == "" {
|
||||
return ""
|
||||
}
|
||||
filename := metadata.RunID + ".metadata.json"
|
||||
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
|
||||
}
|
||||
|
||||
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -122,6 +122,46 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||
briefingPackage := stateBriefingPackage(resolved)
|
||||
paths, err := store.Paths(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
|
||||
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
||||
|
||||
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
||||
Briefing: otherBriefingPath,
|
||||
Metadata: paths.Metadata,
|
||||
DataPackage: paths.DataPackage,
|
||||
Preflight: paths.Preflight,
|
||||
RenderedReport: paths.RenderedReport,
|
||||
})
|
||||
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveMetadata() error = %v", err)
|
||||
}
|
||||
if metadataPath != paths.Metadata {
|
||||
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
|
||||
}
|
||||
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
|
||||
}
|
||||
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshot(t *testing.T) {
|
||||
@@ -400,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"},
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
||||
type Metadata struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
MetadataPath string `json:"-"`
|
||||
ReportID report.ID `json:"reportId"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
PromptID string `json:"promptId"`
|
||||
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"`
|
||||
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
||||
return Metadata{
|
||||
SchemaVersion: MetadataSchemaVersion,
|
||||
RunID: metadata.RunID,
|
||||
MetadataPath: paths.Metadata,
|
||||
ReportID: metadata.ReportID,
|
||||
Variant: briefingPackage.Metadata.Variant,
|
||||
PromptID: metadata.PromptID,
|
||||
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,
|
||||
@@ -51,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