crews_test.gno
13.89 Kb · 355 lines
1package crews
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "chain"
9 "chain/banker"
10
11 "gno.land/p/moul/x/social/crew/v0"
12 "gno.land/p/nt/testutils/v0"
13 "gno.land/p/nt/uassert/v0"
14 "gno.land/p/nt/urequire/v0"
15)
16
17var realmAddr = chain.PackageAddress(realmPath)
18
19var (
20 alice = testutils.TestAddress("alice")
21 bob = testutils.TestAddress("bob")
22 carol = testutils.TestAddress("carol")
23)
24
25// tenGNOT is what every founder here puts in: 10,000 shares at the opening
26// price, so halving and doubling stay exact and the rounding tests can use
27// deliberately awkward numbers instead.
28const tenGNOT = int64(10000000)
29
30// reset clears realm state. Realm globals live for the whole test binary, so
31// everything a later test reads starts here.
32func reset() {
33 crews = crew.New()
34}
35
36// create calls Create the way a real transaction would: the runtime credits
37// the realm's address, then the envelope reports the send to the crossing
38// function. The account switch and the cross are in the same frame on purpose,
39// since testing.SetRealm governs nothing else.
40func create(cur realm, who address, amount int64, name string) int64 {
41 testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}})
42 testing.SetOriginSend(chain.Coins{{denom, amount}})
43 testing.SetRealm(testing.NewUserRealm(who))
44 return Create(cross(cur), name)
45}
46
47// join is [create] for Join.
48func join(cur realm, who address, amount, crewID int64) int64 {
49 testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}})
50 testing.SetOriginSend(chain.Coins{{denom, amount}})
51 testing.SetRealm(testing.NewUserRealm(who))
52 return Join(cross(cur), crewID)
53}
54
55// fund is [create] for Fund.
56func fund(cur realm, who address, amount, crewID int64) int64 {
57 testing.IssueCoins(realmAddr, chain.Coins{{denom, amount}})
58 testing.SetOriginSend(chain.Coins{{denom, amount}})
59 testing.SetRealm(testing.NewUserRealm(who))
60 return Fund(cross(cur), crewID)
61}
62
63func TestCreateMakesTheFounderTheOnlyMember(cur realm, t *testing.T) {
64 reset()
65 id := create(cur, alice, tenGNOT, "validators")
66
67 name, at, members, shares, treasury := Get(id)
68 uassert.Equal(t, "validators", name)
69 uassert.Equal(t, int64(123), at, "tests start at height 123")
70 uassert.Equal(t, 1, members)
71 uassert.Equal(t, tenGNOT/crew.InitialPricePerShare, shares)
72 uassert.Equal(t, tenGNOT, treasury)
73
74 uassert.Equal(t, shares, SharesOf(id, alice))
75 uassert.Equal(t, int64(0), SharesOf(id, bob))
76 uassert.Equal(t, crew.InitialPricePerShare, ValuePerShare(id))
77 uassert.Equal(t, 1, Count())
78 uassert.Equal(t, tenGNOT, Treasury(id))
79}
80
81// A second joiner at the same per-share value gets the same shares, which is
82// the baseline the anti-dilution rule is measured against.
83func TestASecondJoinerAtTheSamePriceGetsTheSameShares(cur realm, t *testing.T) {
84 reset()
85 id := create(cur, alice, tenGNOT, "crew")
86 got := join(cur, bob, tenGNOT, id)
87
88 uassert.Equal(t, SharesOf(id, alice), got, "same money, same shares")
89 uassert.Equal(t, crew.InitialPricePerShare, ValuePerShare(id), "the price did not move")
90 uassert.Equal(t, 2, MemberCount(id))
91 uassert.Equal(t, 2*tenGNOT, Treasury(id))
92}
93
94// A joiner arriving after the treasury grew gets FEWER shares for the same
95// money: they buy at what a share is worth now, not at what the founders paid.
96func TestAJoinerAfterTheTreasuryGrewGetsFewer(cur realm, t *testing.T) {
97 reset()
98 id := create(cur, alice, tenGNOT, "crew")
99 fund(cur, carol, tenGNOT, id) // the crew doubled its money
100 urequire.Equal(t, 2*crew.InitialPricePerShare, ValuePerShare(id))
101
102 got := join(cur, bob, tenGNOT, id)
103 uassert.Equal(t, SharesOf(id, alice)/2, got, "the same money buys half as much")
104 uassert.Equal(t, 2*crew.InitialPricePerShare, ValuePerShare(id),
105 "and joining moved the value for nobody")
106 uassert.Equal(t, int64(0), SharesOf(id, carol), "funding mints nothing")
107}
108
109// Ragequit credits, Withdraw pays, and the coins actually move.
110func TestRagequitCreditsAndWithdrawPays(cur realm, t *testing.T) {
111 reset()
112 id := create(cur, alice, tenGNOT, "crew")
113 join(cur, bob, tenGNOT, id)
114 fund(cur, carol, 2*tenGNOT, id) // treasury 4x, still 2x the shares
115
116 testing.SetRealm(testing.NewUserRealm(bob))
117 owed := Ragequit(cross(cur), id)
118 uassert.Equal(t, 2*tenGNOT, owed, "half the shares of a treasury worth four")
119 uassert.Equal(t, owed, CreditOf(bob), "credited, never sent")
120 uassert.Equal(t, owed, TotalOwed())
121 uassert.Equal(t, 1, MemberCount(id))
122 uassert.Equal(t, int64(0), SharesOf(id, bob))
123 uassert.Equal(t, 2*tenGNOT, Treasury(id))
124
125 before := banker.NewReadonlyBanker().GetCoins(bob).AmountOf(denom)
126
127 testing.SetRealm(testing.NewUserRealm(bob))
128 got := Withdraw(cross(cur))
129 uassert.Equal(t, owed, got)
130 uassert.Equal(t, int64(0), CreditOf(bob), "zeroed before the coins moved")
131 uassert.Equal(t, int64(0), TotalOwed())
132
133 after := banker.NewReadonlyBanker().GetCoins(bob).AmountOf(denom)
134 uassert.Equal(t, before+owed, after, "the coins actually arrived")
135
136 testing.SetRealm(testing.NewUserRealm(bob))
137 uassert.AbortsContains(t, cur, "nothing to withdraw", func() { Withdraw(cross(cur)) })
138}
139
140// Everyone ragequitting empties the treasury to the last ugnot, even when no
141// single division is exact: the dust a leaver rounds away belongs to whoever is
142// still in, and the last one out holds every share.
143func TestEveryoneRagequittingEmptiesTheTreasury(cur realm, t *testing.T) {
144 reset()
145 id := create(cur, alice, 1000, "crew")
146 join(cur, bob, 1000, id)
147 join(cur, carol, 1000, id)
148 fund(cur, alice, 2, id) // 3002 ugnot over 3 shares, divides by nothing
149
150 urequire.Equal(t, int64(3002), Treasury(id))
151
152 var paid int64
153 testing.SetRealm(testing.NewUserRealm(alice))
154 paid += Ragequit(cross(cur), id)
155 testing.SetRealm(testing.NewUserRealm(bob))
156 paid += Ragequit(cross(cur), id)
157 testing.SetRealm(testing.NewUserRealm(carol))
158 paid += Ragequit(cross(cur), id)
159
160 uassert.Equal(t, int64(3002), paid, "every ugnot left with somebody")
161 uassert.Equal(t, int64(0), Treasury(id), "to the last one")
162 uassert.Equal(t, 0, MemberCount(id))
163 uassert.Equal(t, int64(3002), TotalOwed())
164 uassert.Equal(t, int64(1000), CreditOf(alice), "first out, rounded down hardest")
165 uassert.Equal(t, int64(1001), CreditOf(carol), "last out takes the dust")
166}
167
168// A one-member crew ragequitting takes everything, including the ugnot that
169// bought no whole share: their shares ARE the total, so the division is exact.
170func TestAOneMemberCrewTakesEverything(cur realm, t *testing.T) {
171 reset()
172 id := create(cur, alice, 5001, "solo")
173
174 testing.SetRealm(testing.NewUserRealm(alice))
175 got := Ragequit(cross(cur), id)
176 uassert.Equal(t, int64(5001), got)
177 uassert.Equal(t, int64(0), Treasury(id))
178 uassert.Equal(t, 0, MemberCount(id))
179
180 // An emptied crew is not a dead one: with nothing outstanding there is
181 // no value to price against, so it reopens at the opening price.
182 uassert.Equal(t, int64(0), ValuePerShare(id))
183 got = join(cur, bob, tenGNOT, id)
184 uassert.Equal(t, tenGNOT/crew.InitialPricePerShare, got)
185}
186
187func TestProposalsAreAdvisoryAndShareWeighted(cur realm, t *testing.T) {
188 reset()
189 id := create(cur, alice, tenGNOT, "crew") // alice: 10000 shares
190 join(cur, bob, tenGNOT/2, id) // bob: 5000 shares
191
192 testing.SetRealm(testing.NewUserRealm(bob))
193 pid := Propose(cross(cur), id, "ship v1 before the conference")
194
195 _, author, text, deadline, closed, passed := ProposalOf(pid)
196 uassert.Equal(t, bob.String(), author.String())
197 uassert.Equal(t, "ship v1 before the conference", text)
198 uassert.Equal(t, int64(123+crew.VoteBlocks), deadline)
199 uassert.False(t, closed)
200 uassert.False(t, passed)
201
202 testing.SetRealm(testing.NewUserRealm(bob))
203 Vote(cross(cur), pid, true)
204 testing.SetRealm(testing.NewUserRealm(alice))
205 Vote(cross(cur), pid, false)
206
207 yes, no := Tally(pid)
208 uassert.Equal(t, int64(5000), yes)
209 uassert.Equal(t, int64(10000), no, "a bigger holder outvotes a smaller one")
210
211 // Changing your mind while it is open replaces the ballot.
212 testing.SetRealm(testing.NewUserRealm(alice))
213 Vote(cross(cur), pid, true)
214 yes, no = Tally(pid)
215 uassert.Equal(t, int64(15000), yes)
216 uassert.Equal(t, int64(0), no, "the old ballot is removed, not added to")
217
218 testing.SkipHeights(crew.VoteBlocks)
219 testing.SetRealm(testing.NewUserRealm(carol))
220 uassert.True(t, Close(cross(cur), pid), "anyone may close it")
221
222 _, _, _, _, closed, passed = ProposalOf(pid)
223 uassert.True(t, closed)
224 uassert.True(t, passed)
225
226 // Passing it moved nothing. That is the whole v0 claim.
227 uassert.Equal(t, tenGNOT+tenGNOT/2, Treasury(id))
228 uassert.Equal(t, int64(0), TotalOwed())
229}
230
231func TestRefusals(cur realm, t *testing.T) {
232 reset()
233 id := create(cur, alice, tenGNOT, "crew")
234
235 testing.IssueCoins(realmAddr, chain.Coins{{denom, tenGNOT}})
236 testing.SetOriginSend(chain.Coins{{denom, tenGNOT}})
237 testing.SetRealm(testing.NewUserRealm(alice))
238 uassert.AbortsContains(t, cur, "already a member", func() { Join(cross(cur), id) })
239
240 testing.IssueCoins(realmAddr, chain.Coins{{denom, tenGNOT}})
241 testing.SetOriginSend(chain.Coins{{denom, tenGNOT}})
242 testing.SetRealm(testing.NewUserRealm(bob))
243 uassert.AbortsContains(t, cur, "no such crew", func() { Join(cross(cur), 4242) })
244
245 // Create with nothing attached is refused by the envelope, which names
246 // the flag the caller has to fix.
247 testing.SetOriginSend(chain.Coins{})
248 testing.SetRealm(testing.NewUserRealm(bob))
249 uassert.AbortsContains(t, cur, "send", func() { Create(cross(cur), "broke") })
250
251 testing.SetRealm(testing.NewUserRealm(bob))
252 uassert.AbortsContains(t, cur, "not a member", func() {
253 Propose(cross(cur), id, "let me in")
254 })
255 testing.SetRealm(testing.NewUserRealm(bob))
256 uassert.AbortsContains(t, cur, "not a member", func() { Ragequit(cross(cur), id) })
257
258 testing.SetRealm(testing.NewUserRealm(alice))
259 pid := Propose(cross(cur), id, "a question")
260 testing.SetRealm(testing.NewUserRealm(bob))
261 uassert.AbortsContains(t, cur, "not a member", func() { Vote(cross(cur), pid, true) })
262 testing.SetRealm(testing.NewUserRealm(alice))
263 uassert.AbortsContains(t, cur, "still open", func() { Close(cross(cur), pid) })
264
265 testing.SetRealm(testing.NewUserRealm(alice))
266 uassert.AbortsContains(t, cur, "name is empty", func() {
267 create(cur, alice, tenGNOT, " ")
268 })
269 // A pipe in a name would open a column where the name is a link title
270 // in a table cell, which md.Link's escaper does not cover.
271 testing.SetRealm(testing.NewUserRealm(alice))
272 uassert.AbortsContains(t, cur, "name is empty", func() {
273 create(cur, alice, tenGNOT, "a | pipe")
274 })
275
276 uassert.Equal(t, 1, Count(), "no refusal created anything")
277 uassert.Equal(t, 1, MemberCount(id))
278}
279
280// TestRender uses uassert rather than an Example because every view contains
281// consecutive blank lines, which gno collapses inside an // Output: block.
282func TestRender(cur realm, t *testing.T) {
283 reset()
284 id := create(cur, alice, tenGNOT, "a crew [with](markdown)")
285 join(cur, bob, tenGNOT, id)
286
287 testing.SetRealm(testing.NewUserRealm(alice))
288 pid := Propose(cross(cur), id, "ship v1 | before the conference")
289 testing.SetRealm(testing.NewUserRealm(alice))
290 Vote(cross(cur), pid, true)
291
292 index := Render("")
293 uassert.True(t, strings.Contains(index, "# Crews"), index)
294 uassert.True(t, strings.Contains(index, "| 1 | 1 | 0 GNOT |"), "crews, proposals, owed")
295 uassert.True(t, strings.Contains(index, "func=Create"), index)
296 uassert.True(t, strings.Contains(index, "One realm address holds every crew's treasury"),
297 "the index states the custody caveat")
298 uassert.True(t, strings.Contains(index,
299 "[a crew \\[with\\]\\(markdown\\)](/r/moul/x/social/crews/v0:crew/1)"),
300 "md.Link escapes the title exactly once: "+index)
301
302 sid := strconv.FormatInt(id, 10)
303 page := Render("crew/" + sid)
304 uassert.True(t, strings.Contains(page, "# a crew \\[with\\]\\(markdown\\)"),
305 "the name is escaped where it is shown as prose: "+page)
306 uassert.True(t, strings.Contains(page, "| 2 of 15 | 20000 | 20 GNOT | 0.001 GNOT |"), page)
307 uassert.True(t, strings.Contains(page, "| 10000 | 50% | 10 GNOT |"),
308 "each member holds half the shares and half the money")
309 uassert.True(t, strings.Contains(page, "func=Ragequit"), page)
310 uassert.True(t, strings.Contains(page, "executes nothing"), page)
311
312 // A pipe in a proposal's text must not open a column in the crew's
313 // proposal table. It goes through ui.Cell for exactly this.
314 uassert.True(t, strings.Contains(page, "ship v1 \\| before the conference"),
315 "the cell is escaped: "+page)
316 uassert.False(t, strings.Contains(page, "| ship v1 | before"),
317 "a raw pipe would open a column: "+page)
318
319 spid := strconv.FormatInt(pid, 10)
320 prop := Render("proposal/" + spid)
321 uassert.True(t, strings.Contains(prop, "# Proposal #"+spid), prop)
322 uassert.True(t, strings.Contains(prop, "> ship v1 | before the conference"),
323 "a blockquote is prose, where a pipe is inert and must not grow a backslash")
324 uassert.True(t, strings.Contains(prop, "| open until 1123 | 10000 | 0 | 1 |"), prop)
325 uassert.True(t, strings.Contains(prop, "func=Vote"), prop)
326
327 // Past the deadline the page offers the close instead of the vote.
328 testing.SkipHeights(crew.VoteBlocks)
329 prop = Render("proposal/" + spid)
330 uassert.True(t, strings.Contains(prop, "awaiting a close"), prop)
331 uassert.True(t, strings.Contains(prop, "func=Close"), prop)
332 uassert.False(t, strings.Contains(prop, "func=Vote"), prop)
333
334 uassert.Equal(t, "# Crews\nNo crew #4242", Render("crew/4242"))
335 uassert.Equal(t, "# Crews\nNot a crew id: nope", Render("crew/nope"))
336 uassert.Equal(t, "# Crews\nNo proposal #4242", Render("proposal/4242"))
337 uassert.Equal(t, "# Crews\nNot a proposal id: nope", Render("proposal/nope"))
338 uassert.True(t, strings.HasPrefix(Render("elsewhere"), "# Not found"))
339}
340
341// An empty realm still renders something a reader can act on.
342func TestRenderWithNothingInIt(cur realm, t *testing.T) {
343 reset()
344 index := Render("")
345 uassert.True(t, strings.Contains(index, "No crews yet."), index)
346 uassert.True(t, strings.Contains(index, "func=Create"), index)
347}
348
349// Links are rooted at the realm's own path, so they resolve from any page
350// gnoweb serves them on.
351func TestLinksAreRootRelative(t *testing.T) {
352 uassert.Equal(t, "/r/moul/x/social/crews/v0", realmURL())
353 uassert.Equal(t, "/r/moul/x/social/crews/v0:crew/7", crewURL(7))
354 uassert.Equal(t, "/r/moul/x/social/crews/v0:proposal/7", proposalURL(7))
355}