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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}