package backoff import ( "math" "math/rand/v2" "strings" "testing" "time" ) func TestConfigValidate(t *testing.T) { var tests = []struct { name string cfg Config shouldError bool }{ { name: "initial zero", cfg: Config{Initial: 0, Max: time.Second}, shouldError: true, }, { name: "initial negative", cfg: Config{Initial: -1, Max: time.Second}, shouldError: true, }, { name: "max less than initial", cfg: Config{ Initial: time.Second, Max: 500 * time.Millisecond, }, shouldError: true, }, { name: "max equals maxint64", cfg: Config{ Initial: time.Second, Max: math.MaxInt64, }, shouldError: true, }, { name: "valid", cfg: Config{ Initial: time.Second, Max: time.Second, }, shouldError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.cfg.Validate() if tt.shouldError && err == nil { t.Fatalf("expected error, got nil") } if !tt.shouldError && err != nil { t.Fatalf("unexpected error: %v", err) } }) } } func TestNewInvalidConfig(t *testing.T) { _, err := New(Config{Initial: 0, Max: time.Second}) if err == nil { t.Fatalf("expected error, got nil") } if !strings.Contains(err.Error(), "invalid config") { t.Fatalf("expected invalid config error, got %v", err) } } // TestDistribution uses a fixed seed to check the logic flow and jitter. func TestDistribution(t *testing.T) { // RNG with seed (1, 2) rng := rand.New(rand.NewPCG(1, 2)) initial := 100 * time.Millisecond maxDur := 400 * time.Millisecond b, _ := New(Config{ Initial: initial, Max: maxDur, RNG: rng, }) // Generate reference numbers using the same seed to predict outcomes. refRng := rand.New(rand.NewPCG(1, 2)) expectedCaps := []time.Duration{ 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond, 400 * time.Millisecond, // Clamped } for i, cap := range expectedCaps { // Expect Next to pick rand(0, cap). expected := time.Duration(refRng.Int64N(int64(cap) + 1)) got := b.Next() if got != expected { t.Errorf( "step %d: expected %v, got %v (cap was %v)", i, expected, got, cap, ) } if got > cap { t.Errorf( "step %d: got %v greater than cap %v", i, got, cap, ) } } } // TestOverflowProtection ensures that large Max values do not cause overflow // during the doubling phase. func TestOverflowProtection(t *testing.T) { // Use a Max that is near MaxInt64. max := time.Duration(math.MaxInt64 - 1) start := max / 4 b, err := New(Config{ Initial: start, Max: max, }) if err != nil { t.Fatalf("New failed: %v", err) } // 1. Current = start. // Next returns [0, start]. state becomes start*2 (max/2). _ = b.Next() // 2. Current = max/2. // Next returns [0, max/2]. _ = b.Next() // 3. Current = max. // Next returns [0, max]. // Logic check: current >= max. state stays max. val := b.Next() if val < 0 { t.Errorf("got negative duration %v, likely overflow", val) } // Verify clamp. // No panic implies success. for range 5 { b.Next() } } func TestDefaultRNG(t *testing.T) { b, err := New(Config{ Initial: time.Millisecond, Max: 10 * time.Millisecond, }) if err != nil { t.Fatalf("New failed: %v", err) } got := b.Next() if got < 0 || got > time.Millisecond { t.Errorf("out of bounds: %v", got) } } func TestReset(t *testing.T) { b, _ := New(Config{ Initial: 10 * time.Millisecond, Max: 100 * time.Millisecond, }) // Advance state b.Next() // 10 b.Next() // 20 b.Next() // 40 b.Reset() // Verify that the cap is reset by checking the bounds of the next // call. If reset works, the next call is bounded by Initial (10ms). for range 10 { got := b.Next() if got > 10*time.Millisecond { t.Fatalf( "call after Reset returned %v; expected <= 10ms", got, ) } b.Reset() } }