1package accrual23import (4 "testing"56 "gno.land/p/nt/uassert/v0"7)89func 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 r14}1516func TestNewValidation(t *testing.T) {17 cases := []struct {18 name string19 amount, period, capacity int6420 want error21 }{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 continue35 }36 uassert.ErrorIs(t, err, tc.want, tc.name)37 }38}3940func TestAdvance(t *testing.T) {41 cases := []struct {42 name string43 amount, period, capacity int6444 stock, anchor, now int6445 wantStock, wantAnchor int6446 }{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}6667// TestAnchorKeepsTheRemainder pins the one decision the whole package rests68// on: the new anchor is not now. Advancing to a time halfway through a period69// 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}7677// 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, because82// an implementation that re-anchors to now passes every round span and only83// fails on the ones that land mid-period. The generator is a fixed LCG, so a84// failure is reproducible.85func TestSplitInvariant(t *testing.T) {86 rates := []Rate{87 mustNew(t, 1, 60, 0), // one a minute, unbounded88 mustNew(t, 7, 13, 0), // coprime, so remainders never line up89 mustNew(t, 1, 60, 500), // capped90 mustNew(t, 250, 3600, 0), // coarse period91 mustNew(t, 1, 1, 0), // degenerate: every tick pays92 mustNew(t, 3, 1000, 1234), // capped and coarse93 }9495 seed := uint64(0x9E3779B97F4A7C15)96 next := func(n int64) int64 {97 seed = seed*6364136223846793005 + 144269504088896340798 return int64(seed>>33) % n99 }100101 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 + span107108 wantStock, wantAnchor, err := r.Advance(stock, start, end)109 uassert.NoError(t, err)110111 // Cut the span into between one and six pieces and walk them.112 cuts := next(6) + 1113 gotStock, gotAnchor := stock, start114 at := start115 for c := int64(0); c < cuts; c++ {116 remaining := end - at117 if remaining <= 0 {118 break119 }120 step := next(remaining + 1)121 if c == cuts-1 {122 step = remaining123 }124 at += step125 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)131132 uassert.Equal(t, wantStock, gotStock, "split stock diverged")133 uassert.Equal(t, wantAnchor, gotAnchor, "split anchor diverged")134 }135 }136}137138// TestClaimingEveryTickIsNotFree is the regression for the bug this package139// exists to make unrepresentable. r/moul/x/daily/tamagotchi truncated its140// per-call decay, so acting every tick cost nothing and acting rarely cost141// everything. Here the two agree exactly.142func TestClaimingEveryTickIsNotFree(t *testing.T) {143 r := mustNew(t, 1, 60, 0)144 const span = 6000 // 100 whole periods145146 oneShot, _, err := r.Advance(0, 0, span)147 uassert.NoError(t, err)148149 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}160161// TestAtMatchesAdvance pins that the read path cannot drift from the write162// 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}173174func TestAdvanceRejects(t *testing.T) {175 r := mustNew(t, 1, 60, 0)176177 _, _, err := r.Advance(-1, 0, 100)178 uassert.ErrorIs(t, err, ErrNegativeStock)179180 _, _, err = r.Advance(0, 100, 99)181 uassert.ErrorIs(t, err, ErrBackwards)182183 // 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}191192func TestAdvanceOverflow(t *testing.T) {193 // An uncapped rate that would pass int64 refuses rather than wrapping194 // into a negative stock, which is how a large vesting grant once195 // 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)199200 // The same rate with a cap always has an answer, so it gives one201 // 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")207208 got, _, err = capped.Advance(999, 0, 1)209 uassert.NoError(t, err)210 uassert.Equal(t, int64(1000), got, "the sum guard saturates too")211212 // 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}217218func TestFull(t *testing.T) {219 cases := []struct {220 name string221 amount, period, capacity int64222 stock, anchor int64223 want int64224 }{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}240241// TestFullAgreesWithAdvance checks the two halves of the API against each242// other: at the time Full names, the stock is at the cap, and one period243// 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)253254 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")257258 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}265266func TestFullRejects(t *testing.T) {267 r := mustNew(t, 1, 60, 10)268 _, err := r.Full(-1, 0)269 uassert.ErrorIs(t, err, ErrNegativeStock)270271 _, err = Rate{}.Full(0, 0)272 uassert.ErrorIs(t, err, ErrBadAmount)273274 far := mustNew(t, 1, maxInt64/2, 1000)275 _, err = far.Full(0, 0)276 uassert.ErrorIs(t, err, ErrOverflow)277}278Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.