package obs
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func restored(t *testing.T, path string, at time.Time) *signalRing {
t.Helper()
var s signalRing
s.restore(path, at)
return &s
}
// The hour is kept across a restart: what the first process counted is
// there for the second, the minutes between the two are a gap and not
// zeros, and both starts are on the record.
func TestTheHourSurvivesARestartWithTheDownTimeAsAGap(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops-hour.json")
first := restored(t, path, t0)
first.render(t0, true)
first.render(t0.Add(time.Minute), false)
first.running(t0.Add(time.Minute), 4)
first.save(path, t0.Add(time.Minute+30*time.Second))
// Down for three minutes: it comes back at t0+5m.
back := t0.Add(5*time.Minute + 10*time.Second)
second := restored(t, path, back)
got := second.snapshot(back)
if want := back.Truncate(time.Minute).UTC().Format(time.RFC3339); got.End != want {
t.Fatalf("FAIL: end %s, want %s", got.End, want)
}
n := SignalMinutes
// Slots, newest last: t0+5 (now), t0+4, t0+3, t0+2 (down), t0+1, t0.
if want := []int{1, 1, 0, 0, 0, 0}; !reflect.DeepEqual(got.Renders[n-6:], want) {
t.Errorf("FAIL: renders %v, want %v", got.Renders[n-6:], want)
}
if want := []int{0, 4, 0, 0, 0, 0}; !reflect.DeepEqual(got.Busy[n-6:], want) {
t.Errorf("FAIL: busy %v, want %v", got.Busy[n-6:], want)
}
if want := []bool{true, true, false, false, false, true}; !reflect.DeepEqual(got.Up[n-6:], want) {
t.Errorf("FAIL: up %v, want %v", got.Up[n-6:], want)
}
if want := []string{t0.Format(time.RFC3339), back.UTC().Format(time.RFC3339)}; !reflect.DeepEqual(got.Starts, want) {
t.Errorf("FAIL: starts %v, want %v", got.Starts, want)
}
if !t.Failed() {
t.Log("ok")
}
}
// A file that is missing, unreadable, from another version or the wrong
// size is an empty hour and a start, never an error.
func TestABadStateFileIsAnEmptyHour(t *testing.T) {
dir := t.TempDir()
cases := map[string]func(p string){
"missing": func(string) {},
"garbage": func(p string) { os.WriteFile(p, []byte("not json {"), 0o644) },
"empty": func(p string) { os.WriteFile(p, nil, 0o644) },
"version": func(p string) { os.WriteFile(p, []byte(`{"v":99}`), 0o644) },
"shortbin": func(p string) {
os.WriteFile(p, []byte(`{"v":1,"end":"2026-09-16T12:00:00Z","renders":[1],"slow":[1],"busy":[1],"up":[true]}`), 0o644)
},
"negative": func(p string) {
bad := `{"v":1,"end":"2026-09-16T12:00:00Z","renders":[` + zeros(SignalMinutes, "-1") + `],"slow":[` + zeros(SignalMinutes, "0") + `],"busy":[` + zeros(SignalMinutes, "0") + `],"up":[` + zeros(SignalMinutes, "true") + `]}`
os.WriteFile(p, []byte(bad), 0o644)
},
"directory": func(p string) { os.Mkdir(p, 0o755) },
}
for name, setup := range cases {
p := filepath.Join(dir, name)
setup(p)
got := restored(t, p, t0).snapshot(t0)
if sumInts(got.Renders) != 0 || sumInts(got.Busy) != 0 {
t.Errorf("FAIL: %s: counts survived a bad file: %v", name, got.Renders)
}
for i, up := range got.Up[:SignalMinutes-1] {
if up {
t.Errorf("FAIL: %s: minute %d is live in an empty hour", name, i)
}
}
if len(got.Starts) != 1 {
t.Errorf("FAIL: %s: starts %v, want just this one", name, got.Starts)
}
}
if !t.Failed() {
t.Log("ok")
}
}
func zeros(n int, v string) string {
s := v
for i := 1; i < n; i++ {
s += "," + v
}
return s
}
// Anything older than the hour is dropped on load: counts, and starts.
func TestABucketOlderThanAnHourIsDropped(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops-hour.json")
first := restored(t, path, t0)
first.render(t0, false)
first.save(path, t0)
later := t0.Add(61 * time.Minute)
got := restored(t, path, later).snapshot(later)
if sumInts(got.Renders) != 0 {
t.Errorf("FAIL: a count from over an hour ago survived: %v", got.Renders)
}
if len(got.Starts) != 1 || got.Starts[0] != later.UTC().Format(time.RFC3339) {
t.Errorf("FAIL: starts %v, want only the one inside the hour", got.Starts)
}
for i, up := range got.Up[:SignalMinutes-1] {
if up {
t.Errorf("FAIL: minute %d is live though the forge was down for all of it", i)
}
}
if !t.Failed() {
t.Log("ok")
}
}
// Saving replaces the file whole and leaves nothing else beside it.
func TestSavingLeavesOneFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ops-hour.json")
s := restored(t, path, t0)
s.save(path, t0)
s.save(path, t0.Add(time.Minute))
ents, _ := os.ReadDir(dir)
if len(ents) != 1 || ents[0].Name() != "ops-hour.json" {
t.Errorf("FAIL: the directory holds %v", ents)
}
if b, _ := os.ReadFile(path); len(b) > 4096 {
t.Errorf("FAIL: the state file is %d bytes, which is more than an hour of counts needs", len(b))
}
if !t.Failed() {
t.Log("ok")
}
}
// A file that ends after now was written under a clock that has since
// stepped back. Loading it would hold the ring in a minute that has not
// come, so it is an empty hour, and the next save is of the real one.
func TestAFileFromTheFutureIsAnEmptyHour(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops-hour.json")
first := restored(t, path, t0.Add(2*time.Hour))
first.render(t0.Add(2*time.Hour), true)
first.save(path, t0.Add(2*time.Hour))
got := restored(t, path, t0).snapshot(t0)
if want := t0.UTC().Format(time.RFC3339); got.End != want {
t.Errorf("FAIL: end %s, want %s", got.End, want)
}
if sumInts(got.Renders) != 0 || len(got.Starts) != 1 {
t.Errorf("FAIL: the future hour survived: %v starts %v", got.Renders, got.Starts)
}
if !t.Failed() {
t.Log("ok")
}
}
// A clock that steps back while the process runs must not freeze the
// charts: the newest bin follows the clock, and counts land in it.
func TestAClockStepBackDoesNotFreezeTheHour(t *testing.T) {
var s signalRing
s.render(t0.Add(2*time.Hour), false)
back := t0.Add(time.Hour)
s.render(back, false)
s.render(back.Add(time.Minute), false)
got := s.snapshot(back.Add(time.Minute))
if want := back.Add(time.Minute).UTC().Format(time.RFC3339); got.End != want {
t.Errorf("FAIL: end %s, want %s", got.End, want)
}
if got.Renders[SignalMinutes-1] != 1 || sumInts(got.Renders) != 3 {
t.Errorf("FAIL: the renders after the step are not in the newest bins: %v", got.Renders[SignalMinutes-3:])
}
if !t.Failed() {
t.Log("ok")
}
}
// The minute's tick and the shutdown save may overlap; whichever ends
// last must leave a whole, current file.
func TestSavesDoNotCollide(t *testing.T) {
path := filepath.Join(t.TempDir(), "ops-hour.json")
s := restored(t, path, t0)
done := make(chan struct{})
for i := 0; i < 8; i++ {
go func(i int) {
s.render(t0, false)
s.save(path, t0.Add(time.Duration(i)*time.Second))
done <- struct{}{}
}(i)
}
for i := 0; i < 8; i++ {
<-done
}
f, ok := readSignalFile(path)
if !ok {
t.Fatal("FAIL: the file is not whole after overlapping saves")
}
if f.Renders[SignalMinutes-1] != 8 {
t.Errorf("FAIL: the file holds %d renders, want all 8 from the last save", f.Renders[SignalMinutes-1])
}
if !t.Failed() {
t.Log("ok")
}
}