Files
paliad/internal/services/view_service_bounds_test.go
mAi 34e3d7188e feat(filter_spec): t-paliad-248 — symmetric date-range horizons
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
2026-05-25 15:37:00 +02:00

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)
}
}