Compare commits
10 Commits
5ecbc06c85
...
a2f0a2fc36
| Author | SHA1 | Date | |
|---|---|---|---|
| a2f0a2fc36 | |||
| 9f552cff6b | |||
| b913194fb4 | |||
| 3eccafad6b | |||
| a9d87bdbaa | |||
| 6f9255105d | |||
| c04e3c5599 | |||
| f15315f1b9 | |||
| 0ef6cd567e | |||
| b308ff4d6b |
34
docs/cli.md
34
docs/cli.md
@@ -9,7 +9,7 @@ batches, and inspects stored artifacts.
|
||||
weatherreporter generate today --out ./today.md
|
||||
```
|
||||
|
||||
This loads configuration, fetches weather data, writes managed workspace
|
||||
This loads configuration, collects weather data, writes managed workspace
|
||||
artifacts, runs `scriptorium render` as a preflight check, runs structured
|
||||
`scriptorium run`, validates generated text, renders the embedded Today
|
||||
template, and writes an extra Markdown copy to `./today.md`. If distributor
|
||||
@@ -45,26 +45,32 @@ notification uses the managed report path, not the extra copy. `generate daily`,
|
||||
generated-text artifacts, validate structured text from Scriptorium, and render
|
||||
the managed Markdown report from embedded templates. `generate daily` requires
|
||||
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a
|
||||
command error and stops before weather data is fetched. `generate hourly`
|
||||
command error and stops before weather data is collected. `generate hourly`
|
||||
covers the next six hours in the effective report timezone and does not accept
|
||||
date or event window flags. `generate storm` requires explicit event-window
|
||||
bounds with `--start` and `--end`.
|
||||
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
runs continue independent reports after a failure, print a JSON summary to
|
||||
stdout, write compact status lines to stderr, and return nonzero when any report
|
||||
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
|
||||
notification uses each managed report path, not the extra copies. When
|
||||
notification is enabled, batch summaries and status lines include notification
|
||||
status, accepted distributor run ID, or notification error fields for each
|
||||
attempted report.
|
||||
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
|
||||
for each later future local civil day with complete hourly forecast coverage.
|
||||
`run evening` generates Tomorrow Report and the same eligible future Daily
|
||||
reports. Future Daily expansion starts with the day after tomorrow and skips
|
||||
days that do not have every hourly forecast period for the local civil day.
|
||||
Batch commands collect weather data once before planning; a collection failure
|
||||
stops the batch before any report is generated. Batch runs continue independent
|
||||
reports after a later report failure, print a JSON summary to stdout, write
|
||||
compact status lines to stderr, and return nonzero when any report failed.
|
||||
`--out-dir` writes extra Markdown copies for the operator; distributor
|
||||
notification uses each managed report path, not the extra copies. Today and
|
||||
Tomorrow use their report default copy names, and dynamic Daily copies use
|
||||
`daily-YYYY-MM-DD.md`. When notification is enabled, batch summaries and status
|
||||
lines include notification status, accepted distributor run ID, or notification
|
||||
error fields for each attempted report.
|
||||
|
||||
Hourly Report is explicit only; it is not included in `run morning` or `run
|
||||
evening`.
|
||||
Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
|
||||
not included in `run morning` or `run evening`.
|
||||
|
||||
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or invoke `scriptorium`.
|
||||
They do not collect weather data or invoke `scriptorium`.
|
||||
|
||||
## Flags
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ weather_api:
|
||||
base_url: https://weather.api.example.com/
|
||||
```
|
||||
|
||||
`weather_api.base_url` is required for commands that fetch weather data. Other
|
||||
fields fall back to defaults.
|
||||
`weather_api.base_url` is required for commands that collect weather data.
|
||||
Other fields fall back to defaults.
|
||||
|
||||
## Production-Oriented Config
|
||||
|
||||
@@ -41,7 +41,7 @@ config test suite.
|
||||
|
||||
### `weather_api`
|
||||
|
||||
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
|
||||
- `base_url`: absolute base URL for the Weather API. Required for generation and collection workflows.
|
||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||
- `precision`: numeric precision query value. Default: `1`.
|
||||
- `units`: Weather API units query value. Default: `us`.
|
||||
|
||||
@@ -5,11 +5,12 @@ This document describes the workflow coordinator in `internal/app`.
|
||||
## Purpose
|
||||
|
||||
`internal/app` coordinates the top-level use cases after CLI parsing and config
|
||||
loading are complete. It resolves report definitions, fetches weather data,
|
||||
builds collected and derived facts, builds module snapshots and prompt-input
|
||||
artifacts, invokes Scriptorium through the adapter boundary, optionally
|
||||
notifies distributor through an app-owned notifier boundary, persists managed
|
||||
state, runs batches, and reads existing artifacts for inspection.
|
||||
loading are complete. It resolves report definitions, collects weather data
|
||||
through `internal/collect`, builds collected and derived facts, builds module
|
||||
snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
|
||||
boundary, optionally notifies distributor through an app-owned notifier
|
||||
boundary, persists managed state, runs batches, and reads existing artifacts
|
||||
for inspection.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
@@ -17,12 +18,12 @@ Inputs:
|
||||
|
||||
- `GenerateRequest` for one report command
|
||||
- `BatchRequest` for morning or evening batch commands
|
||||
- `FetchBundleRequest` for explicit bundle fetch and save workflows
|
||||
- `FetchBundleRequest` for explicit bundle collection and save workflows
|
||||
- `ReportRequest` for single-report generation
|
||||
- resolved report definitions from `internal/report`
|
||||
- weather data bundles from `internal/adapters/weatherapi`
|
||||
- collection results from `internal/collect`
|
||||
- prior snapshots loaded from `internal/state`
|
||||
- optional renderer, notifier, and state-store fakes for tests
|
||||
- optional collector, renderer, notifier, and state-store fakes for tests
|
||||
|
||||
Outputs:
|
||||
|
||||
@@ -32,7 +33,7 @@ Outputs:
|
||||
notification result when attempted
|
||||
- batch summaries with per-report status, artifact paths, error text, and
|
||||
notification outcome when attempted
|
||||
- saved Weather API bundle JSON for fetch workflows
|
||||
- saved Weather API bundle JSON for explicit bundle collection workflows
|
||||
- inspection JSON values for reports, metadata, module snapshots, data
|
||||
packages, prior snapshots, and source provenance
|
||||
|
||||
@@ -45,15 +46,18 @@ construct Scriptorium argv.
|
||||
|
||||
Report selection and report identity policy come from `internal/report`.
|
||||
Collected and derived fact contracts come from `internal/facts`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
|
||||
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
|
||||
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
|
||||
persisted metadata stay in `internal/state`.
|
||||
Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
|
||||
upstream collection stays in `internal/collect`. Scriptorium subprocess
|
||||
behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
|
||||
stays in `internal/adapters/distributor`. Filesystem layout and persisted
|
||||
metadata stay in `internal/state`.
|
||||
|
||||
## Data Flow Terms
|
||||
|
||||
- `CollectedFacts` are normalized source facts fetched once from Weather API
|
||||
and made available to derivation and module builders.
|
||||
- `collect.Result` is the app-facing upstream collection result. It carries the
|
||||
normalized `weatherdata.Bundle` used by report generation.
|
||||
- `CollectedFacts` are normalized source facts derived from a collected Weather
|
||||
API bundle and made available to derivation and module builders.
|
||||
- `DerivedFacts` are deterministic calculations over collected facts, the
|
||||
resolved valid period, daypart configuration, and report-specific windows.
|
||||
- `module.Output` values are ordered deterministic stanzas built from collected
|
||||
@@ -77,33 +81,35 @@ defaults.
|
||||
|
||||
## Generation Workflow
|
||||
|
||||
Single-report generation shares this setup:
|
||||
Single-report commands validate the report command, collect once through
|
||||
`internal/collect`, resolve the requested report, and pass the resolved report
|
||||
plus explicit collection into `GenerateReport`.
|
||||
|
||||
1. Resolve the command report to a `report.Resolved` value.
|
||||
2. Create or use a filesystem store.
|
||||
3. Locate any prior compatible snapshot through `internal/state`.
|
||||
4. Fetch a Weather API bundle.
|
||||
5. Build collected and derived facts once.
|
||||
6. Execute configured modules and save the module snapshot.
|
||||
7. Compute Recent Changes from structured prior and current module snapshots.
|
||||
8. Build and save the YAML Scriptorium `data_package`.
|
||||
9. Run Scriptorium render preflight.
|
||||
10. Save preflight JSON when a render result is available.
|
||||
11. Save metadata for inspection.
|
||||
`GenerateReport` then uses this setup:
|
||||
|
||||
1. Create or use a filesystem store.
|
||||
2. Locate any prior compatible snapshot through `internal/state`.
|
||||
3. Build collected and derived facts from the supplied collection.
|
||||
4. Execute configured modules and save the module snapshot.
|
||||
5. Compute Recent Changes from structured prior and current module snapshots.
|
||||
6. Build and save the YAML Scriptorium `data_package`.
|
||||
7. Run Scriptorium render preflight.
|
||||
8. Save preflight JSON when a render result is available.
|
||||
9. Save metadata for inspection.
|
||||
|
||||
For `scriptorium_markdown` reports, generation then:
|
||||
|
||||
12. Runs Scriptorium report generation to the managed report path.
|
||||
10. Runs Scriptorium report generation to the managed report path.
|
||||
|
||||
For `generated_text_template` reports, generation then:
|
||||
|
||||
12. Looks up the generated-text catalog entry for the report schema/template
|
||||
10. Looks up the generated-text catalog entry for the report schema/template
|
||||
IDs.
|
||||
13. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||
14. Saves the structured Scriptorium run result.
|
||||
15. Validates and saves normalized generated text.
|
||||
16. Builds and saves a typed render context.
|
||||
17. Renders Markdown from the embedded template to the managed report path.
|
||||
11. Runs structured Scriptorium generation to the raw generated-text JSON path.
|
||||
12. Saves the structured Scriptorium run result.
|
||||
13. Validates and saves normalized generated text.
|
||||
14. Builds and saves a typed render context.
|
||||
15. Renders Markdown from the embedded template to the managed report path.
|
||||
|
||||
After either mode has produced a managed Markdown report, shared finalization:
|
||||
|
||||
@@ -119,7 +125,7 @@ After either mode has produced a managed Markdown report, shared finalization:
|
||||
If render preflight returns both a result and an error, preflight JSON and
|
||||
metadata are persisted before the error is returned. If Scriptorium report
|
||||
generation returns an error after writing output, the managed report and
|
||||
metadata remain inspectable. Notification is not attempted after Weather API,
|
||||
metadata remain inspectable. Notification is not attempted after collection,
|
||||
module snapshot, prompt input, render, Scriptorium run, or metadata-save
|
||||
failures.
|
||||
Generated-text report failures are returned with report ID, RunID, and the
|
||||
@@ -134,17 +140,21 @@ failure context.
|
||||
|
||||
## Batch Workflow
|
||||
|
||||
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except
|
||||
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated
|
||||
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled
|
||||
batches. Batch output copy names come from report definitions. Batch generation
|
||||
continues independent reports after a failure, records each result, writes
|
||||
compact status lines to stderr, emits a JSON summary to stdout, and returns an
|
||||
aggregate error when any report failed. When notification is enabled, each
|
||||
successfully generated report is notified independently. Notification failure
|
||||
marks that report failed, records notification fields in the batch result, and
|
||||
does not stop later reports. `--out-dir` copies are never used as notification
|
||||
source files.
|
||||
`run morning` collects once, plans Today Report, Tomorrow Report, and eligible
|
||||
future Daily Reports from the collected hourly forecast, then passes the same
|
||||
collection into each report generation. `run evening` uses the same collection
|
||||
and planning rules, but starts with Tomorrow Report. Future Daily reports start
|
||||
with the day after tomorrow and require complete hourly forecast coverage for
|
||||
the target local civil day. Dynamic Daily `--out-dir` copies use
|
||||
`daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
|
||||
A collection failure stops the batch before planning or report generation.
|
||||
After planning succeeds, batch generation continues independent reports after a
|
||||
failure, records each result, writes compact status lines to stderr, emits a
|
||||
JSON summary to stdout, and returns an aggregate error when any report failed.
|
||||
When notification is enabled, each successfully generated report is notified
|
||||
independently. Notification failure marks that report failed, records
|
||||
notification fields in the batch result, and does not stop later reports.
|
||||
`--out-dir` copies are never used as notification source files.
|
||||
|
||||
## Inspection Workflow
|
||||
|
||||
@@ -155,8 +165,8 @@ inspection view.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Resolve errors stop the requested workflow before fetching weather data.
|
||||
- Weather API and module execution errors stop that report before Scriptorium
|
||||
- Resolve errors stop the requested workflow before collection.
|
||||
- Collection and module execution errors stop that report before Scriptorium
|
||||
runs.
|
||||
- Prompt input validation fails before render preflight.
|
||||
- Render and run errors preserve Scriptorium stderr and exit-code context.
|
||||
@@ -173,12 +183,17 @@ inspection view.
|
||||
Inspect:
|
||||
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/app/batch_plan_test.go`
|
||||
- `internal/collect/collect_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
- `internal/state/filesystem_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Report behavior is resolved through `internal/report`.
|
||||
- Generate and run commands collect once before report generation.
|
||||
- Batch planning is app-owned because future Daily membership depends on
|
||||
collected hourly forecast coverage.
|
||||
- Generated reports use the same app request and result types regardless of
|
||||
report ID.
|
||||
- Render preflight precedes Scriptorium report generation.
|
||||
|
||||
@@ -77,7 +77,7 @@ remain in report metadata, source provenance, and integration artifacts.
|
||||
- This package selects and shapes already-collected weather facts for prompts.
|
||||
- It validates module composition against report compatibility and option
|
||||
types.
|
||||
- It does not fetch weather data, compare prior snapshots, write module
|
||||
- It does not collect weather data, compare prior snapshots, write module
|
||||
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
|
||||
metadata.
|
||||
|
||||
|
||||
58
docs/internal/collect.md
Normal file
58
docs/internal/collect.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Collection Internals
|
||||
|
||||
This document describes the app-facing upstream collection boundary in
|
||||
`internal/collect`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`internal/collect` is the canonical package used by app workflows to collect
|
||||
upstream Weather API data. It constructs the Weather API adapter, fetches a
|
||||
normalized bundle, and returns that bundle without applying report selection or
|
||||
batch policy.
|
||||
|
||||
## Contract
|
||||
|
||||
Inputs:
|
||||
|
||||
- `collect.Request`, containing the effective `config.Config`
|
||||
- `context.Context` for cancellation
|
||||
|
||||
Output:
|
||||
|
||||
- `collect.Result`, containing `*weatherdata.Bundle`
|
||||
|
||||
`Run` returns an actionable error when Weather API adapter construction or
|
||||
bundle fetch fails. The package does not derive `facts.CollectedFacts`, build
|
||||
modules, resolve report periods, select reports, write state, invoke
|
||||
Scriptorium, or notify distributor.
|
||||
|
||||
## App Usage
|
||||
|
||||
`internal/app` owns a narrow `Collector` interface for orchestration tests. The
|
||||
default implementation calls `collect.Run`.
|
||||
|
||||
Single-report generation collects once, resolves the requested report, and
|
||||
passes the explicit collection into report generation. Batch generation
|
||||
collects once before planning and passes the same collection into each planned
|
||||
report. If collection returns no bundle, app orchestration returns an error
|
||||
before report generation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Weather API HTTP details stay in `internal/adapters/weatherapi`. The collection
|
||||
package returns normalized `weatherdata` only. It must not know about report
|
||||
IDs, prompt IDs, batch names, Daily eligibility, module composition, Recent
|
||||
Changes, state paths, or Scriptorium arguments.
|
||||
|
||||
## Tests
|
||||
|
||||
Inspect:
|
||||
|
||||
- `internal/collect/collect_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
- App-facing Weather API collection goes through `internal/collect`.
|
||||
- Collection returns normalized source data, not report facts or prompt input.
|
||||
- Report and batch policy belongs outside `internal/collect`.
|
||||
@@ -250,7 +250,7 @@ meet the severity threshold.
|
||||
|
||||
- This package owns module identifiers, config item envelopes, output
|
||||
envelopes, snapshot validation, and typed stanza lookup.
|
||||
- It does not define report IDs, execute builders, fetch weather data, derive
|
||||
- It does not define report IDs, execute builders, collect weather data, derive
|
||||
forecast facts, write state, or invoke Scriptorium.
|
||||
|
||||
## State Or Manifest Behavior
|
||||
|
||||
@@ -128,7 +128,7 @@ Current categories are:
|
||||
|
||||
- This package owns prompt package schema, YAML marshaling, YAML loading, and
|
||||
validation.
|
||||
- It does not fetch weather data, derive forecast summaries, execute modules,
|
||||
- It does not collect weather data, derive forecast summaries, execute modules,
|
||||
choose module prompt export shapes, find prior snapshots, compare changes,
|
||||
choose artifact paths, or invoke Scriptorium.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Report Registry Internals
|
||||
|
||||
This document describes report identity, valid-period resolution, batch
|
||||
membership, output naming, artifact grouping, and comparison declarations in
|
||||
This document describes report identity, valid-period resolution, output
|
||||
naming, artifact grouping, batch command names, and comparison declarations in
|
||||
`internal/report`.
|
||||
|
||||
## Purpose
|
||||
@@ -24,7 +24,6 @@ Each report definition declares:
|
||||
- batch output copy filename
|
||||
- generated-report eligibility
|
||||
- prior-report compatibility list
|
||||
- morning or evening batch membership
|
||||
- default ordered module composition
|
||||
|
||||
Report-owned helpers map public command names and config keys to report IDs.
|
||||
@@ -69,8 +68,7 @@ All report definitions are eligible for generation.
|
||||
not a configuration field.
|
||||
- 3-Day Outlook covers the interval from generation time through local midnight
|
||||
three days later.
|
||||
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
|
||||
Sunday morning batch resolution.
|
||||
- Weekend Outlook covers the upcoming weekend window.
|
||||
- Storm Report covers an explicit event window supplied by the caller.
|
||||
|
||||
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
|
||||
@@ -80,8 +78,9 @@ must be after start time.
|
||||
## Boundaries
|
||||
|
||||
`internal/report` defines report metadata, public report names, batch command
|
||||
names, and time coverage. It does not fetch weather data, build module values,
|
||||
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium.
|
||||
names, output naming, and time coverage. It does not collect weather data, plan
|
||||
batch membership, build module values, compare snapshot contents, write state,
|
||||
parse CLI flags, or invoke Scriptorium.
|
||||
|
||||
The CLI parses flags and command structure, then uses report-owned helpers for
|
||||
report and batch command names. Config loading uses report-owned helpers for
|
||||
@@ -94,11 +93,16 @@ output path copying uses batch output names from report definitions. Report
|
||||
module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
|
||||
`hourly`, or descriptive names such as `three_day_outlook`.
|
||||
|
||||
## Batch Membership
|
||||
## Batch Commands
|
||||
|
||||
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook
|
||||
except on Sunday. Evening batches include Tomorrow Report. Daily Report and
|
||||
Hourly Report are not part of a scheduled batch.
|
||||
`internal/report` owns the public batch command names `morning` and `evening`
|
||||
and validates them through `BatchForCommandName`. Data-dependent batch
|
||||
membership is owned by `internal/app`, because it depends on collected hourly
|
||||
forecast coverage.
|
||||
|
||||
Report definitions still declare default batch output copy filenames. App
|
||||
batch planning uses those filenames for fixed report entries and supplies
|
||||
date-qualified names for dynamic Daily entries.
|
||||
|
||||
## State And App Usage
|
||||
|
||||
@@ -135,3 +139,5 @@ Inspect:
|
||||
- Artifact grouping, batch output filenames, generated-report eligibility,
|
||||
default module composition, comparison compatibility, and comparison strategy
|
||||
are declared by report definition.
|
||||
- App-owned batch planning uses report definitions but does not live in the
|
||||
report registry.
|
||||
|
||||
@@ -43,7 +43,7 @@ Generated-text prompt sources are maintained under
|
||||
## Boundaries
|
||||
|
||||
This package owns embedded asset lookup, Go template parsing, and Markdown
|
||||
template execution. It does not fetch weather data, build module outputs,
|
||||
template execution. It does not collect weather data, build module outputs,
|
||||
validate GeneratedText, construct render contexts, choose report definitions,
|
||||
write artifacts, invoke Scriptorium, or notify distributor.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Outputs:
|
||||
|
||||
`internal/adapters/scriptorium` owns Scriptorium command construction and
|
||||
subprocess execution. It does not choose report types, build prompt input,
|
||||
fetch weather data, decide workflow order, or persist workflow metadata.
|
||||
collect weather data, decide workflow order, or persist workflow metadata.
|
||||
|
||||
The adapter exposes request and result structs for render, Markdown run, and
|
||||
structured generated-text run operations. State persistence uses state-owned
|
||||
|
||||
@@ -18,11 +18,11 @@ weatherreporter generate weekend
|
||||
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
|
||||
```
|
||||
|
||||
Generation commands resolve a report period, fetch a Weather API bundle, build
|
||||
a rich JSON module snapshot, build a curated YAML prompt input data package, run
|
||||
`scriptorium render`, and write managed artifacts under the configured
|
||||
workspace. Markdown-path reports then run `scriptorium run` directly to the
|
||||
managed Markdown report path.
|
||||
Generation commands resolve a report period, collect a Weather API bundle,
|
||||
build a rich JSON module snapshot, build a curated YAML prompt input data
|
||||
package, run `scriptorium render`, and write managed artifacts under the
|
||||
configured workspace. Markdown-path reports then run `scriptorium run` directly
|
||||
to the managed Markdown report path.
|
||||
|
||||
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
|
||||
use the generated-text-template workflow. They run structured `scriptorium run`
|
||||
@@ -31,8 +31,8 @@ and render the managed Markdown report from embedded templates. `generate
|
||||
daily` requires `--date YYYY-MM-DD` for the selected local civil day.
|
||||
`generate today` covers the selected or current local civil day. `generate
|
||||
hourly` covers the six-hour rolling period from generation time in the
|
||||
effective report timezone and is not part of scheduled morning or evening
|
||||
batches.
|
||||
effective report timezone and is not included in `run morning` or
|
||||
`run evening`.
|
||||
|
||||
When distributor notification is enabled, weatherreporter uploads the managed
|
||||
Markdown report after report rendering succeeds and final metadata is saved.
|
||||
@@ -46,16 +46,23 @@ weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday. `run evening` generates the Tomorrow Report. Batch
|
||||
commands print a JSON summary to stdout, write compact per-report status lines
|
||||
to stderr, continue independent reports after one report fails, and return
|
||||
nonzero when any report failed. When notification is configured, the summary and
|
||||
status lines include notification status, accepted distributor run ID, or
|
||||
notification error fields for each attempted report. `--out-dir PATH` writes
|
||||
extra Markdown copies using report default filenames such as `today.md`,
|
||||
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
|
||||
distributor upload sources.
|
||||
`run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
|
||||
for each later future local civil day with complete hourly forecast coverage.
|
||||
`run evening` generates Tomorrow Report and the same eligible future Daily
|
||||
reports. Future Daily expansion starts with the day after tomorrow. A Daily
|
||||
report is eligible only when the collected hourly forecast contains every
|
||||
hourly period for that local civil day; partial days are skipped. Batch commands
|
||||
collect weather data once before planning, and a collection failure stops the
|
||||
batch before any report is generated.
|
||||
|
||||
After planning succeeds, batch commands print a JSON summary to stdout, write
|
||||
compact per-report status lines to stderr, continue independent reports after
|
||||
one report fails, and return nonzero when any report failed. When notification
|
||||
is configured, the summary and status lines include notification status,
|
||||
accepted distributor run ID, or notification error fields for each attempted
|
||||
report. `--out-dir PATH` writes extra Markdown copies using report default
|
||||
filenames such as `today.md` and `tomorrow.md`; dynamic Daily copies use
|
||||
`daily-YYYY-MM-DD.md`. These copies are not used as distributor upload sources.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
@@ -247,8 +254,8 @@ and the default output filename value is `daily.md`.
|
||||
For Today, the default report ID and artifact group values are both `today`,
|
||||
and the batch output filename value is `today.md`.
|
||||
|
||||
Notification happens after final metadata save for generated reports. Weather
|
||||
API, module snapshot, data-package, render preflight, Scriptorium run,
|
||||
Notification happens after final metadata save for generated reports.
|
||||
Collection, module snapshot, data-package, render preflight, Scriptorium run,
|
||||
generated-text validation, template rendering, and metadata-save failures do
|
||||
not trigger notification. A notification failure fails that report.
|
||||
In a batch, other reports continue, the failed report includes notification
|
||||
@@ -274,7 +281,7 @@ bundle, so existing uploaded dated report paths can remain available.
|
||||
## Inspection
|
||||
|
||||
Inspection commands read existing workspace artifacts and emit JSON to stdout.
|
||||
They do not fetch weather data or run `scriptorium`.
|
||||
They do not collect weather data or run `scriptorium`.
|
||||
|
||||
```text
|
||||
weatherreporter inspect reports --limit 10
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
# Batch Collection Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap defines the planned change to make `weatherreporter run morning`
|
||||
and `weatherreporter run evening` use one canonical upstream collection path
|
||||
and data-aware batch planning.
|
||||
|
||||
The feature has two related goals:
|
||||
|
||||
- move upstream Weather API collection into a single `internal/collect` package
|
||||
used by all generation workflows;
|
||||
- update scheduled batches so they generate Today, Tomorrow, and future Daily
|
||||
reports according to available full-day hourly forecast coverage.
|
||||
|
||||
This document lives under `docs/roadmap/` because the behavior described here
|
||||
is not yet implemented.
|
||||
|
||||
## User Intent
|
||||
|
||||
Batch commands should produce a practical publication set while avoiding
|
||||
partial Daily reports.
|
||||
|
||||
Morning publication should generate:
|
||||
|
||||
- Today report for the current civil day;
|
||||
- Tomorrow report for the next civil day;
|
||||
- one Daily report for each later future civil day where the upstream hourly
|
||||
forecast fully covers the entire target day.
|
||||
|
||||
Evening publication should generate:
|
||||
|
||||
- Tomorrow report for the next civil day;
|
||||
- one Daily report for each later future civil day where the upstream hourly
|
||||
forecast fully covers the entire target day.
|
||||
|
||||
Neither batch should generate Daily reports for partially covered days. That
|
||||
keeps the Daily report format simple and avoids requiring templates or modules
|
||||
to explain incomplete forecast coverage.
|
||||
|
||||
The morning batch should no longer include the legacy 3-Day Outlook or Weekend
|
||||
Outlook. Those reports remain individually generated report types unless
|
||||
separately removed.
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- Add a new canonical upstream collection package: `internal/collect`.
|
||||
- Keep Weather API HTTP details in `internal/adapters/weatherapi`; `collect`
|
||||
orchestrates collection and returns normalized upstream data.
|
||||
- Use `internal/collect` for all report generation workflows, including
|
||||
`generate <report>`, `run morning`, and `run evening`.
|
||||
- Fetch upstream weather data once per command invocation.
|
||||
- Pass the collected result into report generation; report generation should
|
||||
not fetch directly from Weather API.
|
||||
- Keep batch planning in `internal/app`; do not make `internal/collect` aware of
|
||||
morning, evening, report IDs, or batch membership.
|
||||
- Centralize batch-specific app planning in a single file, expected to be
|
||||
`internal/app/batch_plan.go`, unless implementation shows a clearer local
|
||||
name.
|
||||
- `run morning` should generate `today`, `tomorrow`, then future `daily`
|
||||
reports in ascending local date order.
|
||||
- `run evening` should generate `tomorrow`, then future `daily` reports in
|
||||
ascending local date order.
|
||||
- Future Daily eligibility is based on full hourly forecast coverage, not
|
||||
narrative forecast availability.
|
||||
- Daily expansion starts with the day after tomorrow for both morning and
|
||||
evening batches.
|
||||
- Remove `three-day` and `weekend` from `run morning`.
|
||||
- Dynamic Daily reports in a batch should use date-qualified `--out-dir` copy
|
||||
names, such as `daily-YYYY-MM-DD.md`.
|
||||
- Keep public CLI syntax unchanged: `weatherreporter run morning` and
|
||||
`weatherreporter run evening` remain the commands.
|
||||
|
||||
## Current Repository Shape
|
||||
|
||||
Current code resolves batches statically:
|
||||
|
||||
- `internal/cli/root.go` parses `run morning` or `run evening` and builds
|
||||
`app.BatchRequest`.
|
||||
- `internal/app.RunBatchDetailed` calls `ResolveBatch`.
|
||||
- `internal/app.ResolveBatch` delegates to `report.Registry.BatchReports`.
|
||||
- `internal/report.Registry.BatchReports` currently resolves the morning batch
|
||||
to Today, 3-Day, and Weekend except on Sunday.
|
||||
- `internal/report.Registry.BatchReports` currently resolves the evening batch
|
||||
to Tomorrow.
|
||||
- `internal/app.GenerateReport` fetches the Weather API bundle for each report
|
||||
through `FetchBundle`.
|
||||
- `FetchBundle` directly constructs the Weather API adapter and calls
|
||||
`FetchBundle`.
|
||||
- Daily report resolution already supports explicit target dates through
|
||||
`report.ResolveRequest.Date`.
|
||||
|
||||
This means current batch resolution cannot decide future Daily membership from
|
||||
available upstream data without either fetching during planning or attempting
|
||||
report generation for candidate dates. It also means multiple reports in one
|
||||
batch can fetch different upstream snapshots.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
The target command flow is:
|
||||
|
||||
1. CLI parses the command and loads config.
|
||||
2. App orchestration calls `internal/collect` once.
|
||||
3. `internal/collect` calls the Weather API adapter and returns a collection
|
||||
result containing the normalized `weatherdata.Bundle`.
|
||||
4. App batch planning uses the collected bundle plus report registry metadata
|
||||
to resolve the requested reports.
|
||||
5. App report generation receives the same collected bundle for every report in
|
||||
the command.
|
||||
6. Facts, modules, prompt input, generated text, templates, state, and
|
||||
notifications operate as they do today, but consume already-collected data.
|
||||
|
||||
The intended dependency direction is:
|
||||
|
||||
```text
|
||||
internal/adapters/weatherapi -> internal/collect -> internal/app -> internal/facts -> internal/briefing/internal modules
|
||||
```
|
||||
|
||||
Report definitions still live in `internal/report`. Report definitions should
|
||||
not know how upstream data is fetched or how batch membership is expanded from
|
||||
available data.
|
||||
|
||||
## `internal/collect` Contract
|
||||
|
||||
The initial package should be intentionally narrow. A suitable first contract
|
||||
is:
|
||||
|
||||
```go
|
||||
package collect
|
||||
|
||||
type Request struct {
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Bundle *weatherdata.Bundle
|
||||
}
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- `collect.Run(ctx, Request)` constructs and uses the Weather API adapter.
|
||||
- It returns the normalized Weather API bundle.
|
||||
- It wraps collection errors with operation context.
|
||||
- It does not derive report facts.
|
||||
- It does not resolve reports or batches.
|
||||
- It does not write state artifacts.
|
||||
- It does not invoke Scriptorium or distributor.
|
||||
|
||||
Future source families, such as radar, observations history, or additional
|
||||
review/report inputs, should be added to this package as upstream collection
|
||||
responsibilities. The package should remain source collection, not report
|
||||
policy.
|
||||
|
||||
## Batch Target Behavior
|
||||
|
||||
### Morning
|
||||
|
||||
`run morning` should resolve reports in this order:
|
||||
|
||||
1. `today`
|
||||
2. `tomorrow`
|
||||
3. `daily` for each eligible future date beginning with the day after tomorrow
|
||||
|
||||
### Evening
|
||||
|
||||
`run evening` should resolve reports in this order:
|
||||
|
||||
1. `tomorrow`
|
||||
2. `daily` for each eligible future date beginning with the day after tomorrow
|
||||
|
||||
### Future Daily Eligibility
|
||||
|
||||
Eligibility for each future Daily report:
|
||||
|
||||
- compute the target civil day in `weather_api.timezone`;
|
||||
- examine the collected hourly forecast periods;
|
||||
- require hourly periods whose local `startTime` values cover every required
|
||||
hourly start inside the civil day;
|
||||
- for ordinary days, required starts are `00:00` through `23:00` local time;
|
||||
- on daylight-saving transitions, generate required starts by stepping through
|
||||
the actual local civil day from `day.Start` to `day.End`, so 23-hour and
|
||||
25-hour days are handled consistently;
|
||||
- require each selected period to have a valid start and end;
|
||||
- stop scanning once no later complete civil day can be found within the
|
||||
available hourly forecast range.
|
||||
|
||||
The planner should use exact local hourly start matching for eligibility, not
|
||||
mere overlap. This keeps the contract clear: a day is eligible only when the
|
||||
hourly source contains a full set of hourly rows for that day.
|
||||
|
||||
## Report Generation Contract
|
||||
|
||||
After the collection refactor, `GenerateReport` should no longer fetch upstream
|
||||
data itself. The preferred final shape is:
|
||||
|
||||
- `Generate` collects once, resolves the single report, then calls
|
||||
`GenerateReport` with the collected result.
|
||||
- `RunBatchDetailed` collects once, plans the batch, then calls
|
||||
`GenerateReport` for each resolved report with the same collected result.
|
||||
- `GenerateReport` requires collected data and returns an actionable error if
|
||||
called without it.
|
||||
|
||||
A transitional optional collected-data field may be used during implementation,
|
||||
but the final state should have one explicit collection path and no hidden
|
||||
Weather API fetch inside report generation.
|
||||
|
||||
## Output And State Policy
|
||||
|
||||
Existing managed workspace artifact paths should remain stable. Multiple Daily
|
||||
reports can appear in one batch, so `--out-dir` copies must not use the same
|
||||
`daily.md` filename for every dynamic Daily report. Dynamic Daily batch copies
|
||||
should use `daily-YYYY-MM-DD.md`. Today and Tomorrow should keep their existing
|
||||
batch copy names.
|
||||
|
||||
Distributor upload paths and managed report paths should continue to be derived
|
||||
from report definitions and resolved report metadata, not from optional
|
||||
`--out-dir` copies.
|
||||
|
||||
## Implementation Planning
|
||||
|
||||
The staged implementation plan belongs in `docs/roadmap/implementation.md`.
|
||||
That document should be treated as the executable plan for coding agents.
|
||||
|
||||
After implementation, update implemented docs only:
|
||||
|
||||
- `docs/cli.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/app-orchestration.md`
|
||||
- `docs/internal/report-registry.md`
|
||||
- relevant troubleshooting entries if new failure modes are surfaced
|
||||
|
||||
Do not document future collection sources outside roadmap docs.
|
||||
|
||||
## Refactors To Avoid
|
||||
|
||||
- Do not create a generic workflow engine.
|
||||
- Do not move report definitions out of `internal/report`.
|
||||
- Do not make `internal/collect` aware of report IDs, prompt IDs, or batch
|
||||
names.
|
||||
- Do not make modules fetch upstream data.
|
||||
- Do not add config knobs for partial Daily coverage in this pass.
|
||||
- Do not remove 3-Day or Weekend report definitions unless separately planned.
|
||||
- Do not change public CLI syntax for `run morning` or `run evening`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None block the roadmap.
|
||||
@@ -73,8 +73,8 @@ represented in `CollectedFacts`.
|
||||
|
||||
Future module work should preserve these boundaries:
|
||||
|
||||
- collect upstream facts once per report run
|
||||
- keep upstream fetching out of modules
|
||||
- keep upstream collection in app orchestration
|
||||
- keep upstream collection out of modules
|
||||
- keep broad reusable calculations in `DerivedFacts`
|
||||
- keep prompt-facing field shape inside module builders
|
||||
- use typed options for configurable module behavior
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
# Batch Collection Implementation Roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the staged implementation plan for `docs/roadmap/batch.md`. Implement
|
||||
the stages in order. The feature has two goals:
|
||||
|
||||
- route all upstream Weather API collection through a canonical
|
||||
`internal/collect` package;
|
||||
- update `run morning` and `run evening` so they use one collected snapshot and
|
||||
dynamically add future Daily reports only for dates with complete hourly
|
||||
coverage.
|
||||
|
||||
This is an implementation roadmap only. Background, user intent, and target
|
||||
policy live in `docs/roadmap/batch.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve public CLI syntax.
|
||||
- Preserve managed workspace artifact paths.
|
||||
- Preserve distributor upload behavior and paths.
|
||||
- Keep Weather API HTTP details inside `internal/adapters/weatherapi`.
|
||||
- Keep source collection in `internal/collect`.
|
||||
- Keep batch planning in `internal/app`.
|
||||
- Keep report definitions in `internal/report`.
|
||||
- Do not make `internal/collect` aware of report IDs, prompt IDs, or batch
|
||||
names.
|
||||
- Do not add config fields for partial Daily coverage.
|
||||
- Do not create a workflow engine, scheduler package, or plugin system.
|
||||
- Do not remove 3-Day or Weekend report definitions in this change.
|
||||
|
||||
## Stage 1: Add Canonical Collection Package
|
||||
|
||||
Goal: introduce `internal/collect` as the only app-facing upstream collection
|
||||
API while preserving current behavior.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add `internal/collect`.
|
||||
- Define:
|
||||
|
||||
```go
|
||||
type Request struct {
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Bundle *weatherdata.Bundle
|
||||
}
|
||||
```
|
||||
|
||||
- Add `Run(ctx context.Context, req Request) (*Result, error)`.
|
||||
- `Run` should construct the Weather API adapter with `weatherapi.New` and call
|
||||
`client.FetchBundle(ctx)`.
|
||||
- Return an actionable error when adapter construction or fetch fails.
|
||||
- Keep the returned data normalized as `*weatherdata.Bundle`; do not derive
|
||||
`facts.CollectedFacts` or `facts.DerivedFacts` in this package.
|
||||
- Update `internal/app.FetchBundle` and `FetchAndSaveBundle`, if retained, to
|
||||
call `collect.Run` rather than constructing `weatherapi.New` directly.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add `internal/collect` tests using a local Weather API test server or a small
|
||||
adapter seam if needed.
|
||||
- Keep existing `FetchAndSaveBundle` app tests passing.
|
||||
- Ensure collection errors include collection/fetch context without leaking
|
||||
secrets.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `internal/collect` owns app-facing Weather API collection.
|
||||
- `internal/app.FetchBundle` no longer constructs `weatherapi.New` directly.
|
||||
- No report or batch behavior changes yet.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/collect ./internal/app ./internal/adapters/weatherapi
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 2: Add App Collection Seam
|
||||
|
||||
Goal: make collection testable from app orchestration without requiring live
|
||||
Weather API calls.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add a narrow app-owned collector interface, for example:
|
||||
|
||||
```go
|
||||
type Collector interface {
|
||||
Run(context.Context, collect.Request) (*collect.Result, error)
|
||||
}
|
||||
```
|
||||
|
||||
- Add a default adapter that calls `collect.Run`.
|
||||
- Add `Collector` fields to `GenerateRequest` and `BatchRequest`.
|
||||
- Default to the real collector when the request does not provide one.
|
||||
- Do not expose collector options through CLI.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add app tests proving `Generate` uses the provided collector.
|
||||
- Add app tests proving collection failure stops generation before report
|
||||
execution.
|
||||
- Add batch tests proving `RunBatchDetailed` can use a fake collector in later
|
||||
stages.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- App orchestration can be tested with a fake collector.
|
||||
- CLI behavior is unchanged.
|
||||
- External adapter types do not leak into app request or result structs.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/cli
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 3: Make Report Generation Require Collected Data
|
||||
|
||||
Goal: remove hidden Weather API fetching from `GenerateReport`.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add a collection field to `ReportRequest`, for example:
|
||||
|
||||
```go
|
||||
Collection collect.Result
|
||||
```
|
||||
|
||||
- Update `Generate` to:
|
||||
- collect once;
|
||||
- resolve the requested report;
|
||||
- pass the collection into `GenerateReport`.
|
||||
- Update `GenerateReport` to use `req.Collection.Bundle`.
|
||||
- If `req.Collection.Bundle` is nil, return an actionable error such as
|
||||
`collected weather bundle is required`.
|
||||
- Remove the direct `FetchBundle` call from `GenerateReport`.
|
||||
- Keep `BuildReportFacts` unchanged: it should still receive a
|
||||
`*weatherdata.Bundle`.
|
||||
- Update all app tests that call `GenerateReport` directly to pass a collected
|
||||
test bundle through `ReportRequest.Collection`.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add or update tests proving single-report generation collects once.
|
||||
- Add or update tests proving `GenerateReport` fails before state writes when
|
||||
no collected bundle is provided.
|
||||
- Keep generated-text, notification, state, and output-copy tests passing with
|
||||
the explicit collection field.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- `GenerateReport` has no direct Weather API fetch path.
|
||||
- Single-report commands still produce the same artifacts.
|
||||
- `GenerateReport` has an explicit data dependency.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/cli
|
||||
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected grep matches at this stage should be limited to
|
||||
`internal/collect`, `internal/adapters/weatherapi`, and their tests, plus any
|
||||
transitional app fetch helper tests that explicitly verify `FetchBundle`.
|
||||
|
||||
## Stage 4: Add Hourly Full-Day Coverage Helper
|
||||
|
||||
Goal: isolate the Daily eligibility rule before changing batch behavior.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Add helper logic in `internal/app/batch_plan.go`.
|
||||
- The helper should inspect `weatherdata.ForecastRun` hourly periods and return
|
||||
eligible future Daily dates.
|
||||
- Inputs should include:
|
||||
- hourly forecast run;
|
||||
- `now`;
|
||||
- loaded report timezone/location.
|
||||
- The helper should:
|
||||
- start candidate Daily expansion at the day after tomorrow;
|
||||
- compute local civil days with `timeutil.CivilDay`;
|
||||
- build required hourly start instants by stepping from civil-day start to
|
||||
civil-day end in one-hour increments;
|
||||
- require every required start instant to exist as a valid hourly period
|
||||
`StartTime`;
|
||||
- require matching periods to have valid end times after start times;
|
||||
- scan candidate dates through the maximum local date represented by hourly
|
||||
period start times;
|
||||
- return dates in ascending local date order;
|
||||
- return no dates when hourly data is missing.
|
||||
- Use exact start-time matching, not overlap-only matching.
|
||||
|
||||
Tests:
|
||||
|
||||
- Full ordinary local day with starts `00:00` through `23:00` is eligible.
|
||||
- Missing one required hour makes the date ineligible.
|
||||
- Partial final day is skipped.
|
||||
- Today and tomorrow are never returned by expansion.
|
||||
- Multiple eligible future days are returned in order.
|
||||
- Non-hourly or invalid periods are ignored.
|
||||
- DST transition days use actual civil-day hourly instants.
|
||||
- Missing hourly forecast returns no dynamic Daily dates.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Daily eligibility is tested independently from batch execution.
|
||||
- The helper does not resolve reports, call Weather API, write state, or invoke
|
||||
Scriptorium.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/app
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 5: Add Shared Batch Planner
|
||||
|
||||
Goal: make morning and evening batch membership app-owned and data-aware.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Continue using `report.BatchForCommandName` for CLI validation.
|
||||
- Add an internal planned report type in `internal/app`, for example:
|
||||
|
||||
```go
|
||||
type plannedBatchReport struct {
|
||||
Resolved report.Resolved
|
||||
OutputCopyName string
|
||||
}
|
||||
```
|
||||
|
||||
- Implement app-owned batch planning in `internal/app/batch_plan.go`.
|
||||
- Morning plan:
|
||||
- resolve `report.Today`;
|
||||
- resolve `report.Tomorrow`;
|
||||
- resolve `report.Daily` for each eligible future date from Stage 4.
|
||||
- Evening plan:
|
||||
- resolve `report.Tomorrow`;
|
||||
- resolve `report.Daily` for each eligible future date from Stage 4.
|
||||
- Resolve dynamic Daily reports by setting `ResolveRequest.Date` to the target
|
||||
local date.
|
||||
- Dynamic Daily planned reports should set `OutputCopyName` to
|
||||
`daily-YYYY-MM-DD.md`.
|
||||
- Today and Tomorrow should use their report definition batch output names.
|
||||
- Remove `three-day` and `weekend` from morning planning.
|
||||
- Stop using `report.Registry.BatchReports` from app batch execution. Either
|
||||
remove that method if it becomes unused, or leave it unused only if tests or
|
||||
docs still need it temporarily. Do not allow app batch execution to use it.
|
||||
|
||||
Tests:
|
||||
|
||||
- Morning order is Today, Tomorrow, then Daily dates.
|
||||
- Evening order is Tomorrow, then Daily dates.
|
||||
- Dynamic Daily dates start day after tomorrow.
|
||||
- 3-Day and Weekend are absent from morning.
|
||||
- Dynamic Daily resolves valid periods for the requested dates.
|
||||
- Planned dynamic Daily output copy names are date-qualified.
|
||||
- Unknown batch names still fail through existing CLI/report validation.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Batch membership is owned by `internal/app`.
|
||||
- Batch membership is based on one collected bundle.
|
||||
- `internal/collect` has no report or batch policy.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/report ./internal/cli
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 6: Use One Collection Result Per Batch
|
||||
|
||||
Goal: make both batch commands collect once and reuse that collection for every
|
||||
report.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Update `RunBatchDetailed` to:
|
||||
- resolve the effective time;
|
||||
- collect once using the request collector/default collector;
|
||||
- plan the batch from that collection;
|
||||
- pass the same `collect.Result` into every `GenerateReport` call.
|
||||
- If collection fails, return before generating any report.
|
||||
- Update batch report result construction to use `plannedBatchReport`.
|
||||
- Update `batchOutputPath` or replace it with planned output-copy path logic:
|
||||
- if `OutputDir` is empty, return empty output path;
|
||||
- if `planned.OutputCopyName` is non-empty, use that;
|
||||
- otherwise use `resolved.Definition.BatchOutputName`;
|
||||
- join with `OutputDir`.
|
||||
- Preserve per-report failure behavior after planning: later reports continue
|
||||
after a report failure.
|
||||
- Preserve notification failure behavior.
|
||||
|
||||
Tests:
|
||||
|
||||
- Counted or fake collector proves one collection call for morning.
|
||||
- Counted or fake collector proves one collection call for evening.
|
||||
- Collection failure prevents any renderer calls.
|
||||
- Report failure still allows later planned reports to run.
|
||||
- Notification failure still marks only that report failed and continues.
|
||||
- `--out-dir` output paths use:
|
||||
- `today.md` for Today;
|
||||
- `tomorrow.md` for Tomorrow;
|
||||
- `daily-YYYY-MM-DD.md` for dynamic Daily.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Both batch commands use one collection result per invocation.
|
||||
- No report in a batch performs its own upstream fetch.
|
||||
- Batch JSON and stderr behavior remain coherent.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/app ./internal/cli ./internal/state
|
||||
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected grep matches should be limited to `internal/collect`,
|
||||
`internal/adapters/weatherapi`, and their tests.
|
||||
|
||||
## Stage 7: Remove Or Retire Legacy Static Batch Resolution
|
||||
|
||||
Goal: prevent future code from accidentally using stale batch membership.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Inspect usages of `report.Registry.BatchReports`.
|
||||
- If no longer needed, delete `Registry.BatchReports` and related tests.
|
||||
- If keeping a reduced helper is necessary, document in code comments that app
|
||||
batch planning is authoritative and ensure no production path calls the old
|
||||
static membership helper.
|
||||
- Update report-registry tests that currently assert old morning/evening
|
||||
membership.
|
||||
|
||||
Tests:
|
||||
|
||||
- No production code path calls static registry batch membership.
|
||||
- Report registry tests continue to cover report definitions, generated flags,
|
||||
batch output names, and valid-period resolution as appropriate.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- There is no stale static morning/evening membership path in production code.
|
||||
- Future agents cannot accidentally reintroduce old Today/3-Day/Weekend
|
||||
morning membership by calling a legacy helper.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
go test ./internal/report ./internal/app
|
||||
rg -n "BatchReports\\(" internal
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected `BatchReports` grep result should be empty or limited to tests or
|
||||
comments that explicitly document it as non-production.
|
||||
|
||||
## Stage 8: Documentation Updates
|
||||
|
||||
Goal: update implemented documentation after the code behavior changes.
|
||||
|
||||
Implementation:
|
||||
|
||||
- Update `docs/cli.md`:
|
||||
- morning batch now generates Today, Tomorrow, and eligible future Daily
|
||||
reports;
|
||||
- evening batch now generates Tomorrow and eligible future Daily reports;
|
||||
- Daily eligibility is full hourly coverage.
|
||||
- Update `docs/operations.md` with the same operator-facing behavior and
|
||||
collection-failure behavior.
|
||||
- Update `docs/internal/app-orchestration.md`:
|
||||
- `internal/collect` boundary;
|
||||
- one collection per command;
|
||||
- app-owned batch planning;
|
||||
- explicit collected data passed to report generation.
|
||||
- Update `docs/internal/report-registry.md`:
|
||||
- report definitions remain canonical for report metadata;
|
||||
- batch membership is app-owned when data-dependent.
|
||||
- Add a new implemented internal component doc for `internal/collect`, for
|
||||
example `docs/internal/collect.md`.
|
||||
- Do not describe future source families as implemented behavior.
|
||||
|
||||
Tests/checks:
|
||||
|
||||
- Grep docs for stale old batch language: Today/3-Day/Weekend morning,
|
||||
Weekend except Sunday, Daily not part of scheduled batches.
|
||||
- Confirm docs do not imply `internal/collect` owns report or batch policy.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Non-roadmap docs describe only implemented behavior.
|
||||
- Operator docs and internal docs agree about batch membership.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
rg -n "3-Day|Weekend|scheduled batch|not part of scheduled|except on Sunday" docs README.md
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Stage 9: Final Validation
|
||||
|
||||
Goal: verify the completed migration and behavior end to end.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/weatherreporter --help
|
||||
rg -n "weatherapi\\.New|\\.FetchBundle\\(" internal
|
||||
rg -n "BatchReports\\(" internal
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Manual review:
|
||||
|
||||
- `weatherapi.New` and `.FetchBundle(` matches are limited to
|
||||
`internal/collect`, `internal/adapters/weatherapi`, and their tests.
|
||||
- `BatchReports(` matches are absent from production paths, or explicitly
|
||||
documented as non-production if retained.
|
||||
- `run morning` output summary can include multiple Daily reports without
|
||||
duplicate `--out-dir` copy paths.
|
||||
- `run evening` uses the same collection and dynamic Daily expansion logic as
|
||||
morning.
|
||||
- Managed workspace artifact paths are unchanged.
|
||||
- Distributor upload paths are unchanged.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -6,7 +6,7 @@ fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
|
||||
|
||||
## `weather_api.base_url is required`
|
||||
|
||||
Symptom: a generation command fails before fetching weather data.
|
||||
Symptom: a generation command fails before collecting weather data.
|
||||
|
||||
Likely cause: no Weather API base URL is configured.
|
||||
|
||||
@@ -235,13 +235,18 @@ Relevant docs: [Report Templates](templates.md),
|
||||
|
||||
Symptom: `run morning` or `run evening` returns nonzero.
|
||||
|
||||
Likely cause: at least one report in the batch failed.
|
||||
Likely cause: weather collection failed before planning, or at least one
|
||||
planned report failed after planning succeeded.
|
||||
|
||||
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
|
||||
lines.
|
||||
Diagnostic: if stdout contains a JSON summary, inspect each failed report item
|
||||
and stderr status line. If no summary was emitted, inspect the command error;
|
||||
configuration, Weather API collection, or batch validation failed before any
|
||||
report artifacts were created.
|
||||
|
||||
Safe fix: use the failed report's artifact paths from the summary, then inspect
|
||||
metadata, sources, module snapshot, and data package for that RunID.
|
||||
Safe fix: for collection failures, fix the configuration or upstream Weather
|
||||
API availability and rerun the batch. For report failures, use the failed
|
||||
report's artifact paths from the summary, then inspect metadata, sources,
|
||||
module snapshot, and data package for that RunID.
|
||||
|
||||
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
|
||||
@@ -53,6 +53,7 @@ type GenerateRequest struct {
|
||||
Date time.Time
|
||||
StormStart time.Time
|
||||
StormEnd time.Time
|
||||
Collector Collector
|
||||
Notifier Notifier
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ type BatchRequest struct {
|
||||
Batch BatchKind
|
||||
Now time.Time
|
||||
OutputDir string
|
||||
Collector Collector
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
@@ -85,6 +87,7 @@ type ReportRequest struct {
|
||||
Config config.Config
|
||||
Resolved report.Resolved
|
||||
OutputPath string
|
||||
Collection collect.Result
|
||||
Renderer Renderer
|
||||
Store state.Store
|
||||
Notifier Notifier
|
||||
@@ -161,6 +164,16 @@ type Renderer interface {
|
||||
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error)
|
||||
}
|
||||
|
||||
type Collector interface {
|
||||
Run(context.Context, collect.Request) (*collect.Result, error)
|
||||
}
|
||||
|
||||
type defaultCollector struct{}
|
||||
|
||||
func (defaultCollector) Run(ctx context.Context, req collect.Request) (*collect.Result, error) {
|
||||
return collect.Run(ctx, req)
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Notify(context.Context, NotificationRequest) (*NotificationResult, error)
|
||||
}
|
||||
@@ -215,6 +228,10 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, err := ResolveGenerate(req, now)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -224,6 +241,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: req.OutputPath,
|
||||
Collection: *collection,
|
||||
Notifier: req.Notifier,
|
||||
})
|
||||
return err
|
||||
@@ -247,7 +265,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
resolvedReports, err := ResolveBatch(req, now)
|
||||
if _, err := report.BatchForCommandName(string(req.Batch)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collection, err := collectWeather(ctx, req.Config, req.Collector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plannedReports, err := planBatchRun(req, now, *collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -262,24 +287,27 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
}
|
||||
startedAt := now
|
||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||
for _, resolved := range resolvedReports {
|
||||
for _, planned := range plannedReports {
|
||||
resolved := planned.Resolved
|
||||
if !resolved.Definition.Generated {
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
}
|
||||
for _, resolved := range resolvedReports {
|
||||
item := batchReportResult(resolved)
|
||||
for _, planned := range plannedReports {
|
||||
resolved := planned.Resolved
|
||||
item := batchReportResult(planned)
|
||||
if paths, err := store.Paths(resolved); err == nil {
|
||||
item.DataPackagePath = paths.DataPackage
|
||||
item.PreflightPath = paths.Preflight
|
||||
item.ReportPath = paths.RenderedReport
|
||||
item.MetadataPath = paths.Metadata
|
||||
}
|
||||
outputPath := batchOutputPath(req.OutputDir, resolved.Definition)
|
||||
outputPath := plannedBatchOutputPath(req.OutputDir, planned)
|
||||
reportResult, err := GenerateReport(ctx, ReportRequest{
|
||||
Config: req.Config,
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Collection: *collection,
|
||||
Renderer: req.Renderer,
|
||||
Store: store,
|
||||
Notifier: req.Notifier,
|
||||
@@ -321,7 +349,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
|
||||
func batchReportResult(resolved report.Resolved) BatchReportResult {
|
||||
func batchReportResult(planned plannedBatchReport) BatchReportResult {
|
||||
resolved := planned.Resolved
|
||||
metadata := resolved.Metadata()
|
||||
return BatchReportResult{
|
||||
ReportID: resolved.Definition.ID,
|
||||
@@ -333,11 +362,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
|
||||
}
|
||||
}
|
||||
|
||||
func batchOutputPath(outputDir string, definition report.Definition) string {
|
||||
if outputDir == "" || definition.BatchOutputName == "" {
|
||||
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
|
||||
if outputDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(outputDir, definition.BatchOutputName)
|
||||
outputCopyName := planned.OutputCopyName
|
||||
if outputCopyName == "" {
|
||||
outputCopyName = planned.Resolved.Definition.BatchOutputName
|
||||
}
|
||||
if outputCopyName == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(outputDir, outputCopyName)
|
||||
}
|
||||
|
||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||
@@ -362,25 +398,6 @@ func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return registry.BatchReports(batch, report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
})
|
||||
}
|
||||
|
||||
func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||
overrides, err := cfg.ReportModuleOverrides()
|
||||
if err != nil {
|
||||
@@ -394,15 +411,28 @@ func reportRegistry(cfg config.Config) (report.Registry, error) {
|
||||
}
|
||||
|
||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||
client, err := weatherapi.New(req.Config)
|
||||
result, err := collectWeather(ctx, req.Config, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle, err := client.FetchBundle(ctx)
|
||||
return result.Bundle, nil
|
||||
}
|
||||
|
||||
func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) {
|
||||
if collector == nil {
|
||||
collector = defaultCollector{}
|
||||
}
|
||||
result, err := collector.Run(ctx, collect.Request{Config: cfg})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bundle, nil
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("collect weather bundle: collector returned nil result")
|
||||
}
|
||||
if result.Bundle == nil {
|
||||
return nil, fmt.Errorf("collect weather bundle: collector returned nil bundle")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) {
|
||||
@@ -413,13 +443,18 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
|
||||
return nil, err
|
||||
if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
|
||||
return nil, fmt.Errorf("save bundle: %w", err)
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
|
||||
bundle := req.Collection.Bundle
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("collected weather bundle is required")
|
||||
}
|
||||
|
||||
store := req.Store
|
||||
if store == nil {
|
||||
defaultStore, err := defaultStore(req.Config)
|
||||
@@ -437,10 +472,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
|
||||
Config: req.Config,
|
||||
Resolved: req.Resolved,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
|
||||
@@ -81,6 +82,92 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUsesProvidedCollector(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
collector := &recordingCollector{err: errors.New("provided collector failed")}
|
||||
|
||||
err := Generate(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Generate() error = nil, want collector error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provided collector failed") {
|
||||
t.Fatalf("Generate() error = %q, want provided collector error", err.Error())
|
||||
}
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want 1", len(collector.requests))
|
||||
}
|
||||
if collector.requests[0].Config.WeatherAPI.BaseURL != "" {
|
||||
t.Fatalf("collector base URL = %q, want request config", collector.requests[0].Config.WeatherAPI.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectsOnceForSingleReport(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
collection := collectionForTest(t, cfg)
|
||||
collector := &recordingCollector{result: &collection}
|
||||
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
|
||||
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
|
||||
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nprintf render failed >&2\nexit 1\n", markerPath)
|
||||
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write scriptorium marker script: %v", err)
|
||||
}
|
||||
cfg.Scriptorium.Binary = binaryPath
|
||||
|
||||
err := Generate(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Generate() error = nil, want render error")
|
||||
}
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want 1", len(collector.requests))
|
||||
}
|
||||
if _, statErr := os.Stat(markerPath); statErr != nil {
|
||||
t.Fatalf("scriptorium marker stat error = %v, want report execution reached renderer", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
markerPath := filepath.Join(t.TempDir(), "scriptorium-called")
|
||||
binaryPath := filepath.Join(t.TempDir(), "scriptorium")
|
||||
script := fmt.Sprintf("#!/bin/sh\nprintf called > %q\nexit 0\n", markerPath)
|
||||
if err := os.WriteFile(binaryPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write scriptorium marker script: %v", err)
|
||||
}
|
||||
cfg.Scriptorium.Binary = binaryPath
|
||||
collector := &recordingCollector{err: errors.New("collection unavailable")}
|
||||
|
||||
err := Generate(context.Background(), GenerateRequest{
|
||||
Config: cfg,
|
||||
Report: ReportDaily,
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Generate() error = nil, want collector error")
|
||||
}
|
||||
if _, statErr := os.Stat(markerPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("scriptorium marker stat error = %v, want marker absent after collection failure", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
@@ -112,6 +199,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -341,9 +429,10 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
|
||||
renderer := successfulGeneratedTextRenderer("")
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -486,10 +575,11 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -614,6 +704,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -700,6 +791,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -776,6 +868,7 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -827,9 +920,10 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# 3-Day Outlook\n"),
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# 3-Day Outlook\n"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -856,10 +950,11 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -894,10 +989,11 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
|
||||
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -937,6 +1033,7 @@ func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -983,6 +1080,7 @@ func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -1017,6 +1115,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -1045,6 +1144,7 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *te
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -1079,10 +1179,11 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1106,6 +1207,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
@@ -1178,11 +1280,12 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
|
||||
|
||||
store := recordingFilesystemStore(t, cfg)
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want notification error")
|
||||
@@ -1243,10 +1346,11 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
|
||||
notifier := &recordingNotifier{}
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: tt.renderer,
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: tt.renderer,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want generation error")
|
||||
@@ -1258,7 +1362,7 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
|
||||
func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
@@ -1270,18 +1374,26 @@ func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, "2026-05-29T05:00:00-05:00")
|
||||
notifier := &recordingNotifier{}
|
||||
store := recordingFilesystemStore(t, cfg)
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want fetch setup error")
|
||||
t.Fatal("GenerateReport() error = nil, want collected bundle error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "collected weather bundle is required") {
|
||||
t.Fatalf("GenerateReport() error = %q, want collected bundle context", err.Error())
|
||||
}
|
||||
if len(notifier.requests) != 0 {
|
||||
t.Fatalf("notification requests = %#v, want none after fetch failure", notifier.requests)
|
||||
t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests)
|
||||
}
|
||||
if len(store.calls) != 0 {
|
||||
t.Fatalf("state calls = %#v, want no state writes without collected data", store.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,9 +1414,10 @@ func TestGenerateReportPersistsFailedPreflight(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want render error")
|
||||
@@ -1347,9 +1460,10 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("GenerateReport() error = nil, want run error")
|
||||
@@ -1394,10 +1508,11 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1431,10 +1546,11 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
|
||||
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1492,9 +1608,10 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1564,10 +1681,11 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
|
||||
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1594,10 +1712,11 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
|
||||
Date: mustParse("2026-05-29T12:00:00-05:00"),
|
||||
}, "2026-05-29T05:00:00-05:00")
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1628,10 +1747,11 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1673,10 +1793,11 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: currentResolved,
|
||||
Renderer: renderer,
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1717,6 +1838,7 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
|
||||
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
OutputPath: outputPath,
|
||||
Renderer: renderer,
|
||||
@@ -1756,9 +1878,10 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -1821,10 +1944,10 @@ func TestInspectPriorSnapshot(t *testing.T) {
|
||||
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||
runBody: "# Daily Report\n",
|
||||
}
|
||||
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
|
||||
if _, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil {
|
||||
t.Fatalf("GenerateReport(prior) error = %v", err)
|
||||
}
|
||||
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Resolved: currentResolved, Renderer: renderer, Store: store})
|
||||
current, err := GenerateReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport(current) error = %v", err)
|
||||
}
|
||||
@@ -2091,38 +2214,23 @@ func TestResolveGenerateStorm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
now := mustParse("2026-05-31T06:00:00-05:00")
|
||||
|
||||
resolved, err := ResolveBatch(BatchRequest{Config: cfg, Batch: BatchMorning}, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBatch() error = %v", err)
|
||||
}
|
||||
if len(resolved) != 2 {
|
||||
t.Fatalf("resolved length = %d, want 2", len(resolved))
|
||||
}
|
||||
for _, item := range resolved {
|
||||
if item.Definition.ID == report.Weekend {
|
||||
t.Fatal("morning batch included weekend on Sunday")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
collector := &recordingCollector{result: &collection}
|
||||
renderer := &selectiveRenderer{
|
||||
failRenderPrompt: "weather.three_day_outlook",
|
||||
failRenderPrompt: "weather.tomorrow_generated_text",
|
||||
runBody: "# Batch Report\n",
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Renderer: renderer,
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
@@ -2131,38 +2239,52 @@ func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
|
||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if renderer.runCalls != 1 || renderer.structuredRunCalls != 1 {
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests))
|
||||
}
|
||||
if renderer.runCalls != 0 || renderer.structuredRunCalls != 2 {
|
||||
t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls)
|
||||
}
|
||||
var failedThreeDay bool
|
||||
var failedTomorrow bool
|
||||
var succeededDaily bool
|
||||
for _, item := range result.Reports {
|
||||
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
|
||||
failedThreeDay = true
|
||||
if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
|
||||
failedTomorrow = true
|
||||
}
|
||||
if item.ReportID != report.ThreeDay && item.Status != "succeeded" {
|
||||
if item.ReportID == report.Daily && item.Status == "succeeded" {
|
||||
succeededDaily = true
|
||||
}
|
||||
if item.ReportID != report.Tomorrow && item.Status != "succeeded" {
|
||||
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
|
||||
}
|
||||
}
|
||||
if !failedThreeDay {
|
||||
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports)
|
||||
if !failedTomorrow {
|
||||
t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports)
|
||||
}
|
||||
if !succeededDaily {
|
||||
t.Fatalf("reports = %#v, want Daily report to continue after Tomorrow failure", result.Reports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyNotificationConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
collector := &recordingCollector{result: &collection}
|
||||
notifier := &recordingNotifier{
|
||||
errByReport: map[report.ID]error{
|
||||
report.ThreeDay: errors.New("distributor unavailable"),
|
||||
report.Tomorrow: errors.New("distributor unavailable"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
Notifier: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
@@ -2171,23 +2293,26 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
||||
if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed)
|
||||
}
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests))
|
||||
}
|
||||
if len(notifier.requests) != 3 {
|
||||
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
|
||||
}
|
||||
var failedThreeDay bool
|
||||
var failedTomorrow bool
|
||||
for _, item := range result.Reports {
|
||||
if item.ReportID == report.ThreeDay {
|
||||
if item.ReportID == report.Tomorrow {
|
||||
if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
|
||||
failedThreeDay = true
|
||||
failedTomorrow = true
|
||||
}
|
||||
if item.NotificationStatus != "failed" {
|
||||
t.Fatalf("3-day notification status = %q, want failed", item.NotificationStatus)
|
||||
t.Fatalf("Tomorrow notification status = %q, want failed", item.NotificationStatus)
|
||||
}
|
||||
if item.NotificationPipelineID != "weatherreporter.three-day" {
|
||||
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID)
|
||||
if item.NotificationPipelineID != "weatherreporter.tomorrow" {
|
||||
t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID)
|
||||
}
|
||||
if !strings.Contains(item.NotificationError, "distributor unavailable") {
|
||||
t.Fatalf("3-day notification error = %q, want distributor unavailable", item.NotificationError)
|
||||
t.Fatalf("Tomorrow notification error = %q, want distributor unavailable", item.NotificationError)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -2201,21 +2326,23 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
||||
t.Fatalf("report %s notification pipeline is empty", item.ReportID)
|
||||
}
|
||||
}
|
||||
if !failedThreeDay {
|
||||
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
|
||||
if !failedTomorrow {
|
||||
t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports)
|
||||
}
|
||||
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
collector = &recordingCollector{result: &collection}
|
||||
err = RunBatch(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: &selectiveRenderer{
|
||||
runBody: "# Batch Report\n",
|
||||
},
|
||||
Notifier: &recordingNotifier{
|
||||
errByReport: map[report.ID]error{
|
||||
report.ThreeDay: errors.New("distributor unavailable"),
|
||||
report.Tomorrow: errors.New("distributor unavailable"),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2231,6 +2358,9 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
|
||||
func TestRunBatchUsesOutputDirectory(t *testing.T) {
|
||||
server := dailyBundleServer(t)
|
||||
cfg := dailyWorkspaceConfig(t, server)
|
||||
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
collector := &recordingCollector{result: &collection}
|
||||
outputDir := filepath.Join(t.TempDir(), "reports")
|
||||
|
||||
result, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
@@ -2238,31 +2368,44 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
|
||||
Batch: BatchEvening,
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
OutputDir: outputDir,
|
||||
Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"},
|
||||
Collector: collector,
|
||||
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 0 || len(result.Reports) != 1 {
|
||||
t.Fatalf("summary = %#v, want one successful report", result)
|
||||
if result.Failed != 0 || len(result.Reports) != 2 {
|
||||
t.Fatalf("summary = %#v, want two successful reports", result)
|
||||
}
|
||||
want := filepath.Join(outputDir, "tomorrow.md")
|
||||
if result.Reports[0].OutputPath != want {
|
||||
t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want)
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests))
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected output copy %q: %v", want, err)
|
||||
wantByReport := map[report.ID]string{
|
||||
report.Tomorrow: filepath.Join(outputDir, "tomorrow.md"),
|
||||
report.Daily: filepath.Join(outputDir, "daily-2026-05-31.md"),
|
||||
}
|
||||
reportData, err := os.ReadFile(result.Reports[0].ReportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed report: %v", err)
|
||||
}
|
||||
copyData, err := os.ReadFile(want)
|
||||
if err != nil {
|
||||
t.Fatalf("read output copy: %v", err)
|
||||
}
|
||||
if string(copyData) != string(reportData) {
|
||||
t.Fatalf("batch output copy differs from managed report")
|
||||
for _, item := range result.Reports {
|
||||
want := wantByReport[item.ReportID]
|
||||
if want == "" {
|
||||
t.Fatalf("unexpected report item = %#v", item)
|
||||
}
|
||||
if item.OutputPath != want {
|
||||
t.Fatalf("%s OutputPath = %q, want %q", item.ReportID, item.OutputPath, want)
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("expected output copy %q: %v", want, err)
|
||||
}
|
||||
reportData, err := os.ReadFile(item.ReportPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read managed report: %v", err)
|
||||
}
|
||||
copyData, err := os.ReadFile(want)
|
||||
if err != nil {
|
||||
t.Fatalf("read output copy: %v", err)
|
||||
}
|
||||
if string(copyData) != string(reportData) {
|
||||
t.Fatalf("batch output copy differs from managed report for %s", item.ReportID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2281,7 +2424,7 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RunBatchDetailed() error = %v", err)
|
||||
}
|
||||
if result.Failed != 0 || len(result.Reports) != 3 {
|
||||
if result.Failed != 0 || len(result.Reports) != 2 {
|
||||
t.Fatalf("summary = %#v, want successful morning batch", result)
|
||||
}
|
||||
var todayItem *BatchReportResult
|
||||
@@ -2306,15 +2449,28 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
|
||||
definition, err := report.DefaultRegistry().Lookup(report.Hourly)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(hourly) error = %v", err)
|
||||
func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.Workspace.Root = t.TempDir()
|
||||
collector := &recordingCollector{err: errors.New("batch collector failed")}
|
||||
renderer := &recordingRenderer{}
|
||||
|
||||
_, err := RunBatchDetailed(context.Background(), BatchRequest{
|
||||
Config: cfg,
|
||||
Batch: BatchMorning,
|
||||
Now: mustParse("2026-05-29T05:00:00-05:00"),
|
||||
Collector: collector,
|
||||
Renderer: renderer,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "batch collector failed") {
|
||||
t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err)
|
||||
}
|
||||
outputDir := filepath.Join(t.TempDir(), "reports")
|
||||
want := filepath.Join(outputDir, "hourly.md")
|
||||
if got := batchOutputPath(outputDir, definition); got != want {
|
||||
t.Fatalf("batchOutputPath() = %q, want %q", got, want)
|
||||
if len(collector.requests) != 1 {
|
||||
t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests))
|
||||
}
|
||||
if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 {
|
||||
t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2349,6 +2505,23 @@ func dailyNotificationConfig(t *testing.T, server *httptest.Server) config.Confi
|
||||
return cfg
|
||||
}
|
||||
|
||||
func collectionForTest(t *testing.T, cfg config.Config) collect.Result {
|
||||
t.Helper()
|
||||
bundle, err := FetchBundle(context.Background(), FetchBundleRequest{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBundle() error = %v", err)
|
||||
}
|
||||
return collect.Result{Bundle: bundle}
|
||||
}
|
||||
|
||||
func collectionWithFutureDailyForTest(t *testing.T, cfg config.Config, date string) collect.Result {
|
||||
t.Helper()
|
||||
collection := collectionForTest(t, cfg)
|
||||
location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)
|
||||
collection.Bundle.Hourly.Periods = append(collection.Bundle.Hourly.Periods, fullDayPeriods(t, date, location)...)
|
||||
return collection
|
||||
}
|
||||
|
||||
func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved {
|
||||
t.Helper()
|
||||
req.Config = cfg
|
||||
@@ -2479,9 +2652,10 @@ func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
|
||||
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||
}
|
||||
result, err := GenerateReport(context.Background(), ReportRequest{
|
||||
Config: cfg,
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
Config: cfg,
|
||||
Collection: collectionForTest(t, cfg),
|
||||
Resolved: resolved,
|
||||
Renderer: successfulRenderer("# Daily Report\n"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateReport() error = %v", err)
|
||||
@@ -2790,6 +2964,20 @@ type recordingRenderer struct {
|
||||
structuredRunBody string
|
||||
}
|
||||
|
||||
type recordingCollector struct {
|
||||
result *collect.Result
|
||||
err error
|
||||
requests []collect.Request
|
||||
}
|
||||
|
||||
func (c *recordingCollector) Run(_ context.Context, req collect.Request) (*collect.Result, error) {
|
||||
c.requests = append(c.requests, req)
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.result, nil
|
||||
}
|
||||
|
||||
type recordingStore struct {
|
||||
state.Store
|
||||
calls []string
|
||||
|
||||
139
internal/app/batch_plan.go
Normal file
139
internal/app/batch_plan.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type plannedBatchReport struct {
|
||||
Resolved report.Resolved
|
||||
OutputCopyName string
|
||||
}
|
||||
|
||||
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
|
||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch, err := report.BatchForCommandName(string(req.Batch))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registry, err := reportRegistry(req.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolveReq := report.ResolveRequest{
|
||||
Now: now,
|
||||
Location: location,
|
||||
}
|
||||
var planned []plannedBatchReport
|
||||
switch batch {
|
||||
case report.Morning:
|
||||
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case report.Evening:
|
||||
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||
}
|
||||
|
||||
var hourly *weatherdata.ForecastRun
|
||||
if collection.Bundle != nil {
|
||||
hourly = collection.Bundle.Hourly
|
||||
}
|
||||
for _, date := range eligibleDailyDates(hourly, now, location) {
|
||||
dailyReq := resolveReq
|
||||
dailyReq.Date = date
|
||||
outputCopyName := "daily-" + date.In(location).Format(timeutil.DateLayout) + ".md"
|
||||
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq, outputCopyName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return planned, nil
|
||||
}
|
||||
|
||||
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest, outputCopyName string) ([]plannedBatchReport, error) {
|
||||
resolved, err := registry.Resolve(id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(planned, plannedBatchReport{
|
||||
Resolved: resolved,
|
||||
OutputCopyName: outputCopyName,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
|
||||
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
|
||||
var maxLocalDate time.Time
|
||||
for _, period := range hourly.Periods {
|
||||
if !isHourlyPeriod(period) {
|
||||
continue
|
||||
}
|
||||
start := period.StartTime
|
||||
hourlyStarts[instantKey(start)] = struct{}{}
|
||||
localDate := localDateStart(start, location)
|
||||
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
|
||||
maxLocalDate = localDate
|
||||
}
|
||||
}
|
||||
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
|
||||
var dates []time.Time
|
||||
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
|
||||
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
|
||||
dates = append(dates, candidate)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
|
||||
if period.StartTime.IsZero() || period.EndTime.IsZero() {
|
||||
return false
|
||||
}
|
||||
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
|
||||
}
|
||||
|
||||
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
|
||||
day := timeutil.CivilDay(date, location)
|
||||
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
|
||||
if _, ok := hourlyStarts[instantKey(required)]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func instantKey(value time.Time) time.Time {
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func localDateStart(value time.Time, location *time.Location) time.Time {
|
||||
local := value.In(location)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
|
||||
}
|
||||
352
internal/app/batch_plan_test.go
Normal file
352
internal/app/batch_plan_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestPlanBatchRunMorningOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
|
||||
}
|
||||
|
||||
func TestPlanBatchRunEveningOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
|
||||
}
|
||||
|
||||
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-30", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
daily := plannedDailyReports(planned)
|
||||
if len(daily) != 2 {
|
||||
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
|
||||
}
|
||||
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
|
||||
}
|
||||
|
||||
func TestPlanBatchRunMorningExcludesLegacyStaticReports(t *testing.T) {
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
for _, item := range planned {
|
||||
if item.Resolved.Definition.ID == report.ThreeDay || item.Resolved.Definition.ID == report.Weekend {
|
||||
t.Fatalf("morning plan includes %s, want no 3-Day or Weekend", item.Resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanBatchRunDynamicDailyOutputCopyNames(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
|
||||
if err != nil {
|
||||
t.Fatalf("planBatchRun() error = %v", err)
|
||||
}
|
||||
|
||||
daily := plannedDailyReports(planned)
|
||||
if len(daily) != 1 {
|
||||
t.Fatalf("daily reports = %#v, want one Daily report", daily)
|
||||
}
|
||||
if daily[0].OutputCopyName != "daily-2026-05-31.md" {
|
||||
t.Fatalf("OutputCopyName = %q, want date-qualified Daily name", daily[0].OutputCopyName)
|
||||
}
|
||||
if planned[0].OutputCopyName != "" {
|
||||
t.Fatalf("Tomorrow OutputCopyName = %q, want definition batch output name to apply later", planned[0].OutputCopyName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
|
||||
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
|
||||
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods[:12], periods[13:]...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location)
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-29", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
periods := fullDayPeriods(t, "2026-05-31", location)
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
|
||||
periods := []weatherdata.ForecastPeriod{
|
||||
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
|
||||
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
|
||||
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
|
||||
}
|
||||
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
|
||||
hourly := hourlyRun(periods...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location, "2026-06-01")
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/New_York")
|
||||
tests := []struct {
|
||||
name string
|
||||
now string
|
||||
date string
|
||||
}{
|
||||
{
|
||||
name: "spring forward",
|
||||
now: "2026-03-06T08:00:00-05:00",
|
||||
date: "2026-03-08",
|
||||
},
|
||||
{
|
||||
name: "fall back",
|
||||
now: "2026-10-30T08:00:00-04:00",
|
||||
date: "2026-11-01",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
|
||||
|
||||
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
|
||||
assertLocalDates(t, got, location, tt.date)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
|
||||
location := mustLoadTestLocation(t, "America/Chicago")
|
||||
fullDay := fullDayPeriods(t, "2026-05-31", location)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hourly *weatherdata.ForecastRun
|
||||
}{
|
||||
{name: "nil run"},
|
||||
{name: "empty periods", hourly: hourlyRun()},
|
||||
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
|
||||
assertLocalDates(t, got, location)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||
return forecastRun("hourly", periods...)
|
||||
}
|
||||
|
||||
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
|
||||
return &weatherdata.ForecastRun{
|
||||
Product: product,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
|
||||
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
|
||||
}
|
||||
|
||||
func planningConfig() config.Config {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
|
||||
t.Helper()
|
||||
gotIDs := make([]string, 0, len(got))
|
||||
for _, item := range got {
|
||||
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
|
||||
}
|
||||
wantIDs := make([]string, 0, len(want))
|
||||
for _, id := range want {
|
||||
wantIDs = append(wantIDs, string(id))
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
|
||||
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
|
||||
var daily []plannedBatchReport
|
||||
for _, item := range planned {
|
||||
if item.Resolved.Definition.ID == report.Daily {
|
||||
daily = append(daily, item)
|
||||
}
|
||||
}
|
||||
return daily
|
||||
}
|
||||
|
||||
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
|
||||
var periods []weatherdata.ForecastPeriod
|
||||
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
|
||||
periods = append(periods, weatherdata.ForecastPeriod{
|
||||
StartTime: start,
|
||||
EndTime: start.Add(time.Hour),
|
||||
})
|
||||
}
|
||||
return periods
|
||||
}
|
||||
|
||||
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
periods := fullDayPeriods(t, date, location)
|
||||
if count > len(periods) {
|
||||
count = len(periods)
|
||||
}
|
||||
return periods[:count]
|
||||
}
|
||||
|
||||
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
|
||||
t.Helper()
|
||||
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
|
||||
for _, period := range periods {
|
||||
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixed-offset start: %v", err)
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
t.Fatalf("parse fixed-offset end: %v", err)
|
||||
}
|
||||
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
|
||||
t.Helper()
|
||||
gotDates := make([]string, 0, len(got))
|
||||
for _, date := range got {
|
||||
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
|
||||
}
|
||||
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
|
||||
}
|
||||
for _, date := range got {
|
||||
day := timeutil.CivilDay(date, location)
|
||||
if !date.Equal(day.Start) {
|
||||
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||
t.Helper()
|
||||
if !period.IsValid() {
|
||||
t.Fatalf("period = %#v, want valid", period)
|
||||
}
|
||||
if got := period.Start.Format(time.RFC3339); got != wantStart {
|
||||
t.Fatalf("Start = %s, want %s", got, wantStart)
|
||||
}
|
||||
if got := period.End.Format(time.RFC3339); got != wantEnd {
|
||||
t.Fatalf("End = %s, want %s", got, wantEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation(%q) error = %v", name, err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := timeutil.ParseLocalDate(value, location)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
|
||||
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
|
||||
}
|
||||
|
||||
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
||||
func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
|
||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
@@ -176,7 +176,9 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.yaml")
|
||||
}
|
||||
|
||||
@@ -191,7 +193,7 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want aggregate failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
|
||||
if !strings.Contains(err.Error(), "1 of 2 reports failed") {
|
||||
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
|
||||
}
|
||||
|
||||
@@ -199,14 +201,14 @@ func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
|
||||
if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
|
||||
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
|
||||
}
|
||||
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
|
||||
if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
|
||||
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed)
|
||||
}
|
||||
if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
|
||||
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr)
|
||||
}
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "*.data_package.yaml")
|
||||
}
|
||||
|
||||
func TestBatchOutputIncludesNotificationDetails(t *testing.T) {
|
||||
@@ -413,7 +415,7 @@ func TestRunEveningReportsNotificationFailureWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
|
||||
func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) {
|
||||
fixture := newCLIFixture(t, writeFakeScriptorium)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -427,7 +429,9 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
|
||||
_ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "weekend", "2026-05-31", "*.data_package.yaml")
|
||||
noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
|
||||
}
|
||||
|
||||
@@ -1377,7 +1381,7 @@ if [ "$1" = "render" ]; then
|
||||
fi
|
||||
shift
|
||||
done
|
||||
if [ "$prompt" = "weather.three_day_outlook" ]; then
|
||||
if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
|
||||
printf 'render failed\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
31
internal/collect/collect.go
Normal file
31
internal/collect/collect.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Package collect owns upstream weather source collection for application use.
|
||||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Config config.Config
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Bundle *weatherdata.Bundle
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, req Request) (*Result, error) {
|
||||
client, err := weatherapi.New(req.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare weather collection: %w", err)
|
||||
}
|
||||
bundle, err := client.FetchBundle(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect weather bundle: %w", err)
|
||||
}
|
||||
return &Result{Bundle: bundle}, nil
|
||||
}
|
||||
99
internal/collect/collect_test.go
Normal file
99
internal/collect/collect_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
)
|
||||
|
||||
func TestRunFetchesBundle(t *testing.T) {
|
||||
server := collectionTestServer(t, nil)
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
|
||||
result, err := Run(context.Background(), Request{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if result == nil || result.Bundle == nil {
|
||||
t.Fatal("Run() result bundle = nil, want fetched bundle")
|
||||
}
|
||||
if result.Bundle.Hourly == nil {
|
||||
t.Fatal("Hourly = nil, want fetched hourly forecast")
|
||||
}
|
||||
if result.Bundle.WeatherStory == nil || result.Bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
|
||||
t.Fatalf("WeatherStory = %#v, want fetched weather story", result.Bundle.WeatherStory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWrapsAdapterConstructionError(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = ""
|
||||
cfg.Secrets.Directory = "super-secret-directory"
|
||||
|
||||
_, err := Run(context.Background(), Request{Config: cfg})
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want adapter construction error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "prepare weather collection") {
|
||||
t.Fatalf("error = %q, want collection setup context", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), cfg.Secrets.Directory) {
|
||||
t.Fatalf("error = %q, want no secret path leakage", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWrapsFetchError(t *testing.T) {
|
||||
server := collectionTestServer(t, map[string]int{"/observations": http.StatusBadGateway})
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||
|
||||
_, err := Run(context.Background(), Request{Config: cfg})
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want fetch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "collect weather bundle") {
|
||||
t.Fatalf("error = %q, want collection fetch context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/observations") {
|
||||
t.Fatalf("error = %q, want source endpoint context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func collectionTestServer(t *testing.T, statusByPath map[string]int) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status := statusByPath[r.URL.Path]; status != 0 {
|
||||
http.Error(w, "upstream failure", status)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/observations":
|
||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||
case "/conditions/current":
|
||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||
case "/forecast/hourly":
|
||||
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`))
|
||||
case "/forecast/narrative":
|
||||
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`))
|
||||
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":[],"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"}}`))
|
||||
case "/outlooks/convective":
|
||||
_, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
import "time"
|
||||
|
||||
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||
return DefaultRegistry().Resolve(id, req)
|
||||
@@ -17,39 +14,6 @@ func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||
return r.resolveDefinition(definition, req)
|
||||
}
|
||||
|
||||
func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, error) {
|
||||
if req.Location == nil {
|
||||
req.Location = time.UTC
|
||||
}
|
||||
if req.Now.IsZero() {
|
||||
req.Now = time.Now()
|
||||
}
|
||||
switch batch {
|
||||
case Morning:
|
||||
ids := []ID{Today, ThreeDay}
|
||||
if req.Now.In(req.Location).Weekday() != time.Sunday {
|
||||
ids = append(ids, Weekend)
|
||||
}
|
||||
return r.resolveIDs(ids, req)
|
||||
case Evening:
|
||||
return r.resolveIDs([]ID{Tomorrow}, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
|
||||
resolved := make([]Resolved, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
item, err := r.Resolve(id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved = append(resolved, item)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
|
||||
if req.Location == nil {
|
||||
req.Location = time.UTC
|
||||
|
||||
@@ -241,55 +241,6 @@ func TestStormResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
|
||||
Now: mustParse("2026-05-31T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "today,three_day" {
|
||||
t.Fatalf("ids = %v, want today and three_day", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveningBatchIncludesTomorrow(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := DefaultRegistry().BatchReports(Evening, ResolveRequest{
|
||||
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "tomorrow" {
|
||||
t.Fatalf("ids = %v, want tomorrow", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchesDoNotIncludeManualReports(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
req := ResolveRequest{Now: mustParse("2026-05-29T06:00:00-05:00"), Location: location}
|
||||
|
||||
morning, err := DefaultRegistry().BatchReports(Morning, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(morning) error = %v", err)
|
||||
}
|
||||
evening, err := DefaultRegistry().BatchReports(Evening, req)
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports(evening) error = %v", err)
|
||||
}
|
||||
for _, resolved := range append(morning, evening...) {
|
||||
if resolved.Definition.ID == Hourly || resolved.Definition.ID == Daily {
|
||||
t.Fatalf("batch included %q, want manual reports excluded", resolved.Definition.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDForCommandName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -409,21 +360,6 @@ func TestBatchForCommandName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMorningBatchReportOrder(t *testing.T) {
|
||||
location := mustLoadLocation(t)
|
||||
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
|
||||
Now: mustParse("2026-05-29T06:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchReports() error = %v", err)
|
||||
}
|
||||
ids := resolvedIDs(resolved)
|
||||
if strings.Join(ids, ",") != "today,three_day,weekend" {
|
||||
t.Fatalf("ids = %v, want morning report order", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
||||
if err == nil {
|
||||
@@ -813,14 +749,6 @@ func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEn
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedIDs(resolved []Resolved) []string {
|
||||
ids := make([]string, 0, len(resolved))
|
||||
for _, item := range resolved {
|
||||
ids = append(ids, string(item.Definition.ID))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func resolvedDefinitionIDs(definitions []Definition) []string {
|
||||
ids := make([]string, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
|
||||
Reference in New Issue
Block a user