Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
161 lines
5.2 KiB
Go
161 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestDefaultStepIsMaxOf60sAndRangeOver1000RoundedToMinutes(t *testing.T) {
|
|
for rng, want := range map[time.Duration]time.Duration{
|
|
time.Hour: time.Minute, 16 * time.Hour: time.Minute, 17 * time.Hour: 2 * time.Minute,
|
|
24 * time.Hour: 2 * time.Minute, 30 * 24 * time.Hour: 44 * time.Minute, 365 * 24 * time.Hour: 526 * time.Minute,
|
|
} {
|
|
if got := defaultStep(rng); got != want {
|
|
t.Errorf("defaultStep(%s) = %s, want %s", rng, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanGraphTiers(t *testing.T) {
|
|
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
|
hotStart := now.Add(-30 * 24 * time.Hour)
|
|
cases := []struct {
|
|
name string
|
|
from, to time.Time
|
|
tiers []string
|
|
stitched bool
|
|
wantErr bool
|
|
step time.Duration
|
|
newestStep time.Duration
|
|
}{
|
|
{"1h", now.Add(-time.Hour), now, []string{"raw"}, false, false, 0, time.Minute},
|
|
{"exactly the hot window", hotStart, now, []string{"raw"}, false, false, 0, 44 * time.Minute},
|
|
{"a hair beyond hot is still raw", hotStart.Add(-20 * time.Minute), now, []string{"raw"}, false, false, 0, 44 * time.Minute},
|
|
{"straddles", now.Add(-40 * 24 * time.Hour), now, []string{"1h", "raw"}, true, false, 0, 58 * time.Minute},
|
|
{"ends inside hot start", now.Add(-60 * 24 * time.Hour), hotStart, []string{"1h"}, false, false, 0, time.Hour},
|
|
{"old", now.Add(-90 * 24 * time.Hour), now.Add(-60 * 24 * time.Hour), []string{"1h"}, false, false, 0, time.Hour},
|
|
{"too many points", now.Add(-30 * 24 * time.Hour), now, nil, false, true, time.Second, 0},
|
|
{"explicit step", now.Add(-time.Hour), now, []string{"raw"}, false, false, 5 * time.Minute, 5 * time.Minute},
|
|
}
|
|
for _, tc := range cases {
|
|
p, err := planGraph(tc.from, tc.to, hotStart, tc.step)
|
|
if (err != nil) != tc.wantErr {
|
|
t.Errorf("%s: err %v", tc.name, err)
|
|
continue
|
|
}
|
|
if tc.wantErr {
|
|
continue
|
|
}
|
|
var got []string
|
|
for _, w := range p.tiers {
|
|
got = append(got, w.tier)
|
|
}
|
|
if len(got) != len(tc.tiers) || (p.stitchedAt != nil) != tc.stitched || p.newest().step != tc.newestStep {
|
|
t.Errorf("%s: tiers %v stitched %v newest step %s, want %v %v %s", tc.name, got, p.stitchedAt, p.newest().step, tc.tiers, tc.stitched, tc.newestStep)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.tiers[i] {
|
|
t.Errorf("%s: tiers %v, want %v", tc.name, got, tc.tiers)
|
|
}
|
|
}
|
|
if tc.stitched {
|
|
join := *p.stitchedAt
|
|
if join.Before(hotStart) || join.Sub(hotStart) >= p.newest().step || join.Unix()%int64(p.newest().step/time.Second) != 0 {
|
|
t.Errorf("%s: join %v", tc.name, join)
|
|
}
|
|
if long := p.tiers[0]; long.last.Unix() >= join.Unix() || long.bound != join {
|
|
t.Errorf("%s: long tier overlaps raw: %+v", tc.name, long)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFindGapsThresholdIsTwoSteps(t *testing.T) {
|
|
grid := make([]slot, 11)
|
|
for i := range grid {
|
|
grid[i] = slot{ts: int64(i * 60), step: 60}
|
|
}
|
|
has := func(missing ...int) func(int64) bool {
|
|
return func(ts int64) bool {
|
|
for _, m := range missing {
|
|
if ts == int64(m*60) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
missing []int
|
|
want [][2]int64
|
|
}{
|
|
{"complete", nil, nil},
|
|
{"one missing sample", []int{4}, nil},
|
|
{"two missing samples", []int{4, 5}, [][2]int64{{180, 360}}},
|
|
{"leading", []int{0, 1, 2}, [][2]int64{{0, 180}}},
|
|
{"leading short", []int{0, 1}, nil},
|
|
{"trailing", []int{8, 9, 10}, [][2]int64{{420, 600}}},
|
|
{"two separate holes", []int{2, 3, 7, 8}, [][2]int64{{60, 240}, {360, 540}}},
|
|
} {
|
|
got := findGaps(grid, has(tc.missing...), 0, 600)
|
|
if len(got) != len(tc.want) {
|
|
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
|
|
continue
|
|
}
|
|
for i, g := range got {
|
|
if g.From.Unix() != tc.want[i][0] || g.To.Unix() != tc.want[i][1] {
|
|
t.Errorf("%s: gap %d is %v..%v, want %v", tc.name, i, g.From.Unix(), g.To.Unix(), tc.want[i])
|
|
}
|
|
}
|
|
}
|
|
none := func(int64) bool { return false }
|
|
if g := findGaps(grid, none, 0, 600); len(g) != 1 || g[0].From.Unix() != 0 || g[0].To.Unix() != 600 {
|
|
t.Errorf("no data: %v", g)
|
|
}
|
|
if g := findGaps(nil, none, 0, 0); g != nil {
|
|
t.Errorf("empty grid: %v", g)
|
|
}
|
|
}
|
|
|
|
func TestNearestRank(t *testing.T) {
|
|
seq := func(n int) []float64 {
|
|
out := make([]float64, n)
|
|
for i := range out {
|
|
out[i] = float64(n - i) // descending, so sorting matters
|
|
}
|
|
return out
|
|
}
|
|
for n, want := range map[int]float64{1: 1, 2: 2, 19: 19, 20: 19, 21: 20, 100: 95, 101: 96, 1000: 950} {
|
|
if got := nearestRank(seq(n), 0.95); got != want {
|
|
t.Errorf("p95 of 1..%d = %v, want %v", n, got, want)
|
|
}
|
|
}
|
|
if !math.IsNaN(nearestRank(nil, 0.95)) {
|
|
t.Error("no values should be NaN")
|
|
}
|
|
in := []float64{3, 1, 2}
|
|
nearestRank(in, 0.5)
|
|
if in[0] != 3 {
|
|
t.Error("input must not be reordered")
|
|
}
|
|
}
|
|
|
|
func TestParentCycleHelpersAndEvidence(t *testing.T) {
|
|
got := evidenceLines(`{"b":2,"a":"x","c":["y"]}`)
|
|
if len(got) != 3 || got[0] != "a: x" || got[1] != "b: 2" || got[2] != `c: ["y"]` {
|
|
t.Errorf("evidence: %q", got)
|
|
}
|
|
if got := evidenceLines(`not json`); len(got) != 1 || got[0] != "not json" {
|
|
t.Errorf("evidence fallback: %q", got)
|
|
}
|
|
if got := evidenceLines(``); got == nil || len(got) != 0 {
|
|
t.Errorf("empty evidence must be [] not nil: %#v", got)
|
|
}
|
|
if quote(`a"b\c`) != `"a\"b\\c"` {
|
|
t.Errorf("quote: %s", quote(`a"b\c`))
|
|
}
|
|
}
|