diff --git a/internal/briefing/modules_test.go b/internal/briefing/modules_test.go index 350d21b..aa418ed 100644 --- a/internal/briefing/modules_test.go +++ b/internal/briefing/modules_test.go @@ -32,6 +32,9 @@ func TestDefaultReportModulesBuildSnapshots(t *testing.T) { registry := MustDefaultModuleRegistry() for _, definition := range report.DefaultRegistry().All() { t.Run(string(definition.ID), func(t *testing.T) { + if len(definition.Modules) == 0 { + return + } ctx := derivedModuleContext(definition.ID) var outputs []module.Output for _, item := range definition.Modules { diff --git a/internal/report/definition.go b/internal/report/definition.go index b05e815..81bfe67 100644 --- a/internal/report/definition.go +++ b/internal/report/definition.go @@ -14,6 +14,7 @@ type ID string const ( DailyToday ID = "daily_today" DailyTomorrow ID = "daily_tomorrow" + NearTerm ID = "near_term" ThreeDay ID = "three_day" Weekend ID = "weekend" Storm ID = "storm" @@ -25,6 +26,7 @@ const ( CompareSameValidDate ComparisonStrategy = "same_valid_date" CompareWeekendWindow ComparisonStrategy = "same_weekend_window" CompareExplicitWindow ComparisonStrategy = "explicit_event_window" + CompareRollingWindow ComparisonStrategy = "rolling_window" ) type Batch string diff --git a/internal/report/near_term_report.go b/internal/report/near_term_report.go new file mode 100644 index 0000000..275df67 --- /dev/null +++ b/internal/report/near_term_report.go @@ -0,0 +1,37 @@ +package report + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +const nearTermHours = 6 + +func nearTermDefinition() Definition { + return Definition{ + ID: NearTerm, + Name: "Near-Term Report", + PromptID: "weather.near_term_report", + ComparisonStrategy: CompareRollingWindow, + ArtifactGroup: "near-term", + BatchOutputName: "near-term.md", + Generated: true, + CompatiblePriorIDs: []ID{NearTerm}, + Modules: nearTermModules(), + resolve: resolveNearTerm, + } +} + +func nearTermModules() []module.ConfigItem { + return nil +} + +func resolveNearTerm(req ResolveRequest) (timeutil.Period, error) { + localNow := req.Now.In(req.Location) + return timeutil.Period{ + Start: localNow, + End: localNow.Add(nearTermHours * time.Hour), + }, nil +} diff --git a/internal/report/period_test.go b/internal/report/period_test.go index cb402b4..fd4d7c8 100644 --- a/internal/report/period_test.go +++ b/internal/report/period_test.go @@ -58,6 +58,51 @@ func TestThreeDayPeriodCalculation(t *testing.T) { assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00") } +func TestNearTermLookupAndPeriodCalculation(t *testing.T) { + location := mustLoadLocation(t) + now := mustParse("2026-05-29T05:15:00-05:00") + + resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if resolved.Definition.ID != NearTerm { + t.Fatalf("ID = %q, want near_term", resolved.Definition.ID) + } + if resolved.Definition.PromptID != "weather.near_term_report" { + t.Fatalf("PromptID = %q, want weather.near_term_report", resolved.Definition.PromptID) + } + if resolved.Definition.ComparisonStrategy != CompareRollingWindow { + t.Fatalf("ComparisonStrategy = %q, want rolling_window", resolved.Definition.ComparisonStrategy) + } + assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:15:00-05:00", "2026-05-29T11:15:00-05:00") +} + +func TestNearTermPeriodUsesEffectiveTimezone(t *testing.T) { + location, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("load location: %v", err) + } + now := mustParse("2026-05-29T10:15:00Z") + + resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertPeriod(t, resolved.ValidPeriod, "2026-05-29T06:15:00-04:00", "2026-05-29T12:15:00-04:00") +} + +func TestNearTermPeriodIsNotCivilDayTruncated(t *testing.T) { + location := mustLoadLocation(t) + now := mustParse("2026-05-29T22:30:00-05:00") + + resolved, err := Resolve(NearTerm, ResolveRequest{Now: now, Location: location}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + assertPeriod(t, resolved.ValidPeriod, "2026-05-29T22:30:00-05:00", "2026-05-30T04:30:00-05:00") +} + func TestWeekendPeriodCalculation(t *testing.T) { location := mustLoadLocation(t) tests := []struct { @@ -158,6 +203,25 @@ func TestEveningBatchIncludesTomorrow(t *testing.T) { } } +func TestBatchesDoNotIncludeNearTerm(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 == NearTerm { + t.Fatalf("batch included %q, want near-term excluded", resolved.Definition.ID) + } + } +} + func TestRegistryLookupErrorIsActionable(t *testing.T) { _, err := DefaultRegistry().Lookup(ID("unknown")) if err == nil { @@ -168,6 +232,14 @@ func TestRegistryLookupErrorIsActionable(t *testing.T) { } } +func TestRegistryAllIncludesNearTermInStableOrder(t *testing.T) { + ids := resolvedDefinitionIDs(DefaultRegistry().All()) + want := []string{"daily_today", "daily_tomorrow", "near_term", "three_day", "weekend", "storm"} + if strings.Join(ids, ",") != strings.Join(want, ",") { + t.Fatalf("All() ids = %#v, want %#v", ids, want) + } +} + func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) { for _, definition := range DefaultRegistry().All() { if definition.PromptID == "" { @@ -186,6 +258,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName string generated bool compatiblePriorIDs []ID + comparisonStrategy ComparisonStrategy }{ { id: DailyToday, @@ -193,6 +266,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName: "daily.md", generated: true, compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + comparisonStrategy: CompareSameValidDate, }, { id: DailyTomorrow, @@ -200,6 +274,15 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName: "tomorrow.md", generated: true, compatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + comparisonStrategy: CompareSameValidDate, + }, + { + id: NearTerm, + artifactGroup: "near-term", + batchOutputName: "near-term.md", + generated: true, + compatiblePriorIDs: []ID{NearTerm}, + comparisonStrategy: CompareRollingWindow, }, { id: ThreeDay, @@ -207,6 +290,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName: "three-day.md", generated: true, compatiblePriorIDs: []ID{ThreeDay}, + comparisonStrategy: CompareSameValidDate, }, { id: Weekend, @@ -214,6 +298,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName: "weekend.md", generated: true, compatiblePriorIDs: []ID{Weekend}, + comparisonStrategy: CompareWeekendWindow, }, { id: Storm, @@ -221,6 +306,7 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { batchOutputName: "storm.md", generated: true, compatiblePriorIDs: []ID{Storm}, + comparisonStrategy: CompareExplicitWindow, }, } @@ -240,6 +326,9 @@ func TestRegistryDefinitionsDeclarePathAndCompatibilityPolicy(t *testing.T) { if definition.Generated != tt.generated { t.Fatalf("Generated = %t, want %t", definition.Generated, tt.generated) } + if definition.ComparisonStrategy != tt.comparisonStrategy { + t.Fatalf("ComparisonStrategy = %q, want %q", definition.ComparisonStrategy, tt.comparisonStrategy) + } if !reflect.DeepEqual(definition.CompatiblePriorIDs, tt.compatiblePriorIDs) { t.Fatalf("CompatiblePriorIDs = %#v, want %#v", definition.CompatiblePriorIDs, tt.compatiblePriorIDs) } @@ -294,6 +383,10 @@ func TestRegistryDefinitionsDeclareDefaultModules(t *testing.T) { module.HourlyForecast, }, }, + { + id: NearTerm, + want: []module.ID{}, + }, { id: ThreeDay, want: []module.ID{ @@ -412,6 +505,24 @@ func TestResolvedMetadata(t *testing.T) { } } +func TestNearTermMetadataRunIDIncludesReportID(t *testing.T) { + location := mustLoadLocation(t) + resolved, err := Resolve(NearTerm, ResolveRequest{Now: mustParse("2026-05-29T05:15:00-05:00"), Location: location}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + metadata := resolved.Metadata() + if metadata.ReportID != NearTerm { + t.Fatalf("ReportID = %q, want near_term", metadata.ReportID) + } + if metadata.PromptID != "weather.near_term_report" { + t.Fatalf("PromptID = %q, want weather.near_term_report", metadata.PromptID) + } + if !strings.Contains(metadata.RunID, "near_term") { + 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() { @@ -433,6 +544,14 @@ func resolvedIDs(resolved []Resolved) []string { return ids } +func resolvedDefinitionIDs(definitions []Definition) []string { + ids := make([]string, 0, len(definitions)) + for _, definition := range definitions { + ids = append(ids, string(definition.ID)) + } + return ids +} + func mustLoadLocation(t *testing.T) *time.Location { t.Helper() location, err := time.LoadLocation("America/Chicago") diff --git a/internal/report/registry.go b/internal/report/registry.go index 7497f7d..536207e 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -14,6 +14,7 @@ func DefaultRegistry() Registry { definitions := []Definition{ dailyTodayDefinition(), dailyTomorrowDefinition(), + nearTermDefinition(), threeDayDefinition(), weekendDefinition(), stormDefinition(), @@ -73,7 +74,7 @@ func (r Registry) MustLookup(id ID) Definition { } func (r Registry) All() []Definition { - ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm} + ids := []ID{DailyToday, DailyTomorrow, NearTerm, ThreeDay, Weekend, Storm} out := make([]Definition, 0, len(ids)) for _, id := range ids { if definition, ok := r.definitions[id]; ok { diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 00dc7b9..6fdf6e9 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -305,6 +305,21 @@ func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) { } } +func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) { + store := newTestStore(t) + first := resolveNearTermAt(t, "2026-05-29T05:00:00-05:00") + second := resolveNearTermAt(t, "2026-05-29T06:00:00-05:00") + savePriorMetadata(t, store, first, stateBriefingMetadata(first)) + + prior, err := store.FindPriorSnapshot(context.Background(), second) + if err != nil { + t.Fatalf("FindPriorSnapshot() error = %v", err) + } + if prior != nil { + t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior) + } +} + func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { cfg := config.Defaults().Workspace cfg.Root = t.TempDir() @@ -390,6 +405,26 @@ func resolveWeekendAt(t *testing.T, value string) report.Resolved { return resolved } +func resolveNearTermAt(t *testing.T, value string) report.Resolved { + t.Helper() + location, err := timeutil.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + now, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + resolved, err := report.DefaultRegistry().Resolve(report.NearTerm, report.ResolveRequest{ + Now: now, + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { return briefing.Metadata{ RunID: resolved.Metadata().RunID,