10 Commits

24 changed files with 1250 additions and 1108 deletions

View File

@@ -9,7 +9,7 @@ batches, and inspects stored artifacts.
weatherreporter generate today --out ./today.md 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 artifacts, runs `scriptorium render` as a preflight check, runs structured
`scriptorium run`, validates generated text, renders the embedded Today `scriptorium run`, validates generated text, renders the embedded Today
template, and writes an extra Markdown copy to `./today.md`. If distributor 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 generated-text artifacts, validate structured text from Scriptorium, and render
the managed Markdown report from embedded templates. `generate daily` requires the managed Markdown report from embedded templates. `generate daily` requires
`--date YYYY-MM-DD` for the selected local civil day; omitting `--date` is a `--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 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 date or event window flags. `generate storm` requires explicit event-window
bounds with `--start` and `--end`. bounds with `--start` and `--end`.
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Report. Batch for each later future local civil day with complete hourly forecast coverage.
runs continue independent reports after a failure, print a JSON summary to `run evening` generates Tomorrow Report and the same eligible future Daily
stdout, write compact status lines to stderr, and return nonzero when any report reports. Future Daily expansion starts with the day after tomorrow and skips
failed. `--out-dir` writes extra Markdown copies for the operator; distributor days that do not have every hourly forecast period for the local civil day.
notification uses each managed report path, not the extra copies. When Batch commands collect weather data once before planning; a collection failure
notification is enabled, batch summaries and status lines include notification stops the batch before any report is generated. Batch runs continue independent
status, accepted distributor run ID, or notification error fields for each reports after a later report failure, print a JSON summary to stdout, write
attempted report. 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 Hourly Report, 3-Day Outlook, and Weekend Outlook are explicit only; they are
evening`. not included in `run morning` or `run evening`.
`inspect` commands read existing workspace artifacts and emit JSON to stdout. `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 ## Flags

View File

@@ -29,8 +29,8 @@ weather_api:
base_url: https://weather.api.example.com/ base_url: https://weather.api.example.com/
``` ```
`weather_api.base_url` is required for commands that fetch weather data. Other `weather_api.base_url` is required for commands that collect weather data.
fields fall back to defaults. Other fields fall back to defaults.
## Production-Oriented Config ## Production-Oriented Config
@@ -41,7 +41,7 @@ config test suite.
### `weather_api` ### `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`. - `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`. - `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`. - `units`: Weather API units query value. Default: `us`.

View File

@@ -5,11 +5,12 @@ This document describes the workflow coordinator in `internal/app`.
## Purpose ## Purpose
`internal/app` coordinates the top-level use cases after CLI parsing and config `internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data, loading are complete. It resolves report definitions, collects weather data
builds collected and derived facts, builds module snapshots and prompt-input through `internal/collect`, builds collected and derived facts, builds module
artifacts, invokes Scriptorium through the adapter boundary, optionally snapshots and prompt-input artifacts, invokes Scriptorium through the adapter
notifies distributor through an app-owned notifier boundary, persists managed boundary, optionally notifies distributor through an app-owned notifier
state, runs batches, and reads existing artifacts for inspection. boundary, persists managed state, runs batches, and reads existing artifacts
for inspection.
## Inputs And Outputs ## Inputs And Outputs
@@ -17,12 +18,12 @@ Inputs:
- `GenerateRequest` for one report command - `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands - `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 - `ReportRequest` for single-report generation
- resolved report definitions from `internal/report` - resolved report definitions from `internal/report`
- weather data bundles from `internal/adapters/weatherapi` - collection results from `internal/collect`
- prior snapshots loaded from `internal/state` - 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: Outputs:
@@ -32,7 +33,7 @@ Outputs:
notification result when attempted notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and - batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted 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 - inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance packages, prior snapshots, and source provenance
@@ -45,15 +46,18 @@ construct Scriptorium argv.
Report selection and report identity policy come from `internal/report`. Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`. Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium Weather API transport stays in `internal/adapters/weatherapi`, and app-facing
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor upstream collection stays in `internal/collect`. Scriptorium subprocess
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and behavior stays in `internal/adapters/scriptorium`. Distributor upload behavior
persisted metadata stay in `internal/state`. stays in `internal/adapters/distributor`. Filesystem layout and persisted
metadata stay in `internal/state`.
## Data Flow Terms ## Data Flow Terms
- `CollectedFacts` are normalized source facts fetched once from Weather API - `collect.Result` is the app-facing upstream collection result. It carries the
and made available to derivation and module builders. 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 - `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows. resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected - `module.Output` values are ordered deterministic stanzas built from collected
@@ -77,33 +81,35 @@ defaults.
## Generation Workflow ## 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. `GenerateReport` then uses this setup:
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`. 1. Create or use a filesystem store.
4. Fetch a Weather API bundle. 2. Locate any prior compatible snapshot through `internal/state`.
5. Build collected and derived facts once. 3. Build collected and derived facts from the supplied collection.
6. Execute configured modules and save the module snapshot. 4. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current module snapshots. 5. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the YAML Scriptorium `data_package`. 6. Build and save the YAML Scriptorium `data_package`.
9. Run Scriptorium render preflight. 7. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available. 8. Save preflight JSON when a render result is available.
11. Save metadata for inspection. 9. Save metadata for inspection.
For `scriptorium_markdown` reports, generation then: 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: 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. IDs.
13. Runs structured Scriptorium generation to the raw generated-text JSON path. 11. Runs structured Scriptorium generation to the raw generated-text JSON path.
14. Saves the structured Scriptorium run result. 12. Saves the structured Scriptorium run result.
15. Validates and saves normalized generated text. 13. Validates and saves normalized generated text.
16. Builds and saves a typed render context. 14. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path. 15. Renders Markdown from the embedded template to the managed report path.
After either mode has produced a managed Markdown report, shared finalization: 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 If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and 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 module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures. failures.
Generated-text report failures are returned with report ID, RunID, and the Generated-text report failures are returned with report ID, RunID, and the
@@ -134,17 +140,21 @@ failure context.
## Batch Workflow ## Batch Workflow
`run morning` resolves Today Report, 3-Day Outlook, and Weekend Outlook except `run morning` collects once, plans Today Report, Tomorrow Report, and eligible
on Sunday. `run evening` resolves Tomorrow Report. Daily Report is generated future Daily Reports from the collected hourly forecast, then passes the same
only through `generate daily --date YYYY-MM-DD`; it is not part of scheduled collection into each report generation. `run evening` uses the same collection
batches. Batch output copy names come from report definitions. Batch generation and planning rules, but starts with Tomorrow Report. Future Daily reports start
continues independent reports after a failure, records each result, writes with the day after tomorrow and require complete hourly forecast coverage for
compact status lines to stderr, emits a JSON summary to stdout, and returns an the target local civil day. Dynamic Daily `--out-dir` copies use
aggregate error when any report failed. When notification is enabled, each `daily-YYYY-MM-DD.md`; other batch copies use report definition output names.
successfully generated report is notified independently. Notification failure A collection failure stops the batch before planning or report generation.
marks that report failed, records notification fields in the batch result, and After planning succeeds, batch generation continues independent reports after a
does not stop later reports. `--out-dir` copies are never used as notification failure, records each result, writes compact status lines to stderr, emits a
source files. 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 ## Inspection Workflow
@@ -155,8 +165,8 @@ inspection view.
## Failure Behavior ## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data. - Resolve errors stop the requested workflow before collection.
- Weather API and module execution errors stop that report before Scriptorium - Collection and module execution errors stop that report before Scriptorium
runs. runs.
- Prompt input validation fails before render preflight. - Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context. - Render and run errors preserve Scriptorium stderr and exit-code context.
@@ -173,12 +183,17 @@ inspection view.
Inspect: Inspect:
- `internal/app/app_test.go` - `internal/app/app_test.go`
- `internal/app/batch_plan_test.go`
- `internal/collect/collect_test.go`
- `internal/cli/root_test.go` - `internal/cli/root_test.go`
- `internal/state/filesystem_test.go` - `internal/state/filesystem_test.go`
## Invariants ## Invariants
- Report behavior is resolved through `internal/report`. - 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 - Generated reports use the same app request and result types regardless of
report ID. report ID.
- Render preflight precedes Scriptorium report generation. - Render preflight precedes Scriptorium report generation.

View File

@@ -77,7 +77,7 @@ remain in report metadata, source provenance, and integration artifacts.
- This package selects and shapes already-collected weather facts for prompts. - This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option - It validates module composition against report compatibility and option
types. 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 snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata. metadata.

58
docs/internal/collect.md Normal file
View 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`.

View File

@@ -250,7 +250,7 @@ meet the severity threshold.
- This package owns module identifiers, config item envelopes, output - This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup. 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. forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior ## State Or Manifest Behavior

View File

@@ -128,7 +128,7 @@ Current categories are:
- This package owns prompt package schema, YAML marshaling, YAML loading, and - This package owns prompt package schema, YAML marshaling, YAML loading, and
validation. 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 module prompt export shapes, find prior snapshots, compare changes,
choose artifact paths, or invoke Scriptorium. choose artifact paths, or invoke Scriptorium.

View File

@@ -1,7 +1,7 @@
# Report Registry Internals # Report Registry Internals
This document describes report identity, valid-period resolution, batch This document describes report identity, valid-period resolution, output
membership, output naming, artifact grouping, and comparison declarations in naming, artifact grouping, batch command names, and comparison declarations in
`internal/report`. `internal/report`.
## Purpose ## Purpose
@@ -24,7 +24,6 @@ Each report definition declares:
- batch output copy filename - batch output copy filename
- generated-report eligibility - generated-report eligibility
- prior-report compatibility list - prior-report compatibility list
- morning or evening batch membership
- default ordered module composition - default ordered module composition
Report-owned helpers map public command names and config keys to report IDs. 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. not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight - 3-Day Outlook covers the interval from generation time through local midnight
three days later. three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for - Weekend Outlook covers the upcoming weekend window.
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller. - 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 Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
@@ -80,8 +78,9 @@ must be after start time.
## Boundaries ## Boundaries
`internal/report` defines report metadata, public report names, batch command `internal/report` defines report metadata, public report names, batch command
names, and time coverage. It does not fetch weather data, build module values, names, output naming, and time coverage. It does not collect weather data, plan
compare snapshot contents, write state, parse CLI flags, or invoke Scriptorium. 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 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 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 module overrides can use short keys such as `daily`, `today`, `tomorrow`, and
`hourly`, or descriptive names such as `three_day_outlook`. `hourly`, or descriptive names such as `three_day_outlook`.
## Batch Membership ## Batch Commands
Morning batches include Today Report, 3-Day Outlook, and Weekend Outlook `internal/report` owns the public batch command names `morning` and `evening`
except on Sunday. Evening batches include Tomorrow Report. Daily Report and and validates them through `BatchForCommandName`. Data-dependent batch
Hourly Report are not part of a scheduled 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 ## State And App Usage
@@ -135,3 +139,5 @@ Inspect:
- Artifact grouping, batch output filenames, generated-report eligibility, - Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy default module composition, comparison compatibility, and comparison strategy
are declared by report definition. are declared by report definition.
- App-owned batch planning uses report definitions but does not live in the
report registry.

View File

@@ -43,7 +43,7 @@ Generated-text prompt sources are maintained under
## Boundaries ## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown 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, validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor. write artifacts, invoke Scriptorium, or notify distributor.

View File

@@ -34,7 +34,7 @@ Outputs:
`internal/adapters/scriptorium` owns Scriptorium command construction and `internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input, 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 The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned structured generated-text run operations. State persistence uses state-owned

View File

@@ -18,11 +18,11 @@ weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 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 Generation commands resolve a report period, collect a Weather API bundle,
a rich JSON module snapshot, build a curated YAML prompt input data package, run build a rich JSON module snapshot, build a curated YAML prompt input data
`scriptorium render`, and write managed artifacts under the configured package, run `scriptorium render`, and write managed artifacts under the
workspace. Markdown-path reports then run `scriptorium run` directly to the configured workspace. Markdown-path reports then run `scriptorium run` directly
managed Markdown report path. to the managed Markdown report path.
`generate daily`, `generate today`, `generate tomorrow`, and `generate hourly` `generate daily`, `generate today`, `generate tomorrow`, and `generate hourly`
use the generated-text-template workflow. They run structured `scriptorium run` 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. daily` requires `--date YYYY-MM-DD` for the selected local civil day.
`generate today` covers the selected or current local civil day. `generate `generate today` covers the selected or current local civil day. `generate
hourly` covers the six-hour rolling period from generation time in the hourly` covers the six-hour rolling period from generation time in the
effective report timezone and is not part of scheduled morning or evening effective report timezone and is not included in `run morning` or
batches. `run evening`.
When distributor notification is enabled, weatherreporter uploads the managed When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved. Markdown report after report rendering succeeds and final metadata is saved.
@@ -46,16 +46,23 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`run morning` generates Today Report and the 3-Day Outlook, plus Weekend Outlook `run morning` generates Today Report, Tomorrow Report, and a dated Daily Report
except on Sunday. `run evening` generates the Tomorrow Report. Batch for each later future local civil day with complete hourly forecast coverage.
commands print a JSON summary to stdout, write compact per-report status lines `run evening` generates Tomorrow Report and the same eligible future Daily
to stderr, continue independent reports after one report fails, and return reports. Future Daily expansion starts with the day after tomorrow. A Daily
nonzero when any report failed. When notification is configured, the summary and report is eligible only when the collected hourly forecast contains every
status lines include notification status, accepted distributor run ID, or hourly period for that local civil day; partial days are skipped. Batch commands
notification error fields for each attempted report. `--out-dir PATH` writes collect weather data once before planning, and a collection failure stops the
extra Markdown copies using report default filenames such as `today.md`, batch before any report is generated.
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
distributor upload sources. 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 ## 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`, For Today, the default report ID and artifact group values are both `today`,
and the batch output filename value is `today.md`. and the batch output filename value is `today.md`.
Notification happens after final metadata save for generated reports. Weather Notification happens after final metadata save for generated reports.
API, module snapshot, data-package, render preflight, Scriptorium run, Collection, module snapshot, data-package, render preflight, Scriptorium run,
generated-text validation, template rendering, and metadata-save failures do generated-text validation, template rendering, and metadata-save failures do
not trigger notification. A notification failure fails that report. not trigger notification. A notification failure fails that report.
In a batch, other reports continue, the failed report includes notification 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
Inspection commands read existing workspace artifacts and emit JSON to stdout. 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 ```text
weatherreporter inspect reports --limit 10 weatherreporter inspect reports --limit 10

View File

@@ -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.

View File

@@ -73,8 +73,8 @@ represented in `CollectedFacts`.
Future module work should preserve these boundaries: Future module work should preserve these boundaries:
- collect upstream facts once per report run - keep upstream collection in app orchestration
- keep upstream fetching out of modules - keep upstream collection out of modules
- keep broad reusable calculations in `DerivedFacts` - keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders - keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior - use typed options for configurable module behavior

View File

@@ -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.

View File

@@ -6,7 +6,7 @@ fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
## `weather_api.base_url is required` ## `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. 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. 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 Diagnostic: if stdout contains a JSON summary, inspect each failed report item
lines. 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 Safe fix: for collection failures, fix the configuration or upstream Weather
metadata, sources, module snapshot, and data package for that RunID. 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). Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).

View File

@@ -10,9 +10,9 @@ import (
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "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/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "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/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
@@ -53,6 +53,7 @@ type GenerateRequest struct {
Date time.Time Date time.Time
StormStart time.Time StormStart time.Time
StormEnd time.Time StormEnd time.Time
Collector Collector
Notifier Notifier Notifier Notifier
} }
@@ -61,6 +62,7 @@ type BatchRequest struct {
Batch BatchKind Batch BatchKind
Now time.Time Now time.Time
OutputDir string OutputDir string
Collector Collector
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
@@ -85,6 +87,7 @@ type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string OutputPath string
Collection collect.Result
Renderer Renderer Renderer Renderer
Store state.Store Store state.Store
Notifier Notifier Notifier Notifier
@@ -161,6 +164,16 @@ type Renderer interface {
StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) 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 { type Notifier interface {
Notify(context.Context, NotificationRequest) (*NotificationResult, error) Notify(context.Context, NotificationRequest) (*NotificationResult, error)
} }
@@ -215,6 +228,10 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return err
}
resolved, err := ResolveGenerate(req, now) resolved, err := ResolveGenerate(req, now)
if err != nil { if err != nil {
return err return err
@@ -224,6 +241,7 @@ func Generate(ctx context.Context, req GenerateRequest) error {
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
Collection: *collection,
Notifier: req.Notifier, Notifier: req.Notifier,
}) })
return err return err
@@ -247,7 +265,14 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
if now.IsZero() { if now.IsZero() {
now = time.Now() 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 { if err != nil {
return nil, err return nil, err
} }
@@ -262,24 +287,27 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
} }
startedAt := now startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt} result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
resolved := planned.Resolved
if !resolved.Definition.Generated { if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
for _, resolved := range resolvedReports { for _, planned := range plannedReports {
item := batchReportResult(resolved) resolved := planned.Resolved
item := batchReportResult(planned)
if paths, err := store.Paths(resolved); err == nil { if paths, err := store.Paths(resolved); err == nil {
item.DataPackagePath = paths.DataPackage item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata item.MetadataPath = paths.Metadata
} }
outputPath := batchOutputPath(req.OutputDir, resolved.Definition) outputPath := plannedBatchOutputPath(req.OutputDir, planned)
reportResult, err := GenerateReport(ctx, ReportRequest{ reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Collection: *collection,
Renderer: req.Renderer, Renderer: req.Renderer,
Store: store, Store: store,
Notifier: req.Notifier, Notifier: req.Notifier,
@@ -321,7 +349,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
return nil, fmt.Errorf("run is not implemented") 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() metadata := resolved.Metadata()
return BatchReportResult{ return BatchReportResult{
ReportID: resolved.Definition.ID, ReportID: resolved.Definition.ID,
@@ -333,11 +362,18 @@ func batchReportResult(resolved report.Resolved) BatchReportResult {
} }
} }
func batchOutputPath(outputDir string, definition report.Definition) string { func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) string {
if outputDir == "" || definition.BatchOutputName == "" { if outputDir == "" {
return "" 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) { 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) { func reportRegistry(cfg config.Config) (report.Registry, error) {
overrides, err := cfg.ReportModuleOverrides() overrides, err := cfg.ReportModuleOverrides()
if err != nil { 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) { 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err return nil, err
} }
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil {
return nil, err return nil, fmt.Errorf("save bundle: %w", err)
} }
return bundle, nil return bundle, nil
} }
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { 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 store := req.Store
if store == nil { if store == nil {
defaultStore, err := defaultStore(req.Config) defaultStore, err := defaultStore(req.Config)
@@ -437,10 +472,6 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
return nil, err return nil, err
} }
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{ reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{
Config: req.Config, Config: req.Config,
Resolved: req.Resolved, Resolved: req.Resolved,

View File

@@ -15,6 +15,7 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "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) { func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
@@ -112,6 +199,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -341,9 +429,10 @@ func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testi
renderer := successfulGeneratedTextRenderer("") renderer := successfulGeneratedTextRenderer("")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -486,10 +575,11 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -614,6 +704,7 @@ func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T)
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -700,6 +791,7 @@ func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testin
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -776,6 +868,7 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -827,9 +920,10 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: successfulRenderer("# 3-Day Outlook\n"), Resolved: resolved,
Renderer: successfulRenderer("# 3-Day Outlook\n"),
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -856,10 +950,11 @@ func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *t
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -894,10 +989,11 @@ func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Notifier: notifier, Renderer: renderer,
Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -937,6 +1033,7 @@ func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -983,6 +1080,7 @@ func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) {
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -1017,6 +1115,7 @@ func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) {
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -1045,6 +1144,7 @@ func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *te
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -1079,10 +1179,11 @@ func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) {
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: successfulRenderer("# Daily Report\n"), Resolved: resolved,
Notifier: notifier, Renderer: successfulRenderer("# Daily Report\n"),
Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1106,6 +1207,7 @@ func TestGenerateReportNotifiesManagedReportPath(t *testing.T) {
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: successfulRenderer("# Daily Report\n"), Renderer: successfulRenderer("# Daily Report\n"),
@@ -1178,11 +1280,12 @@ func TestGenerateReportNotificationFailureFailsReport(t *testing.T) {
store := recordingFilesystemStore(t, cfg) store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: successfulRenderer("# Daily Report\n"), Resolved: resolved,
Store: store, Renderer: successfulRenderer("# Daily Report\n"),
Notifier: notifier, Store: store,
Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want notification error") t.Fatal("GenerateReport() error = nil, want notification error")
@@ -1243,10 +1346,11 @@ func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) {
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: tt.renderer, Resolved: resolved,
Notifier: notifier, Renderer: tt.renderer,
Notifier: notifier,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want generation error") 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 := config.Defaults()
cfg.WeatherAPI.BaseURL = "" cfg.WeatherAPI.BaseURL = ""
cfg.WeatherAPI.Timezone = "America/Chicago" cfg.WeatherAPI.Timezone = "America/Chicago"
@@ -1270,18 +1374,26 @@ func TestGenerateReportDoesNotNotifyAfterFetchFailure(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
notifier := &recordingNotifier{} notifier := &recordingNotifier{}
store := recordingFilesystemStore(t, cfg)
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"), Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
Notifier: notifier, Notifier: notifier,
}) })
if err == nil { 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 { 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{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want render error") t.Fatal("GenerateReport() error = nil, want render error")
@@ -1347,9 +1460,10 @@ func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) {
} }
_, err := GenerateReport(context.Background(), ReportRequest{ _, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Renderer: renderer,
}) })
if err == nil { if err == nil {
t.Fatal("GenerateReport() error = nil, want run error") t.Fatal("GenerateReport() error = nil, want run error")
@@ -1394,10 +1508,11 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: currentResolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1431,10 +1546,11 @@ func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) {
renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: currentResolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1492,9 +1608,10 @@ func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1564,10 +1681,11 @@ func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) {
renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON())
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: currentResolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1594,10 +1712,11 @@ func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) {
Date: mustParse("2026-05-29T12:00:00-05:00"), Date: mustParse("2026-05-29T12:00:00-05:00"),
}, "2026-05-29T05:00:00-05:00") }, "2026-05-29T05:00:00-05:00")
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: successfulRenderer("# Daily Report\n"), Resolved: currentResolved,
Store: store, Renderer: successfulRenderer("# Daily Report\n"),
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1628,10 +1747,11 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: currentResolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1673,10 +1793,11 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: currentResolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: currentResolved,
Store: store, Renderer: renderer,
Store: store,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1717,6 +1838,7 @@ func TestGenerateStormReportWritesReport(t *testing.T) {
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Collection: collectionForTest(t, cfg),
Resolved: resolved, Resolved: resolved,
OutputPath: outputPath, OutputPath: outputPath,
Renderer: renderer, Renderer: renderer,
@@ -1756,9 +1878,10 @@ func TestInspectGeneratedReportArtifacts(t *testing.T) {
runBody: "# Daily Report\n", runBody: "# Daily Report\n",
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: renderer, Resolved: resolved,
Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -1821,10 +1944,10 @@ func TestInspectPriorSnapshot(t *testing.T) {
runResult: &scriptorium.RunResult{ExitCode: 0}, runResult: &scriptorium.RunResult{ExitCode: 0},
runBody: "# Daily Report\n", 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) 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 { if err != nil {
t.Fatalf("GenerateReport(current) error = %v", err) 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) { func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
renderer := &selectiveRenderer{ renderer := &selectiveRenderer{
failRenderPrompt: "weather.three_day_outlook", failRenderPrompt: "weather.tomorrow_generated_text",
runBody: "# Batch Report\n", runBody: "# Batch Report\n",
} }
result, err := RunBatchDetailed(context.Background(), BatchRequest{ result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Renderer: renderer, Collector: collector,
Renderer: renderer,
}) })
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) 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 { 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) 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) 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 { for _, item := range result.Reports {
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") { if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedThreeDay = true 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) t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
} }
} }
if !failedThreeDay { if !failedTomorrow {
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports) 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) { func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyNotificationConfig(t, server) cfg := dailyNotificationConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
notifier := &recordingNotifier{ notifier := &recordingNotifier{
errByReport: map[report.ID]error{ errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"), report.Tomorrow: errors.New("distributor unavailable"),
}, },
} }
result, err := RunBatchDetailed(context.Background(), BatchRequest{ result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, Collector: collector,
Notifier: notifier, Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
Notifier: notifier,
}) })
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) 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 { 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) 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 { if len(notifier.requests) != 3 {
t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests)) t.Fatalf("notification requests = %d, want one per generated report", len(notifier.requests))
} }
var failedThreeDay bool var failedTomorrow bool
for _, item := range result.Reports { 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") { if item.Status == "failed" && strings.Contains(item.Error, "notify report") && strings.Contains(item.Error, "distributor unavailable") {
failedThreeDay = true failedTomorrow = true
} }
if item.NotificationStatus != "failed" { 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" { if item.NotificationPipelineID != "weatherreporter.tomorrow" {
t.Fatalf("3-day notification pipeline = %q, want weatherreporter.three-day", item.NotificationPipelineID) t.Fatalf("Tomorrow notification pipeline = %q, want weatherreporter.tomorrow", item.NotificationPipelineID)
} }
if !strings.Contains(item.NotificationError, "distributor unavailable") { 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 continue
} }
@@ -2201,21 +2326,23 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
t.Fatalf("report %s notification pipeline is empty", item.ReportID) t.Fatalf("report %s notification pipeline is empty", item.ReportID)
} }
} }
if !failedThreeDay { if !failedTomorrow {
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports) t.Fatalf("reports = %#v, want notification failure on Tomorrow item", result.Reports)
} }
cfg.Workspace.Root = t.TempDir() cfg.Workspace.Root = t.TempDir()
collector = &recordingCollector{result: &collection}
err = RunBatch(context.Background(), BatchRequest{ err = RunBatch(context.Background(), BatchRequest{
Config: cfg, Config: cfg,
Batch: BatchMorning, Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"), Now: mustParse("2026-05-29T05:00:00-05:00"),
Collector: collector,
Renderer: &selectiveRenderer{ Renderer: &selectiveRenderer{
runBody: "# Batch Report\n", runBody: "# Batch Report\n",
}, },
Notifier: &recordingNotifier{ Notifier: &recordingNotifier{
errByReport: map[report.ID]error{ 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) { func TestRunBatchUsesOutputDirectory(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := dailyWorkspaceConfig(t, server) cfg := dailyWorkspaceConfig(t, server)
collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31")
cfg.WeatherAPI.BaseURL = ""
collector := &recordingCollector{result: &collection}
outputDir := filepath.Join(t.TempDir(), "reports") outputDir := filepath.Join(t.TempDir(), "reports")
result, err := RunBatchDetailed(context.Background(), BatchRequest{ result, err := RunBatchDetailed(context.Background(), BatchRequest{
@@ -2238,31 +2368,44 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
Batch: BatchEvening, Batch: BatchEvening,
Now: mustParse("2026-05-29T18:00:00-05:00"), Now: mustParse("2026-05-29T18:00:00-05:00"),
OutputDir: outputDir, OutputDir: outputDir,
Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"}, Collector: collector,
Renderer: &selectiveRenderer{runBody: "# Batch Report\n"},
}) })
if err != nil { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) t.Fatalf("RunBatchDetailed() error = %v", err)
} }
if result.Failed != 0 || len(result.Reports) != 1 { if result.Failed != 0 || len(result.Reports) != 2 {
t.Fatalf("summary = %#v, want one successful report", result) t.Fatalf("summary = %#v, want two successful reports", result)
} }
want := filepath.Join(outputDir, "tomorrow.md") if len(collector.requests) != 1 {
if result.Reports[0].OutputPath != want { t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests))
t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want)
} }
if _, err := os.Stat(want); err != nil { wantByReport := map[report.ID]string{
t.Fatalf("expected output copy %q: %v", want, err) 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) for _, item := range result.Reports {
if err != nil { want := wantByReport[item.ReportID]
t.Fatalf("read managed report: %v", err) if want == "" {
} t.Fatalf("unexpected report item = %#v", item)
copyData, err := os.ReadFile(want) }
if err != nil { if item.OutputPath != want {
t.Fatalf("read output copy: %v", err) t.Fatalf("%s OutputPath = %q, want %q", item.ReportID, item.OutputPath, want)
} }
if string(copyData) != string(reportData) { if _, err := os.Stat(want); err != nil {
t.Fatalf("batch output copy differs from managed report") 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 { if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err) 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) t.Fatalf("summary = %#v, want successful morning batch", result)
} }
var todayItem *BatchReportResult var todayItem *BatchReportResult
@@ -2306,15 +2449,28 @@ func TestRunBatchMorningUsesTodayOutputName(t *testing.T) {
} }
} }
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) {
definition, err := report.DefaultRegistry().Lookup(report.Hourly) cfg := config.Defaults()
if err != nil { cfg.WeatherAPI.BaseURL = ""
t.Fatalf("Lookup(hourly) error = %v", err) 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") if len(collector.requests) != 1 {
want := filepath.Join(outputDir, "hourly.md") t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests))
if got := batchOutputPath(outputDir, definition); got != want { }
t.Fatalf("batchOutputPath() = %q, want %q", got, want) 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 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 { func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved {
t.Helper() t.Helper()
req.Config = cfg req.Config = cfg
@@ -2479,9 +2652,10 @@ func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult {
t.Fatalf("ResolveGenerate() error = %v", err) t.Fatalf("ResolveGenerate() error = %v", err)
} }
result, err := GenerateReport(context.Background(), ReportRequest{ result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg, Config: cfg,
Resolved: resolved, Collection: collectionForTest(t, cfg),
Renderer: successfulRenderer("# Daily Report\n"), Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
}) })
if err != nil { if err != nil {
t.Fatalf("GenerateReport() error = %v", err) t.Fatalf("GenerateReport() error = %v", err)
@@ -2790,6 +2964,20 @@ type recordingRenderer struct {
structuredRunBody string 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 { type recordingStore struct {
state.Store state.Store
calls []string calls []string

139
internal/app/batch_plan.go Normal file
View 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)
}

View 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
}

View File

@@ -164,7 +164,7 @@ func TestRunGenerateWeekendWritesMarkdownReport(t *testing.T) {
assertFileContains(t, dataPackagePath, "derived_daypart_summaries:") assertFileContains(t, dataPackagePath, "derived_daypart_summaries:")
} }
func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) { func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) {
fixture := newCLIFixture(t, writeFakeScriptorium) fixture := newCLIFixture(t, writeFakeScriptorium)
runner := Runner{Clock: fixedClock()} runner := Runner{Clock: fixedClock()}
@@ -176,7 +176,9 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
t.Fatalf("Run() error = %v", err) 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", "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") 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 { if err == nil {
t.Fatal("Run() error = nil, want aggregate failure") 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()) 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 { if decodeErr := json.Unmarshal([]byte(output.stdout), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout) t.Fatalf("decode summary: %v\n%s", decodeErr, output.stdout)
} }
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 { if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed) 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") { if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) 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", "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) { 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) fixture := newCLIFixture(t, writeFakeScriptorium)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -427,7 +429,9 @@ func TestRunMorningGeneratesTodayAndThreeDayOnSunday(t *testing.T) {
t.Fatalf("Run() error = %v", err) 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", "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") noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.yaml")
} }
@@ -1377,7 +1381,7 @@ if [ "$1" = "render" ]; then
fi fi
shift shift
done done
if [ "$prompt" = "weather.three_day_outlook" ]; then if [ "$prompt" = "weather.tomorrow_generated_text" ]; then
printf 'render failed\n' >&2 printf 'render failed\n' >&2
exit 1 exit 1
fi fi

View 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
}

View 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)
}
}))
}

View File

@@ -1,9 +1,6 @@
package report package report
import ( import "time"
"fmt"
"time"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) { func Resolve(id ID, req ResolveRequest) (Resolved, error) {
return DefaultRegistry().Resolve(id, req) return DefaultRegistry().Resolve(id, req)
@@ -17,39 +14,6 @@ func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
return r.resolveDefinition(definition, req) 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) { func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
if req.Location == nil { if req.Location == nil {
req.Location = time.UTC req.Location = time.UTC

View File

@@ -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) { func TestIDForCommandName(t *testing.T) {
tests := []struct { tests := []struct {
name string 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) { func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown")) _, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil { 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 { func resolvedDefinitionIDs(definitions []Definition) []string {
ids := make([]string, 0, len(definitions)) ids := make([]string, 0, len(definitions))
for _, definition := range definitions { for _, definition := range definitions {