tamagotchi_test.gno
6.31 Kb · 233 lines
1package tamagotchi
2
3import (
4 "strings"
5 "testing"
6
7 "gno.land/p/nt/avl/v0"
8 "gno.land/p/nt/testutils/v0"
9)
10
11func resetState() {
12 pets = avl.Tree{}
13}
14
15func TestClamp(t *testing.T) {
16 if clamp(150, 0, 100) != 100 {
17 t.Fatal("clamp should cap at hi")
18 }
19 if clamp(-5, 0, 100) != 0 {
20 t.Fatal("clamp should floor at lo")
21 }
22 if clamp(50, 0, 100) != 50 {
23 t.Fatal("clamp should pass through in-range values")
24 }
25}
26
27func TestHatchCreatesLivingPet(t *testing.T) {
28 resetState()
29 alice := testutils.TestAddress("alice")
30
31 hatch(alice, "Fluffy", 100)
32
33 p, ok := get(alice)
34 if !ok || !p.alive {
35 t.Fatal("expected a living pet after hatch")
36 }
37 if p.name != "Fluffy" {
38 t.Fatalf("expected name Fluffy, got %s", p.name)
39 }
40}
41
42func TestHatchTwiceWhileAlivePanics(t *testing.T) {
43 resetState()
44 alice := testutils.TestAddress("alice")
45 hatch(alice, "Fluffy", 100)
46
47 defer func() {
48 if r := recover(); r == nil {
49 t.Fatal("expected panic hatching a second living pet")
50 }
51 }()
52 hatch(alice, "Again", 101)
53}
54
55func TestHatchAfterDeathKeepsLifetimeStats(t *testing.T) {
56 resetState()
57 alice := testutils.TestAddress("alice")
58 hatch(alice, "Fluffy", 0)
59 feed(alice, 0)
60
61 p, _ := get(alice)
62 p.alive = false
63 p.deaths = 1
64
65 hatch(alice, "Fluffy2", 10)
66
67 p, _ = get(alice)
68 if p.feeds != 1 || p.deaths != 1 {
69 t.Fatalf("expected lifetime stats to carry over, got feeds=%d deaths=%d", p.feeds, p.deaths)
70 }
71 if !p.alive {
72 t.Fatal("expected the new pet to be alive")
73 }
74}
75
76func TestFeedReducesHunger(t *testing.T) {
77 resetState()
78 alice := testutils.TestAddress("alice")
79 hatch(alice, "Fluffy", 100)
80
81 p, _ := get(alice)
82 p.hunger = 60
83
84 feed(alice, 100)
85 if p.hunger != 30 {
86 t.Fatalf("expected hunger 30 after feed, got %d", p.hunger)
87 }
88 if p.feeds != 1 {
89 t.Fatalf("expected feeds counter to increment, got %d", p.feeds)
90 }
91}
92
93func TestPlayRefusesWhenTooTired(t *testing.T) {
94 resetState()
95 alice := testutils.TestAddress("alice")
96 hatch(alice, "Fluffy", 100)
97
98 p, _ := get(alice)
99 p.energy = 5
100
101 out := play(alice, 100)
102 if !strings.Contains(out, "too tired") {
103 t.Fatalf("expected too-tired message, got %q", out)
104 }
105 if p.plays != 0 {
106 t.Fatal("play should not have counted while too tired")
107 }
108}
109
110func TestNeglectKillsPet(t *testing.T) {
111 resetState()
112 alice := testutils.TestAddress("alice")
113 hatch(alice, "Fluffy", 0)
114
115 defer func() {
116 if r := recover(); r == nil {
117 t.Fatal("expected panic acting on a neglected/dead pet")
118 }
119 }()
120 // Feed re-ticks against a much later height, starving the pet to death.
121 feed(alice, 1000)
122}
123
124func TestFeedWithoutPetPanics(t *testing.T) {
125 resetState()
126 bob := testutils.TestAddress("bob")
127
128 defer func() {
129 if r := recover(); r == nil {
130 t.Fatal("expected panic feeding without a pet")
131 }
132 }()
133 feed(bob, 100)
134}
135
136func TestRenderShowsEmptyThenPet(t *testing.T) {
137 resetState()
138 if !strings.Contains(Render(""), "No pets hatched yet") {
139 t.Fatal("expected empty-state placeholder")
140 }
141
142 alice := testutils.TestAddress("alice")
143 hatch(alice, "Fluffy", 100)
144
145 out := Render("")
146 if !strings.Contains(out, "Fluffy") {
147 t.Fatal("expected pet name in leaderboard render")
148 }
149
150 detail := Render(alice.String())
151 if !strings.Contains(detail, "Hunger") {
152 t.Fatal("expected detail card to show hunger stat")
153 }
154}
155
156// TestHatchViaCrossingCall smoke-tests the exported, realm-crossing entry
157// point end to end (as an on-chain caller would invoke it).
158func TestHatchViaCrossingCall(cur realm, t *testing.T) {
159 resetState()
160 alice := testutils.TestAddress("alice")
161 testing.SetRealm(testing.NewUserRealm(alice))
162
163 msg := Hatch(cross(cur), "Fluffy")
164 if !strings.Contains(msg, "hatched") {
165 t.Fatalf("expected hatch confirmation, got %q", msg)
166 }
167 if _, ok := get(alice); !ok {
168 t.Fatal("expected pet to be persisted for caller")
169 }
170}
171
172// decayOver ticks one pet from full stats across `blocks` blocks, calling tick
173// every `every` blocks, and returns what it is left with. It drives tick
174// directly rather than through Feed/Play/Sleep, because those also restore
175// stats and the question here is only what decay does.
176func decayOver(blocks, every int) (hunger, happiness, energy int) {
177 p := &pet{alive: true, hunger: 0, happiness: 100, energy: 100, lastUpdate: 0}
178 for h := every; h <= blocks; h += every {
179 p.tick(int64(h))
180 }
181 return p.hunger, p.happiness, p.energy
182}
183
184// TestDecayDoesNotDependOnCallFrequency is the bug this file exists for.
185//
186// happiness decays once per 2 blocks and energy once per 3, by integer
187// division. Before the carried debt, that division truncated on EVERY call, so
188// ticking once per block cost 1/2 = 0 happiness and 1/3 = 0 energy, forever: a
189// pet fed in a `while true` loop was immortal while everyone else's died. The
190// leaderboard rewarded a bot, which is the opposite of what the realm claims.
191//
192// Over the same 60 blocks the outcome must now be the same whether the owner
193// acts every block or once at the end.
194func TestDecayDoesNotDependOnCallFrequency(t *testing.T) {
195 const blocks = 60
196 wantHunger, wantHappy, wantEnergy := decayOver(blocks, blocks)
197
198 for _, every := range []int{1, 2, 3, 5, 30} {
199 h, hap, e := decayOver(blocks, every)
200 if h != wantHunger || hap != wantHappy || e != wantEnergy {
201 t.Errorf("acting every %d block(s) over %d blocks: hunger=%d happiness=%d energy=%d, "+
202 "want %d/%d/%d as when acting once",
203 every, blocks, h, hap, e, wantHunger, wantHappy, wantEnergy)
204 }
205 }
206}
207
208// The absolute numbers, so a future change to the rates is a deliberate edit
209// and not a silent one: 60 blocks costs 60 hunger, 30 happiness, 20 energy.
210func TestDecayRates(t *testing.T) {
211 h, hap, e := decayOver(60, 1)
212 if h != 60 || hap != 70 || e != 80 {
213 t.Errorf("60 blocks of decay = hunger %d, happiness %d, energy %d; want 60, 70, 80", h, hap, e)
214 }
215}
216
217// project draws the page; tick decides the outcome. If they disagree, the pet a
218// reader sees is not the pet the next call will act on.
219func TestProjectAgreesWithTick(t *testing.T) {
220 for _, every := range []int{1, 2, 3, 7} {
221 p := &pet{alive: true, hunger: 0, happiness: 100, energy: 100, lastUpdate: 0}
222 for h := every; h <= 42; h += every {
223 p.tick(int64(h))
224 }
225 // One more step, projected rather than applied.
226 ph, phap, pe, _ := project(p, int64(42+every))
227 p.tick(int64(42 + every))
228 if ph != p.hunger || phap != p.happiness || pe != p.energy {
229 t.Errorf("every %d: project says %d/%d/%d, tick says %d/%d/%d",
230 every, ph, phap, pe, p.hunger, p.happiness, p.energy)
231 }
232 }
233}