Add report registry and valid periods
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`weatherreporter` currently resolves configuration and command requests, then
|
||||
returns a not-implemented error for report generation and scheduled runs.
|
||||
`weatherreporter` currently resolves configuration, command requests, report
|
||||
definitions, and valid periods, then returns a not-implemented error for report
|
||||
generation and scheduled runs.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
The command parses flags, loads configuration, resolves the request, and then
|
||||
stops before weather data fetching or report rendering.
|
||||
The command parses flags, loads configuration, resolves report identity and the
|
||||
valid period, and then stops before weather data fetching or report rendering.
|
||||
|
||||
## 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/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||
)
|
||||
|
||||
type ReportKind string
|
||||
@@ -49,16 +51,81 @@ type FetchBundleRequest struct {
|
||||
|
||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||
_ = ctx
|
||||
_ = req
|
||||
if _, err := ResolveGenerate(req, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("generate is not implemented")
|
||||
}
|
||||
|
||||
func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||
_ = ctx
|
||||
_ = req
|
||||
if _, err := ResolveBatch(req, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
client, err := weatherapi.New(req.Config)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestFetchAndSaveBundle(t *testing.T) {
|
||||
@@ -62,3 +64,77 @@ func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) {
|
||||
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