Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}