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

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}