accrual_test.gno
8.89 Kb · 277 lines
1package accrual
2
3import (
4 "testing"
5
6 "gno.land/p/nt/uassert/v0"
7)
8
9func mustNew(t *testing.T, amount, period, capacity int64) Rate {
10 t.Helper()
11 r, err := New(amount, period, capacity)
12 uassert.NoError(t, err)
13 return r
14}
15
16func TestNewValidation(t *testing.T) {
17 cases := []struct {
18 name string
19 amount, period, capacity int64
20 want error
21 }{
22 {"zero amount", 0, 60, 100, ErrBadAmount},
23 {"negative amount", -1, 60, 100, ErrBadAmount},
24 {"zero period", 1, 0, 100, ErrBadPeriod},
25 {"negative period", 1, -60, 100, ErrBadPeriod},
26 {"negative cap", 1, 60, -1, ErrBadCap},
27 {"zero cap is unbounded", 1, 60, 0, nil},
28 {"all positive", 5, 60, 100, nil},
29 }
30 for _, tc := range cases {
31 _, err := New(tc.amount, tc.period, tc.capacity)
32 if tc.want == nil {
33 uassert.NoError(t, err, tc.name)
34 continue
35 }
36 uassert.ErrorIs(t, err, tc.want, tc.name)
37 }
38}
39
40func TestAdvance(t *testing.T) {
41 cases := []struct {
42 name string
43 amount, period, capacity int64
44 stock, anchor, now int64
45 wantStock, wantAnchor int64
46 }{
47 {"nothing elapsed", 1, 60, 0, 0, 1000, 1000, 0, 1000},
48 {"less than one period", 1, 60, 0, 0, 1000, 1059, 0, 1000},
49 {"exactly one period", 1, 60, 0, 0, 1000, 1060, 1, 1060},
50 {"one period and change keeps the change", 1, 60, 0, 0, 1000, 1119, 1, 1060},
51 {"ten periods", 3, 60, 0, 0, 1000, 1600, 30, 1600},
52 {"adds to what was held", 3, 60, 0, 7, 1000, 1600, 37, 1600},
53 {"saturates at the cap", 1, 60, 10, 0, 1000, 9000, 10, 8980},
54 {"already full produces nothing", 1, 60, 10, 10, 1000, 9000, 10, 8980},
55 {"over the cap is left alone", 1, 60, 10, 25, 1000, 9000, 25, 8980},
56 {"uncapped keeps going", 1, 60, 0, 0, 0, 604800, 10080, 604800},
57 }
58 for _, tc := range cases {
59 r := mustNew(t, tc.amount, tc.period, tc.capacity)
60 gotStock, gotAnchor, err := r.Advance(tc.stock, tc.anchor, tc.now)
61 uassert.NoError(t, err, tc.name)
62 uassert.Equal(t, tc.wantStock, gotStock, tc.name+": stock")
63 uassert.Equal(t, tc.wantAnchor, gotAnchor, tc.name+": anchor")
64 }
65}
66
67// TestAnchorKeepsTheRemainder pins the one decision the whole package rests
68// on: the new anchor is not now. Advancing to a time halfway through a period
69// leaves that half on the clock, which is what makes splitting free.
70func TestAnchorKeepsTheRemainder(t *testing.T) {
71 r := mustNew(t, 1, 60, 0)
72 _, anchor, err := r.Advance(0, 0, 599)
73 uassert.NoError(t, err)
74 uassert.Equal(t, int64(540), anchor, "anchor must stop at the last whole period, not at now")
75}
76
77// TestSplitInvariant is the contract of this package:
78//
79// Advance(s, a, c) == Advance(Advance(s, a, b), b, c)
80//
81// It runs over pseudo-random partitions rather than hand-picked ones, because
82// an implementation that re-anchors to now passes every round span and only
83// fails on the ones that land mid-period. The generator is a fixed LCG, so a
84// failure is reproducible.
85func TestSplitInvariant(t *testing.T) {
86 rates := []Rate{
87 mustNew(t, 1, 60, 0), // one a minute, unbounded
88 mustNew(t, 7, 13, 0), // coprime, so remainders never line up
89 mustNew(t, 1, 60, 500), // capped
90 mustNew(t, 250, 3600, 0), // coarse period
91 mustNew(t, 1, 1, 0), // degenerate: every tick pays
92 mustNew(t, 3, 1000, 1234), // capped and coarse
93 }
94
95 seed := uint64(0x9E3779B97F4A7C15)
96 next := func(n int64) int64 {
97 seed = seed*6364136223846793005 + 1442695040888963407
98 return int64(seed>>33) % n
99 }
100
101 for _, r := range rates {
102 for i := 0; i < 400; i++ {
103 start := next(10000)
104 span := next(100000)
105 stock := next(600)
106 end := start + span
107
108 wantStock, wantAnchor, err := r.Advance(stock, start, end)
109 uassert.NoError(t, err)
110
111 // Cut the span into between one and six pieces and walk them.
112 cuts := next(6) + 1
113 gotStock, gotAnchor := stock, start
114 at := start
115 for c := int64(0); c < cuts; c++ {
116 remaining := end - at
117 if remaining <= 0 {
118 break
119 }
120 step := next(remaining + 1)
121 if c == cuts-1 {
122 step = remaining
123 }
124 at += step
125 gotStock, gotAnchor, err = r.Advance(gotStock, gotAnchor, at)
126 uassert.NoError(t, err)
127 }
128 // Land on the end whatever the cuts did.
129 gotStock, gotAnchor, err = r.Advance(gotStock, gotAnchor, end)
130 uassert.NoError(t, err)
131
132 uassert.Equal(t, wantStock, gotStock, "split stock diverged")
133 uassert.Equal(t, wantAnchor, gotAnchor, "split anchor diverged")
134 }
135 }
136}
137
138// TestClaimingEveryTickIsNotFree is the regression for the bug this package
139// exists to make unrepresentable. r/moul/x/daily/tamagotchi truncated its
140// per-call decay, so acting every tick cost nothing and acting rarely cost
141// everything. Here the two agree exactly.
142func TestClaimingEveryTickIsNotFree(t *testing.T) {
143 r := mustNew(t, 1, 60, 0)
144 const span = 6000 // 100 whole periods
145
146 oneShot, _, err := r.Advance(0, 0, span)
147 uassert.NoError(t, err)
148
149 for _, cadence := range []int64{1, 2, 7, 60, 61, 599, 3000} {
150 stock, anchor := int64(0), int64(0)
151 for at := cadence; at <= span; at += cadence {
152 stock, anchor, err = r.Advance(stock, anchor, at)
153 uassert.NoError(t, err)
154 }
155 stock, _, err = r.Advance(stock, anchor, span)
156 uassert.NoError(t, err)
157 uassert.Equal(t, oneShot, stock, "cadence changed the payout")
158 }
159}
160
161// TestAtMatchesAdvance pins that the read path cannot drift from the write
162// path, which was the second and quieter half of the tamagotchi bug.
163func TestAtMatchesAdvance(t *testing.T) {
164 r := mustNew(t, 5, 17, 900)
165 for now := int64(3); now < 4000; now += 37 {
166 want, _, err := r.Advance(11, 3, now)
167 uassert.NoError(t, err)
168 got, err := r.At(11, 3, now)
169 uassert.NoError(t, err)
170 uassert.Equal(t, want, got, "At diverged from Advance")
171 }
172}
173
174func TestAdvanceRejects(t *testing.T) {
175 r := mustNew(t, 1, 60, 0)
176
177 _, _, err := r.Advance(-1, 0, 100)
178 uassert.ErrorIs(t, err, ErrNegativeStock)
179
180 _, _, err = r.Advance(0, 100, 99)
181 uassert.ErrorIs(t, err, ErrBackwards)
182
183 // A Rate built by literal instead of New is still checked.
184 _, _, err = Rate{}.Advance(0, 0, 100)
185 uassert.ErrorIs(t, err, ErrBadAmount)
186 _, _, err = Rate{Amount: 1}.Advance(0, 0, 100)
187 uassert.ErrorIs(t, err, ErrBadPeriod)
188 _, _, err = Rate{Amount: 1, Period: 1, Cap: -1}.Advance(0, 0, 100)
189 uassert.ErrorIs(t, err, ErrBadCap)
190}
191
192func TestAdvanceOverflow(t *testing.T) {
193 // An uncapped rate that would pass int64 refuses rather than wrapping
194 // into a negative stock, which is how a large vesting grant once
195 // reported a negative balance.
196 r := mustNew(t, maxInt64/2, 1, 0)
197 _, _, err := r.Advance(0, 0, 100)
198 uassert.ErrorIs(t, err, ErrOverflow)
199
200 // The same rate with a cap always has an answer, so it gives one
201 // instead of an error, whichever of the two guards it trips.
202 capped := mustNew(t, maxInt64/2, 1, 1000)
203 got, anchor, err := capped.Advance(0, 0, 100)
204 uassert.NoError(t, err)
205 uassert.Equal(t, int64(1000), got)
206 uassert.Equal(t, int64(100), anchor, "a saturated advance still moves the anchor")
207
208 got, _, err = capped.Advance(999, 0, 1)
209 uassert.NoError(t, err)
210 uassert.Equal(t, int64(1000), got, "the sum guard saturates too")
211
212 // A negative anchor far enough below now wraps the elapsed span itself.
213 slow := mustNew(t, 1, maxInt64, 0)
214 _, _, err = slow.Advance(0, -10, maxInt64-5)
215 uassert.ErrorIs(t, err, ErrOverflow)
216}
217
218func TestFull(t *testing.T) {
219 cases := []struct {
220 name string
221 amount, period, capacity int64
222 stock, anchor int64
223 want int64
224 }{
225 {"uncapped never fills", 1, 60, 0, 0, 1000, 0},
226 {"already full is now", 1, 60, 10, 10, 1000, 1000},
227 {"past full is now", 1, 60, 10, 99, 1000, 1000},
228 {"empty takes the whole cap", 1, 60, 10, 0, 1000, 1600},
229 {"partly full takes the rest", 1, 60, 10, 4, 1000, 1360},
230 {"rounds up to the payout that crosses", 3, 60, 10, 0, 0, 240},
231 {"exact division does not round up", 5, 60, 10, 0, 0, 120},
232 }
233 for _, tc := range cases {
234 r := mustNew(t, tc.amount, tc.period, tc.capacity)
235 got, err := r.Full(tc.stock, tc.anchor)
236 uassert.NoError(t, err, tc.name)
237 uassert.Equal(t, tc.want, got, tc.name)
238 }
239}
240
241// TestFullAgreesWithAdvance checks the two halves of the API against each
242// other: at the time Full names, the stock is at the cap, and one period
243// earlier it is not.
244func TestFullAgreesWithAdvance(t *testing.T) {
245 for _, r := range []Rate{
246 mustNew(t, 1, 60, 10),
247 mustNew(t, 3, 60, 10),
248 mustNew(t, 7, 13, 1000),
249 mustNew(t, 250, 3600, 875),
250 } {
251 full, err := r.Full(0, 0)
252 uassert.NoError(t, err)
253
254 atFull, err := r.At(0, 0, full)
255 uassert.NoError(t, err)
256 uassert.Equal(t, r.Cap, atFull, "not full at the time Full named")
257
258 before, err := r.At(0, 0, full-r.Period)
259 uassert.NoError(t, err)
260 if before >= r.Cap {
261 t.Errorf("already full a period early: %d >= %d", before, r.Cap)
262 }
263 }
264}
265
266func TestFullRejects(t *testing.T) {
267 r := mustNew(t, 1, 60, 10)
268 _, err := r.Full(-1, 0)
269 uassert.ErrorIs(t, err, ErrNegativeStock)
270
271 _, err = Rate{}.Full(0, 0)
272 uassert.ErrorIs(t, err, ErrBadAmount)
273
274 far := mustNew(t, 1, maxInt64/2, 1000)
275 _, err = far.Full(0, 0)
276 uassert.ErrorIs(t, err, ErrOverflow)
277}