Slice A backend, fully additive. Adds six new TimeHorizon constants to make the past/future fan symmetric for the date-range picker: next_1d, next_14d, next_all, past_1d, past_14d, past_all Each one-sided 'all' is distinct from the existing HorizonAll (bidirectional unbounded, Q26-gated) and HorizonAny (no time filter at all). next_all keeps from=today + to=nil; past_all keeps to=tomorrow + from=nil — half-open intervals, never crossing the boundary. computeViewSpecBounds gets twelve explicit fan arms plus the pre-existing any/all/custom paths. validate() accepts the six new horizons against any scope (none of them is the unbounded substrate scan that triggers Q26 on HorizonAll). New tests: - TestFilterSpec_NewSymmetricHorizonsValidate — round-trip - TestComputeViewSpecBounds_Horizons — table of 14 cases - TestComputeViewSpecBounds_NewHorizonsAreOneSided - TestComputeViewSpecBounds_CustomRoundTrips
124 lines
4.4 KiB
Go
124 lines
4.4 KiB
Go
package services
|
|
|
|
// Pure tests for computeViewSpecBounds — t-paliad-248. Covers every
|
|
// TimeHorizon constant in the symmetric date-range fan, including the
|
|
// six new ones added when the picker shipped (next_1d / next_14d /
|
|
// next_all / past_1d / past_14d / past_all).
|
|
//
|
|
// Anchored against a fixed `now` so the assertions never drift with the
|
|
// wall clock. Each case asserts the bounds shape (open-ended vs.
|
|
// closed) and the exact offsets from the anchor day.
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestComputeViewSpecBounds_Horizons(t *testing.T) {
|
|
// Anchor: 2026-05-25 14:37:00 UTC. computeViewSpecBounds normalises
|
|
// to startOfDay UTC, so the wall-clock time within the day is
|
|
// irrelevant.
|
|
now := time.Date(2026, 5, 25, 14, 37, 0, 0, time.UTC)
|
|
day := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
|
tomorrow := day.AddDate(0, 0, 1)
|
|
|
|
cases := []struct {
|
|
name string
|
|
horizon TimeHorizon
|
|
wantFrom *time.Time
|
|
wantTo *time.Time
|
|
}{
|
|
// Future fan.
|
|
{"next_1d", HorizonNext1d, &day, tptr(day.AddDate(0, 0, 1))},
|
|
{"next_7d", HorizonNext7d, &day, tptr(day.AddDate(0, 0, 7))},
|
|
{"next_14d", HorizonNext14d, &day, tptr(day.AddDate(0, 0, 14))},
|
|
{"next_30d", HorizonNext30d, &day, tptr(day.AddDate(0, 0, 30))},
|
|
{"next_90d", HorizonNext90d, &day, tptr(day.AddDate(0, 0, 90))},
|
|
// One-sided unbounded: from today, no upper bound.
|
|
{"next_all", HorizonNextAll, &day, nil},
|
|
|
|
// Past fan — upper bound is tomorrow (exclusive end-of-today).
|
|
{"past_1d", HorizonPast1d, tptr(day.AddDate(0, 0, -1)), &tomorrow},
|
|
{"past_7d", HorizonPast7d, tptr(day.AddDate(0, 0, -7)), &tomorrow},
|
|
{"past_14d", HorizonPast14d, tptr(day.AddDate(0, 0, -14)), &tomorrow},
|
|
{"past_30d", HorizonPast30d, tptr(day.AddDate(0, 0, -30)), &tomorrow},
|
|
{"past_90d", HorizonPast90d, tptr(day.AddDate(0, 0, -90)), &tomorrow},
|
|
// One-sided unbounded: no lower bound, up to and including today.
|
|
{"past_all", HorizonPastAll, nil, &tomorrow},
|
|
|
|
// Bidirectional unbounded — both nil.
|
|
{"any", HorizonAny, nil, nil},
|
|
{"all", HorizonAll, nil, nil},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := computeViewSpecBounds(now, TimeSpec{Horizon: tc.horizon})
|
|
assertBound(t, "from", got.from, tc.wantFrom)
|
|
assertBound(t, "to", got.to, tc.wantTo)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeViewSpecBounds_NewHorizonsAreOneSided documents the
|
|
// semantic distinction between next_all / past_all (one-sided
|
|
// unbounded, with one bound nil and the other set) and the existing
|
|
// HorizonAll / HorizonAny (both bounds nil).
|
|
func TestComputeViewSpecBounds_NewHorizonsAreOneSided(t *testing.T) {
|
|
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
|
|
|
nextAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonNextAll})
|
|
if nextAll.from == nil {
|
|
t.Fatalf("HorizonNextAll: from must be set (today), got nil")
|
|
}
|
|
if nextAll.to != nil {
|
|
t.Fatalf("HorizonNextAll: to must be nil (no upper bound), got %v", *nextAll.to)
|
|
}
|
|
|
|
pastAll := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonPastAll})
|
|
if pastAll.from != nil {
|
|
t.Fatalf("HorizonPastAll: from must be nil (no lower bound), got %v", *pastAll.from)
|
|
}
|
|
if pastAll.to == nil {
|
|
t.Fatalf("HorizonPastAll: to must be set (tomorrow), got nil")
|
|
}
|
|
|
|
any := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonAny})
|
|
if any.from != nil || any.to != nil {
|
|
t.Fatalf("HorizonAny: both bounds must be nil, got from=%v to=%v", any.from, any.to)
|
|
}
|
|
}
|
|
|
|
// TestComputeViewSpecBounds_CustomRoundTrips makes sure the custom
|
|
// horizon passes through the caller-supplied from/to verbatim — no
|
|
// normalisation, no clamping.
|
|
func TestComputeViewSpecBounds_CustomRoundTrips(t *testing.T) {
|
|
now := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
|
from := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
to := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
|
|
|
got := computeViewSpecBounds(now, TimeSpec{Horizon: HorizonCustom, From: &from, To: &to})
|
|
if got.from == nil || !got.from.Equal(from) {
|
|
t.Fatalf("custom from: want %v, got %v", from, got.from)
|
|
}
|
|
if got.to == nil || !got.to.Equal(to) {
|
|
t.Fatalf("custom to: want %v, got %v", to, got.to)
|
|
}
|
|
}
|
|
|
|
func tptr(t time.Time) *time.Time { return &t }
|
|
|
|
func assertBound(t *testing.T, name string, got *time.Time, want *time.Time) {
|
|
t.Helper()
|
|
switch {
|
|
case got == nil && want == nil:
|
|
return
|
|
case got == nil:
|
|
t.Fatalf("%s: want %v, got nil", name, *want)
|
|
case want == nil:
|
|
t.Fatalf("%s: want nil, got %v", name, *got)
|
|
case !got.Equal(*want):
|
|
t.Fatalf("%s: want %v, got %v", name, *want, *got)
|
|
}
|
|
}
|