diff --git a/internal/config/types.go b/internal/config/types.go index a34e03b98..60d11945b 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" + "math" "strings" "time" @@ -276,16 +277,22 @@ var PresetRampSchedules = map[string]RampSchedule{ }, } +// CumulativePercent is the share of the base (0..100) a ramp has bought once +// step steps are complete: step*PercentPerStep capped at 100, and 100 for an +// immediate schedule. It is the one definition of cumulative ramp progress. +func (r *RampSchedule) CumulativePercent(step int) float64 { + if r.Type == "immediate" { + return 100 + } + return math.Min(100, float64(step)*r.PercentPerStep) +} + // GetCurrentCoverage calculates the current effective coverage based on ramp progress. func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64 { if r.Type == "immediate" { - return baseCoverage - } - completedPercent := float64(r.CurrentStep) * r.PercentPerStep - if completedPercent > 100 { - completedPercent = 100 + return baseCoverage // base*100/100 is not exact in floating point } - return baseCoverage * completedPercent / 100 + return baseCoverage * r.CumulativePercent(r.CurrentStep) / 100 } // GetNextPurchaseDate calculates when the next purchase step should occur. diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 19bdd90c4..f85ba30a0 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -1,6 +1,7 @@ package config import ( + "math" "testing" "time" @@ -93,6 +94,46 @@ func TestRampSchedule_GetCurrentCoverage(t *testing.T) { } } +func TestRampSchedule_CumulativePercent(t *testing.T) { + weekly := RampSchedule{Type: "weekly", PercentPerStep: 25, TotalSteps: 4} + custom := RampSchedule{Type: "custom", PercentPerStep: 40, TotalSteps: 3} + tests := []struct { + name string + schedule RampSchedule + step int + expected float64 + }{ + {"immediate is always 100", RampSchedule{Type: "immediate"}, 0, 100}, + {"weekly step 0", weekly, 0, 0}, + {"weekly step 1", weekly, 1, 25}, + {"weekly step 4", weekly, 4, 100}, + {"custom 3x40 step 2", custom, 2, 80}, + {"custom 3x40 step 3 clamps to 100", custom, 3, 100}, + {"step beyond the ramp stays at 100", weekly, 9, 100}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.schedule.CumulativePercent(tt.step)) + }) + } +} + +// GetCurrentCoverage must stay the base scaled by CumulativePercent at the +// schedule's own CurrentStep, so the two cannot drift. +func TestRampSchedule_GetCurrentCoverageUsesCumulativePercent(t *testing.T) { + s := RampSchedule{Type: "custom", PercentPerStep: 40, TotalSteps: 3, CurrentStep: 2} + assert.Equal(t, 80*s.CumulativePercent(2)/100, s.GetCurrentCoverage(80)) +} + +// An immediate schedule returns the base untouched: base*100/100 would turn +// 0.1694 into 0.16939999999999997 and math.MaxFloat64 into +Inf. +func TestRampSchedule_GetCurrentCoverageImmediateIsExact(t *testing.T) { + s := RampSchedule{Type: "immediate"} + for _, base := range []float64{0.1694, 80, math.MaxFloat64} { + assert.Equal(t, base, s.GetCurrentCoverage(base)) + } +} + func TestRampSchedule_GetNextPurchaseDate(t *testing.T) { now := time.Now() startDate := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)