Preserve civil daypart clocks across DST
This commit is contained in:
@@ -13,7 +13,12 @@ Each `Definition` declares a stable ID and display name, prompt ID and version,
|
||||
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
|
||||
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
|
||||
|
||||
Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
|
||||
Daily derives its filename and run identity from the resolved valid-period start
|
||||
in the effective timezone, so multiple Daily items have distinct destinations
|
||||
and identifiers. Exact template fields and schema assets belong to [report
|
||||
templates](../templates.md) and [generated-text internals](generatedtext.md).
|
||||
Prompt assets own default profile selection; the registry stores no provider
|
||||
setting.
|
||||
|
||||
## Collaborators And Boundaries
|
||||
|
||||
|
||||
@@ -43,6 +43,37 @@ func TestResolveDailyRequiresDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRunIDsDistinguishResolvedDates(t *testing.T) {
|
||||
location := loadTestLocation(t)
|
||||
now := parseTestTime("2026-05-29T17:45:00-05:00")
|
||||
tests := []struct {
|
||||
date time.Time
|
||||
wantDate string
|
||||
}{
|
||||
{date: time.Date(2026, time.May, 30, 12, 0, 0, 0, location), wantDate: "2026-05-30"},
|
||||
{date: time.Date(2026, time.May, 31, 12, 0, 0, 0, location), wantDate: "2026-05-31"},
|
||||
}
|
||||
|
||||
runIDs := make(map[string]struct{}, len(tests))
|
||||
for _, tt := range tests {
|
||||
resolved, err := Resolve(Daily, ResolveRequest{Now: now, Location: location, Date: tt.date})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(Daily) error = %v", err)
|
||||
}
|
||||
runID := resolved.Metadata().RunID
|
||||
if runID == "" {
|
||||
t.Fatal("Daily RunID is empty")
|
||||
}
|
||||
if !strings.HasSuffix(runID, "_"+tt.wantDate) {
|
||||
t.Fatalf("Daily RunID = %q, want resolved date %q", runID, tt.wantDate)
|
||||
}
|
||||
if _, exists := runIDs[runID]; exists {
|
||||
t.Fatalf("duplicate Daily RunID %q", runID)
|
||||
}
|
||||
runIDs[runID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryContainsOnlyPromptBackedReports(t *testing.T) {
|
||||
registry := DefaultRegistry()
|
||||
definitions := registry.All()
|
||||
|
||||
@@ -72,10 +72,17 @@ func CivilDay(date time.Time, location *time.Location) Period {
|
||||
|
||||
func ClockWindow(date time.Time, location *time.Location, startClock time.Duration, endClock time.Duration) Period {
|
||||
day := CivilDay(date, location)
|
||||
start := day.Start.Add(startClock)
|
||||
end := day.Start.Add(endClock)
|
||||
start := civilClock(day.Start, location, startClock)
|
||||
end := civilClock(day.Start, location, endClock)
|
||||
if !end.After(start) {
|
||||
end = end.AddDate(0, 0, 1)
|
||||
end = civilClock(day.Start.AddDate(0, 0, 1), location, endClock)
|
||||
}
|
||||
return Period{Start: start, End: end}
|
||||
}
|
||||
|
||||
func civilClock(date time.Time, location *time.Location, clock time.Duration) time.Time {
|
||||
local := date.In(location)
|
||||
hour := int(clock / time.Hour)
|
||||
minute := int(clock%time.Hour) / int(time.Minute)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), hour, minute, 0, 0, location)
|
||||
}
|
||||
|
||||
@@ -33,3 +33,72 @@ func TestClockWindowHandlesOvernight(t *testing.T) {
|
||||
t.Fatalf("End = %s, want 2026-05-30T06:00", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClockWindowUsesCivilTimeAcrossDSTTransitions(t *testing.T) {
|
||||
location, err := LoadLocation("America/Chicago")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
date time.Time
|
||||
start string
|
||||
end string
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{
|
||||
name: "spring-forward daytime",
|
||||
date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location),
|
||||
start: "00:00",
|
||||
end: "06:00",
|
||||
wantStart: "2026-03-08T00:00:00-06:00",
|
||||
wantEnd: "2026-03-08T06:00:00-05:00",
|
||||
},
|
||||
{
|
||||
name: "fall-back daytime",
|
||||
date: time.Date(2026, time.November, 1, 12, 0, 0, 0, location),
|
||||
start: "00:00",
|
||||
end: "06:00",
|
||||
wantStart: "2026-11-01T00:00:00-05:00",
|
||||
wantEnd: "2026-11-01T06:00:00-06:00",
|
||||
},
|
||||
{
|
||||
name: "spring-forward overnight",
|
||||
date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location),
|
||||
start: "22:00",
|
||||
end: "06:00",
|
||||
wantStart: "2026-03-08T22:00:00-05:00",
|
||||
wantEnd: "2026-03-09T06:00:00-05:00",
|
||||
},
|
||||
{
|
||||
name: "spring-forward end of day",
|
||||
date: time.Date(2026, time.March, 8, 12, 0, 0, 0, location),
|
||||
start: "18:00",
|
||||
end: "24:00",
|
||||
wantStart: "2026-03-08T18:00:00-05:00",
|
||||
wantEnd: "2026-03-09T00:00:00-05:00",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, err := ParseClock(tt.start)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClock(%q) error = %v", tt.start, err)
|
||||
}
|
||||
end, err := ParseClock(tt.end)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseClock(%q) error = %v", tt.end, err)
|
||||
}
|
||||
|
||||
window := ClockWindow(tt.date, location, start, end)
|
||||
if got := window.Start.Format(time.RFC3339); got != tt.wantStart {
|
||||
t.Fatalf("Start = %s, want %s", got, tt.wantStart)
|
||||
}
|
||||
if got := window.End.Format(time.RFC3339); got != tt.wantEnd {
|
||||
t.Fatalf("End = %s, want %s", got, tt.wantEnd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user