Add report registry and valid periods
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter` currently resolves configuration and command requests, then
|
`weatherreporter` currently resolves configuration, command requests, report
|
||||||
returns a not-implemented error for report generation and scheduled runs.
|
definitions, and valid periods, then returns a not-implemented error for report
|
||||||
|
generation and scheduled runs.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
@@ -9,8 +10,8 @@ returns a not-implemented error for report generation and scheduled runs.
|
|||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The command parses flags, loads configuration, resolves the request, and then
|
The command parses flags, loads configuration, resolves report identity and the
|
||||||
stops before weather data fetching or report rendering.
|
valid period, and then stops before weather data fetching or report rendering.
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
|
||||||
|
|||||||
63
docs/internal/report-registry.md
Normal file
63
docs/internal/report-registry.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Report Registry Internals
|
||||||
|
|
||||||
|
This document describes the implemented report identity and valid-period
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
|
||||||
|
valid-period resolution, report metadata, and scheduled batch membership.
|
||||||
|
|
||||||
|
## Inputs and Outputs
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- report ID or batch name
|
||||||
|
- generation time
|
||||||
|
- configured timezone
|
||||||
|
- optional Daily date override
|
||||||
|
- optional manual storm start and end times
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `report.Resolved` values with definition metadata and half-open valid periods
|
||||||
|
- `report.Metadata` values suitable for later persisted run metadata
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- This package defines report identity and time coverage only.
|
||||||
|
- It does not fetch weather data, build briefings, compare snapshots, write
|
||||||
|
state, or call `scriptorium`.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Daily Today covers one configured local civil day.
|
||||||
|
- Daily Tomorrow covers the next configured local civil day.
|
||||||
|
- 3-Day Outlook covers generation time through local midnight after the second
|
||||||
|
following local civil day.
|
||||||
|
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
|
||||||
|
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
|
||||||
|
or generation time, whichever is later.
|
||||||
|
- Manual Storm Report uses explicit start and end times.
|
||||||
|
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
|
||||||
|
except on Sunday.
|
||||||
|
- Evening batch resolves Daily Tomorrow.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- Unknown report and batch names return actionable errors.
|
||||||
|
- Sunday Weekend Outlook resolution returns an error.
|
||||||
|
- Storm windows require start and end, with end after start.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
- `internal/report/period_test.go`
|
||||||
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Report selection goes through the registry.
|
||||||
|
- Valid periods are independent of rendered report text.
|
||||||
|
- Prompt IDs and comparison strategies are declared with report definitions.
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportKind string
|
type ReportKind string
|
||||||
@@ -49,16 +51,81 @@ type FetchBundleRequest struct {
|
|||||||
|
|
||||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
_ = req
|
if _, err := ResolveGenerate(req, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return fmt.Errorf("generate is not implemented")
|
return fmt.Errorf("generate is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunBatch(ctx context.Context, req BatchRequest) error {
|
func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
_ = req
|
if _, err := ResolveBatch(req, time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return fmt.Errorf("run is not implemented")
|
return fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return report.Resolved{}, err
|
||||||
|
}
|
||||||
|
id, err := reportIDForCommand(req.Report)
|
||||||
|
if err != nil {
|
||||||
|
return report.Resolved{}, err
|
||||||
|
}
|
||||||
|
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
Date: req.Date,
|
||||||
|
StormStart: req.StormStart,
|
||||||
|
StormEnd: req.StormEnd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := reportBatchForCommand(req.Batch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
|
||||||
|
Now: now,
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportIDForCommand(kind ReportKind) (report.ID, error) {
|
||||||
|
switch kind {
|
||||||
|
case ReportDaily:
|
||||||
|
return report.DailyToday, nil
|
||||||
|
case ReportTomorrow:
|
||||||
|
return report.DailyTomorrow, nil
|
||||||
|
case ReportThreeDay:
|
||||||
|
return report.ThreeDay, nil
|
||||||
|
case ReportWeekend:
|
||||||
|
return report.Weekend, nil
|
||||||
|
case ReportStorm:
|
||||||
|
return report.Storm, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown report command %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
|
||||||
|
switch kind {
|
||||||
|
case BatchMorning:
|
||||||
|
return report.Morning, nil
|
||||||
|
case BatchEvening:
|
||||||
|
return report.Evening, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown batch command %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
|
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
|
||||||
client, err := weatherapi.New(req.Config)
|
client, err := weatherapi.New(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFetchAndSaveBundle(t *testing.T) {
|
func TestFetchAndSaveBundle(t *testing.T) {
|
||||||
@@ -62,3 +64,77 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
|||||||
t.Fatalf("error = %q, want output path context", err.Error())
|
t.Fatalf("error = %q, want output path context", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
now := mustParse("2026-05-29T18:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportTomorrow,
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ID != report.DailyTomorrow {
|
||||||
|
t.Fatalf("ID = %q, want daily_tomorrow", resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if resolved.Definition.PromptID != "weather.daily_report" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
if got := resolved.ValidPeriod.Start.Format("2006-01-02"); got != "2026-05-30" {
|
||||||
|
t.Fatalf("valid start date = %s, want 2026-05-30", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveGenerateStorm(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
now := mustParse("2026-05-29T12:00:00-05:00")
|
||||||
|
start := mustParse("2026-05-29T18:00:00-05:00")
|
||||||
|
end := mustParse("2026-05-30T06:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportStorm,
|
||||||
|
StormStart: start,
|
||||||
|
StormEnd: end,
|
||||||
|
}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Definition.ID != report.Storm {
|
||||||
|
t.Fatalf("ID = %q, want storm", resolved.Definition.ID)
|
||||||
|
}
|
||||||
|
if !resolved.ValidPeriod.Start.Equal(start) || !resolved.ValidPeriod.End.Equal(end) {
|
||||||
|
t.Fatalf("period = %#v, want storm window", resolved.ValidPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mustParse(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|||||||
87
internal/report/definition.go
Normal file
87
internal/report/definition.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Package report defines report identities, registry metadata, and valid periods.
|
||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DailyToday ID = "daily_today"
|
||||||
|
DailyTomorrow ID = "daily_tomorrow"
|
||||||
|
ThreeDay ID = "three_day"
|
||||||
|
Weekend ID = "weekend"
|
||||||
|
Storm ID = "storm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComparisonStrategy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CompareSameValidDate ComparisonStrategy = "same_valid_date"
|
||||||
|
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
|
||||||
|
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Batch string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Morning Batch = "morning"
|
||||||
|
Evening Batch = "evening"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Definition struct {
|
||||||
|
ID ID
|
||||||
|
Name string
|
||||||
|
PromptID string
|
||||||
|
ComparisonStrategy ComparisonStrategy
|
||||||
|
DefaultOutputName string
|
||||||
|
Morning bool
|
||||||
|
Evening bool
|
||||||
|
resolve func(ResolveRequest) (timeutil.Period, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if d.resolve == nil {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID)
|
||||||
|
}
|
||||||
|
return d.resolve(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveRequest struct {
|
||||||
|
Now time.Time
|
||||||
|
Location *time.Location
|
||||||
|
Date time.Time
|
||||||
|
StormStart time.Time
|
||||||
|
StormEnd time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resolved struct {
|
||||||
|
Definition Definition
|
||||||
|
GeneratedAt time.Time
|
||||||
|
Timezone string
|
||||||
|
ValidPeriod timeutil.Period
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
ReportID ID `json:"reportId"`
|
||||||
|
PromptID string `json:"promptId"`
|
||||||
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Resolved) Metadata() Metadata {
|
||||||
|
return Metadata{
|
||||||
|
RunID: r.GeneratedAt.UTC().Format("20060102T150405Z") + "_" + string(r.Definition.ID),
|
||||||
|
ReportID: r.Definition.ID,
|
||||||
|
PromptID: r.Definition.PromptID,
|
||||||
|
GeneratedAt: r.GeneratedAt,
|
||||||
|
Timezone: r.Timezone,
|
||||||
|
ValidPeriod: r.ValidPeriod,
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/report/period.go
Normal file
144
internal/report/period.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||||
|
return DefaultRegistry().Resolve(id, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
|
||||||
|
definition, err := r.Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
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{DailyToday, 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{DailyTomorrow}, req)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown batch %q", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
|
||||||
|
resolved := make([]Resolved, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
item, err := r.Resolve(id, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolved = append(resolved, item)
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
|
||||||
|
if req.Location == nil {
|
||||||
|
req.Location = time.UTC
|
||||||
|
}
|
||||||
|
if req.Now.IsZero() {
|
||||||
|
req.Now = time.Now()
|
||||||
|
}
|
||||||
|
period, err := definition.ResolvePeriod(req)
|
||||||
|
if err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
return Resolved{
|
||||||
|
Definition: definition,
|
||||||
|
GeneratedAt: req.Now,
|
||||||
|
Timezone: req.Location.String(),
|
||||||
|
ValidPeriod: period,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if !req.Date.IsZero() {
|
||||||
|
return timeutil.CivilDay(req.Date, req.Location), nil
|
||||||
|
}
|
||||||
|
return timeutil.CivilDay(req.Now, req.Location), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
|
||||||
|
if location == nil {
|
||||||
|
location = time.UTC
|
||||||
|
}
|
||||||
|
startTime, err := timeutil.ParseStormTime(start, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
endTime, err := timeutil.ParseStormTime(end, location)
|
||||||
|
if err != nil {
|
||||||
|
return timeutil.Period{}, err
|
||||||
|
}
|
||||||
|
period := timeutil.Period{Start: startTime, End: endTime}
|
||||||
|
if !period.IsValid() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return period, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
endDate := localNow.AddDate(0, 0, 3)
|
||||||
|
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
return timeutil.Period{Start: localNow, End: end}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
localNow := req.Now.In(req.Location)
|
||||||
|
weekday := localNow.Weekday()
|
||||||
|
if weekday == time.Sunday {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
|
||||||
|
}
|
||||||
|
|
||||||
|
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
|
||||||
|
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
|
||||||
|
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
|
||||||
|
if weekday == time.Friday || weekday == time.Saturday {
|
||||||
|
friday := start.AddDate(0, 0, -1)
|
||||||
|
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
|
||||||
|
start = fridayEvening
|
||||||
|
if localNow.After(start) {
|
||||||
|
start = localNow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
|
||||||
|
return timeutil.Period{Start: start, End: end}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
|
||||||
|
if req.StormStart.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
|
||||||
|
}
|
||||||
|
if req.StormEnd.IsZero() {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
|
||||||
|
}
|
||||||
|
if !req.StormEnd.After(req.StormStart) {
|
||||||
|
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
|
||||||
|
}
|
||||||
|
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
|
||||||
|
}
|
||||||
231
internal/report/period_test.go
Normal file
231
internal/report/period_test.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDailyValidPeriod(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T17:45:00-05:00")
|
||||||
|
date := mustParse("2026-05-31T12:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T20:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
|
||||||
|
if resolved.Definition.PromptID != "weather.daily_report" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreeDayPeriodCalculation(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
now := mustParse("2026-05-29T05:00:00-05:00")
|
||||||
|
|
||||||
|
resolved, err := Resolve(ThreeDay, ResolveRequest{Now: now, Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendPeriodCalculation(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
now string
|
||||||
|
start string
|
||||||
|
end string
|
||||||
|
}{
|
||||||
|
{name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||||
|
{name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||||
|
{name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||||
|
{name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resolved, err := Resolve(Weekend, ResolveRequest{Now: mustParse(tt.now), Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, tt.start, tt.end)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeekendSundayErrors(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
_, err := Resolve(Weekend, ResolveRequest{Now: mustParse("2026-05-31T08:00:00-05:00"), Location: location})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Resolve() error = nil, want Sunday weekend error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Sunday") {
|
||||||
|
t.Fatalf("error = %q, want Sunday context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStormManualPeriodParsingAndValidation(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
period, err := ParseStormPeriod("2026-05-29T18:00", "2026-05-30T06:00:00-05:00", location)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseStormPeriod() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, period, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
|
||||||
|
|
||||||
|
_, err = ParseStormPeriod("2026-05-30T06:00", "2026-05-29T18:00", location)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ParseStormPeriod() error = nil, want invalid period error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStormResolve(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
resolved, err := Resolve(Storm, ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T12:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
|
||||||
|
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
|
||||||
|
if resolved.Definition.ComparisonStrategy != CompareExplicitWindow {
|
||||||
|
t.Fatalf("ComparisonStrategy = %q, want explicit event window", resolved.Definition.ComparisonStrategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ",") != "daily_today,three_day" {
|
||||||
|
t.Fatalf("ids = %v, want daily_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, ",") != "daily_tomorrow" {
|
||||||
|
t.Fatalf("ids = %v, want daily_tomorrow", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryLookupErrorIsActionable(t *testing.T) {
|
||||||
|
_, err := DefaultRegistry().Lookup(ID("unknown"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Lookup() error = nil, want unknown report error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
|
||||||
|
t.Fatalf("error = %q, want unknown report context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
|
||||||
|
for _, definition := range DefaultRegistry().All() {
|
||||||
|
if definition.PromptID == "" {
|
||||||
|
t.Fatalf("%s PromptID is empty", definition.ID)
|
||||||
|
}
|
||||||
|
if definition.ComparisonStrategy == "" {
|
||||||
|
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvedMetadata(t *testing.T) {
|
||||||
|
location := mustLoadLocation(t)
|
||||||
|
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
metadata := resolved.Metadata()
|
||||||
|
if metadata.ReportID != DailyToday {
|
||||||
|
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
|
||||||
|
}
|
||||||
|
if metadata.PromptID != "weather.daily_report" {
|
||||||
|
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(metadata.RunID, "daily_today") {
|
||||||
|
t.Fatalf("RunID = %q, want report id", metadata.RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
|
||||||
|
t.Helper()
|
||||||
|
if !period.IsValid() {
|
||||||
|
t.Fatalf("period = %#v, want valid", period)
|
||||||
|
}
|
||||||
|
if period.Start.Format(time.RFC3339) != wantStart {
|
||||||
|
t.Fatalf("Start = %s, want %s", period.Start.Format(time.RFC3339), wantStart)
|
||||||
|
}
|
||||||
|
if period.End.Format(time.RFC3339) != wantEnd {
|
||||||
|
t.Fatalf("End = %s, want %s", period.End.Format(time.RFC3339), wantEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 mustLoadLocation(t *testing.T) *time.Location {
|
||||||
|
t.Helper()
|
||||||
|
location, err := time.LoadLocation("America/Chicago")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParse(value string) time.Time {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
88
internal/report/registry.go
Normal file
88
internal/report/registry.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package report
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
definitions map[ID]Definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultRegistry() Registry {
|
||||||
|
definitions := []Definition{
|
||||||
|
{
|
||||||
|
ID: DailyToday,
|
||||||
|
Name: "Daily Report",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
DefaultOutputName: "daily.md",
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveDailyToday,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: DailyTomorrow,
|
||||||
|
Name: "Tomorrow Planning Brief",
|
||||||
|
PromptID: "weather.daily_report",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
DefaultOutputName: "tomorrow.md",
|
||||||
|
Evening: true,
|
||||||
|
resolve: resolveDailyTomorrow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ThreeDay,
|
||||||
|
Name: "3-Day Outlook",
|
||||||
|
PromptID: "weather.three_day_outlook",
|
||||||
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
|
DefaultOutputName: "three_day.md",
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveThreeDay,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: Weekend,
|
||||||
|
Name: "Weekend Outlook",
|
||||||
|
PromptID: "weather.weekend_outlook",
|
||||||
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
|
DefaultOutputName: "weekend.md",
|
||||||
|
Morning: true,
|
||||||
|
resolve: resolveWeekend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: Storm,
|
||||||
|
Name: "Storm Report",
|
||||||
|
PromptID: "weather.storm_report",
|
||||||
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
|
DefaultOutputName: "storm.md",
|
||||||
|
resolve: resolveStorm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
registry := Registry{definitions: map[ID]Definition{}}
|
||||||
|
for _, definition := range definitions {
|
||||||
|
registry.definitions[definition.ID] = definition
|
||||||
|
}
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) Lookup(id ID) (Definition, error) {
|
||||||
|
definition, ok := r.definitions[id]
|
||||||
|
if !ok {
|
||||||
|
return Definition{}, fmt.Errorf("unknown report %q", id)
|
||||||
|
}
|
||||||
|
return definition, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) MustLookup(id ID) Definition {
|
||||||
|
definition, err := r.Lookup(id)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) All() []Definition {
|
||||||
|
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
|
||||||
|
out := make([]Definition, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if definition, ok := r.definitions[id]; ok {
|
||||||
|
out = append(out, definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user