package obs
import (
"reflect"
"testing"
"time"
)
var t0 = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
// The line answers the same question as the number above it: how many
// were running, not how many started.
func TestConcurrencyCountsWhatWasRunning(t *testing.T) {
// A window of 4 bins over 4 seconds: bins start at +0s, +1s, +2s, +3s.
now := t0.Add(4 * time.Second)
subs := []Subprocess{
// Runs the whole window: counted in every bin.
{At: t0.Add(-time.Second), Duration: 10 * time.Second},
// Starts and ends inside the second bin: counted once.
{At: t0.Add(1100 * time.Millisecond), Duration: 300 * time.Millisecond},
// Ends exactly at a bin start: not running at that instant.
{At: t0.Add(2 * time.Second), Duration: time.Second},
}
got := ConcurrencyBins(subs, now, 4*time.Second, 4)
if want := []int{1, 1, 2, 1}; !reflect.DeepEqual(got, want) {
t.Fatalf("concurrency = %v, want %v", got, want)
}
// Counting starts instead would read 1,1,1,0 — a different shape
// under the same number, which is the reason this counts overlap.
}
// Events outside the window are dropped, not piled into an end bin.
func TestCountBinsKeepsTheWindowHonest(t *testing.T) {
now := t0.Add(4 * time.Second)
ats := []time.Time{
t0.Add(-time.Hour), // long before: dropped
t0.Add(100 * time.Millisecond), // bin 0
t0.Add(200 * time.Millisecond), // bin 0
t0.Add(3500 * time.Millisecond), // bin 3
now, // the instant now: last bin
}
got := CountBins(ats, now, 4*time.Second, 4)
if want := []int{2, 0, 0, 2}; !reflect.DeepEqual(got, want) {
t.Fatalf("counts = %v, want %v", got, want)
}
}
// A degenerate ask draws nothing rather than dividing by zero.
func TestSeriesRefuseDegenerateWindows(t *testing.T) {
if got := ConcurrencyBins(nil, t0, 0, 4); got != nil {
t.Errorf("zero window = %v, want nil", got)
}
if got := CountBins(nil, t0, time.Minute, 0); got != nil {
t.Errorf("zero bins = %v, want nil", got)
}
}
// The panel's question is "what asked for these", and the answer has
// two namespaces: a background activity names itself, a request-driven
// call is named by the route it served.
func TestAttributionNamesBackgroundAndRoutes(t *testing.T) {
subs := []Subprocess{
{Verb: "log", Repo: "pkgsrc", Reason: ReasonWarm},
{Verb: "log", Repo: "pkgsrc", Reason: ReasonWarm},
{Verb: "log", Repo: "tcl", RenderID: "r1"},
{Verb: "cat", Repo: "tcl", RenderID: "r1"},
{Verb: "cat", Repo: "tcl", RenderID: "r2"},
{Verb: "cat", Repo: "sqlite", Reason: ReasonIngest},
// A render that has fallen out of the ring: counted nowhere
// rather than attributed to a route nobody can name.
{Verb: "cat", Repo: "sqlite", RenderID: "gone"},
}
routes := map[string]string{"r1": "GET /r/{repo}/file", "r2": "GET /r/{repo}/file"}
got := AttributeVerbs(subs, func(id string) string { return routes[id] }, 2)
if len(got) != 2 || got[0].Verb != "cat" || got[1].Verb != "log" {
t.Fatalf("verbs = %+v, want cat then log", got)
}
log := got[1]
if len(log.Repos) != 2 || log.Repos[0] != (Tally{"pkgsrc", 2}) || log.Repos[1] != (Tally{"tcl", 1}) {
t.Errorf("log repos = %+v", log.Repos)
}
// The warm pass is named, and it outranks the one render.
if len(log.Causes) != 2 || log.Causes[0] != (Tally{ReasonWarm, 2}) {
t.Errorf("log causes = %+v, want warm first", log.Causes)
}
cat := got[0]
if len(cat.Causes) != 2 || cat.Causes[0] != (Tally{"GET /r/{repo}/file", 2}) {
t.Errorf("cat causes = %+v, want the route first", cat.Causes)
}
// Seven calls in, but only six attributable: the unresolvable
// render adds to no cause.
total := 0
for _, c := range cat.Causes {
total += c.Count
}
if total != 3 {
t.Errorf("cat causes total = %d, want 3 (the unnamed render is not guessed)", total)
}
}
// Ties break by name so two refreshes that saw the same counts draw the
// same order.
func TestAttributionIsStableOnTies(t *testing.T) {
subs := []Subprocess{
{Verb: "ls", Repo: "beta", Reason: ReasonSync},
{Verb: "ls", Repo: "alpha", Reason: ReasonIngest},
}
for i := 0; i < 5; i++ {
got := AttributeVerbs(subs, nil, 4)
if got[0].Repos[0].Name != "alpha" || got[0].Causes[0].Name != ReasonIngest {
t.Fatalf("unstable order: %+v", got[0])
}
}
}