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.

render_test.gno

44.24 Kb · 959 lines
  1package bolo
  2
  3import (
  4	"chain"
  5	"math"
  6	"strconv"
  7	"strings"
  8	"testing"
  9
 10	"gno.land/p/nt/markdown/sanitize/v0"
 11	"gno.land/p/nt/seqid/v0"
 12	"gno.land/p/nt/testutils/v0"
 13)
 14
 15func TestGnot(t *testing.T) {
 16	for ugnot, want := range map[int64]string{
 17		0: "0 GNOT", 1: "0.000001 GNOT", 1_500_000: "1.5 GNOT", 5_000_000: "5 GNOT",
 18		1_234_567: "1.234567 GNOT", 10_000_010: "10.00001 GNOT", -2_500_000: "-2.5 GNOT",
 19	} {
 20		if got := gnot(ugnot); got != want {
 21			t.Errorf("gnot(%d) = %q, want %q", ugnot, got, want)
 22		}
 23	}
 24	if got := when(0); got != "1970-01-01 00:00 UTC" {
 25		t.Errorf("when(0) = %q", got)
 26	}
 27	for n, want := range map[int]string{0: "0 donors", 1: "1 donor", 2: "2 donors"} {
 28		if got := count(n, "donor", "donors"); got != want {
 29			t.Errorf("count(%d) = %q, want %q", n, got, want)
 30		}
 31	}
 32}
 33
 34func TestParsers(t *testing.T) {
 35	for s, want := range map[string]int{"1": 1, "20": 20, "999999999": 999999999} {
 36		if n, ok := parsePage(s); !ok || n != want {
 37			t.Errorf("parsePage(%q) = %d, %t", s, n, ok)
 38		}
 39	}
 40	for _, s := range []string{"", "0", "01", "-1", "+1", "1x", " 1", "1000000000"} {
 41		if _, ok := parsePage(s); ok {
 42			t.Errorf("parsePage(%q) accepted", s)
 43		}
 44	}
 45	good := testutils.TestAddress("renderer").String()
 46	if _, ok := parseAddress(good); !ok {
 47		t.Errorf("parseAddress refused %s", good)
 48	}
 49	last := "q"
 50	if good[39:] == "q" {
 51		last = "p"
 52	}
 53	badSum := good[:39] + last // 40 characters, a lowercase "g", a wrong checksum
 54	for _, s := range []string{"", "notanaddress", strings.ToUpper(good), good[:39], good + "x", badSum} {
 55		if _, ok := parseAddress(s); ok {
 56			t.Errorf("parseAddress(%q) accepted", s)
 57		}
 58	}
 59	if q, ok := parseQuery("page=2&&size=1000000&x"); !ok || q["page"] != "2" || q["size"] != "1000000" {
 60		t.Errorf("parseQuery: %v, %t", q, ok)
 61	}
 62	if _, ok := parseQuery("page=1&page=2"); ok {
 63		t.Errorf("parseQuery accepted a key twice")
 64	}
 65}
 66
 67// TestRenderNotFound: every path that fails gets the one not-found page, which echoes
 68// nothing from the request (API §5).
 69func TestRenderNotFound(t *testing.T) {
 70	c := testCampaign(9961, modeEscrow, 0)
 71	id, unstore := storeCampaign(c)
 72	defer unstore()
 73	c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: testutils.TestAddress("donor"), amount: 5})
 74	bad := []string{
 75		"c/0" + id, "c/x", "c/" + id + "/", "c/" + id + "/r/x", "c/" + id + "/r/0", "c/" + id + "/r/2",
 76		"c/" + id + "/donations?page=-1", "c/" + id + "/donations?page=0", "c/" + id + "/ledger?page=x",
 77		"c/" + id + "/updates?page=1&page=2", "c/" + id + "/nope", "c/99999", "c/", "c/" + id + "/r/1/x",
 78		"u/notanaddress", "u/" + strings.ToUpper(testutils.TestAddress("renderer").String()),
 79		"u/" + testutils.TestAddress("renderer").String() + "/nope",
 80		"u/" + testutils.TestAddress("renderer").String() + "/gave?after=0x",
 81		"?after=x", "?after=", "?cat=nope", "?cat=", "c?cat=nope", "c/" + id + "/r?page=0", "u/", "u?address=a&address=b",
 82		"about/x", "x", "/", "//", strings.Repeat("c/", 200),
 83	}
 84	for _, path := range bad {
 85		if got := Render(path); got != notFound {
 86			t.Errorf("Render(%q) isn't the not-found page:\n%s", path, got)
 87		}
 88	}
 89	// A key the route doesn't take is ignored.
 90	if Render("c/"+id+"/donations?size=1000000") == notFound || Render("?page=-1") == notFound {
 91		t.Errorf("an ignored key made the page fail")
 92	}
 93}
 94
 95// injection is what a stranger, a moderator or an organizer might write to pass for
 96// Bolo, plant a link, an image, raw HTML or a mention, open an alert or one of
 97// gnoweb's blocks, hide characters, or break out of their slot — shapes the writes'
 98// own checks let through, the hidden characters stripped (API §2.3).
 99const injection = "[!WARNING] [Verify](https://evil.example) **BOLO** # x | y ] ) ! <b>raw</b> ![i](https://evil.example/i.png) \u200b\u202e @bolo_help </gno-columns> <gno-columns-sep> <gno-form path=\"u\">"
100
101// mention reports whether s holds what gnoweb makes a link of on its own: an "@" no
102// backslash escapes, or an address after a space or opening a line's text.
103func mention(s string) bool {
104	for i := 0; i < len(s); i++ {
105		if s[i] == '@' && (i == 0 || s[i-1] != '\\') {
106			return true
107		}
108	}
109	for _, line := range strings.Split(s, "\n") {
110		if strings.HasPrefix(strings.TrimLeft(line, "#>- "), "g1") {
111			return true
112		}
113	}
114	return strings.Contains(s, " g1")
115}
116
117// ownTags reports whether every tag of gnoweb's blocks in s that isn't escaped stands
118// on a line of its own, as Bolo writes them — the only place gnoweb reads one — and the
119// columns open as often as they close.
120func ownTags(s string) bool {
121	lines := map[string]bool{"<gno-columns>": true, "<gno-columns-sep />": true, "</gno-columns>": true,
122		"<gno-form path=\"u\">": true, "  <gno-input name=\"address\" placeholder=\"g1…\" />": true, "</gno-form>": true}
123	for _, line := range strings.Split(s, "\n") {
124		own := lines[line] || line == "  <gno-input name=\"amount\" placeholder=\"7.5\" />" ||
125			strings.HasPrefix(line, "<gno-form path=\"c/") && strings.HasSuffix(line, "/give\">") && isDigits(line[len("<gno-form path=\"c/"):len(line)-len("/give\">")])
126		for i := 0; i+5 <= len(line); i++ {
127			if (line[i:i+5] == "<gno-" || line[i:i+5] == "</gno") && (i == 0 || line[i-1] != '\\') && !own {
128				return false
129			}
130		}
131	}
132	return strings.Count(s, "\n<gno-columns>\n") == strings.Count(s, "\n</gno-columns>\n")
133}
134
135// isDigits reports whether s is one or more decimal digits.
136func isDigits(s string) bool {
137	for i := 0; i < len(s); i++ {
138		if s[i] < '0' || s[i] > '9' {
139			return false
140		}
141	}
142	return s != ""
143}
144
145// TestRenderEscapes: every text slot is escaped by who wrote it. Strangers' and
146// moderators' text can carry no link, no image, no mention and none of gnoweb's
147// blocks; the organizer's story and updates stay inside their quote, below Bolo's own
148// blocks; the alerts are Bolo's — the notice on the campaign's page, the refund banner
149// on a refunded gift's receipt — which no user text can open; addresses sit in code
150// spans (API §5).
151func TestRenderEscapes(t *testing.T) {
152	phisher := testutils.TestAddress("phisher").String()
153	inj := phisher + " " + injection + " " + phisher
154	story := "[!WARNING] Notice from Bolo's moderator\nHelp us.\n## Check it yourself\n- Escrow: `g1fake`\n| a | b |\n[ok](https://ok.example) @bolo_help"
155	body := "## Act\n[Give 5 GNOT](https://evil.example)"
156	donor := testutils.TestAddress("donor")
157	c := testCampaign(9962, modeEscrow, 0)
158	c.text.title, c.text.description = inj, story
159	c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", inj
160	c.state.notice = inj
161	id, unstore := storeCampaign(c)
162	defer unstore()
163	c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: donor, amount: 5, message: inj})
164	c.state.donationCount, c.state.recent[0], c.state.recentNext = 1, 1, 1
165	c.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, body: body})
166	c.state.updateCount = 1
167	c.contributions.Set(donor.String(), &contribution{amount: 5, refunded: 1, public: true})
168	c.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1, donor: donor, kind: "open"})
169	c.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1, to: c.beneficiary})
170	byOrganizer.Set(c.organizer.String()+"/"+c.id.Binary(), true)
171	defer byOrganizer.Remove(c.organizer.String() + "/" + c.id.Binary())
172
173	org := "u/" + c.organizer.String()
174	for _, path := range []string{"c/" + id, "c/" + id + "/r/1", "c/" + id + "/donations", "c/" + id + "/updates", "c/" + id + "/ledger", "c/" + id + "/give", "c/" + id + "/give?amount=5", "?cat=water", org, org + "/organized"} {
175		out := Render(path)
176		// The organizer's own text, quoted, keeps its links and mentions: without it,
177		// nothing on the page may carry one.
178		rest := strings.ReplaceAll(strings.ReplaceAll(out, sanitize.Blockquote(story), ""), sanitize.Blockquote(body), "")
179		if strings.Contains(rest, "](https://") || mention(rest) {
180			t.Errorf("%s: a link, an image or a mention outside the organizer's quote:\n%s", path, rest)
181		}
182		if strings.Contains(strings.ReplaceAll(rest, `\<`, ""), "<b>") || strings.ContainsAny(rest, "\u200b\u202e") {
183			t.Errorf("%s: raw HTML or a hidden character outside the organizer's quote:\n%s", path, rest)
184		}
185		if !ownTags(rest) {
186			t.Errorf("%s: a block's tag Bolo didn't write:\n%s", path, rest)
187		}
188		alerts, entities := 0, 0
189		switch path {
190		case "c/" + id:
191			alerts, entities = 1, 2
192			if !strings.HasPrefix(out, "> [!WARNING] Notice from Bolo's moderator\n> ") {
193				t.Errorf("the notice isn't the alert at the top of the page:\n%s", out)
194			}
195		case "c/" + id + "/r/1":
196			alerts = 1
197			if !strings.HasPrefix(out, "> [!CAUTION] Refunded since\n> 0.000001 GNOT went back to this donor after this gift.\n\n# Gift receipt") {
198				t.Errorf("the refund banner isn't the alert at the top of the receipt:\n%s", out)
199			}
200		}
201		if n := strings.Count("\n"+out, "\n> [!"); n != alerts {
202			t.Errorf("%s: %d alerts, want %d", path, n, alerts)
203		}
204		// The title's heading and the notice's line open with an address; nothing
205		// else may write its "g" as an entity, which a table of contents shows as is.
206		if n := strings.Count(out, "&#103;"); n != entities {
207			t.Errorf("%s: %d entities, want %d", path, n, entities)
208		}
209	}
210	page := Render("c/" + id)
211	verifyAt, storyAt := strings.Index(page, "## Check it yourself\n\n- **Escrow** `g1"), strings.Index(page, "## In the organizer's words")
212	if verifyAt < 0 || storyAt < 0 || verifyAt > storyAt {
213		t.Errorf("the verification block isn't before the story: %d, %d", verifyAt, storyAt)
214	}
215	if !strings.Contains(page, "## In the organizer's words\n"+sanitize.Blockquote(story)) {
216		t.Errorf("the story isn't quoted under its heading:\n%s", page)
217	}
218	if !strings.Contains(page, "](ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG)") {
219		t.Errorf("the photo is missing:\n%s", page)
220	}
221}
222
223// TestRenderHiddenPhoto: a hidden photo is gone from the campaign's page, its
224// description with it (API §3.1, §3.5, §5).
225func TestRenderHiddenPhoto(t *testing.T) {
226	c := testCampaign(9968, modeEscrow, 0)
227	c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", "The village well"
228	id, unstore := storeCampaign(c)
229	defer unstore()
230	if page := Render("c/" + id); !strings.Contains(page, "![The village well](ipfs://") {
231		t.Errorf("the photo is missing:\n%s", page)
232	}
233	c.state.imageHidden = true
234	if page := Render("c/" + id); strings.Contains(page, "ipfs://") || strings.Contains(page, "The village well") {
235		t.Errorf("a hidden photo is still on the page:\n%s", page)
236	}
237}
238
239// TestRenderDelisted: a delisted campaign shows its notice and no donate links, and is
240// on no list that promotes (API §5).
241func TestRenderDelisted(t *testing.T) {
242	org := testutils.TestAddress("renderorg")
243	cs, unindex := indexCampaigns(9963, 2, org, org)
244	defer unindex()
245	off := cs[0]
246	off.state.listed, off.state.notice = false, "Under review"
247	dropActive(off)
248	defer addActive(off)
249	if page := Render("c/9963"); !strings.Contains(page, "Under review") || strings.Contains(page, "## Give") || strings.Contains(page, "func=Donate&id=9963&tip") {
250		t.Errorf("a delisted campaign's page:\n%s", page)
251	}
252	if !strings.Contains(Render("c/9964"), "## Give") {
253		t.Errorf("a listed campaign has no donate links")
254	}
255	for _, path := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} {
256		if addr := Render(path); strings.Contains(addr, ":c/9963)") || !strings.Contains(addr, ":c/9964)") {
257			t.Errorf("%s lists the delisted campaign, or misses the listed one:\n%s", path, addr)
258		}
259	}
260	if home := Render("?cat=animals"); strings.Contains(home, ":c/9963)") || !strings.Contains(home, ":c/9964)") {
261		t.Errorf("the home page:\n%s", home)
262	}
263}
264
265// TestRenderBounds: a path longer than any valid one gets the not-found page before it
266// is parsed, and a page's size never comes from the query: ?size= is ignored (API §5).
267func TestRenderBounds(t *testing.T) {
268	if got := Render("?pad=" + strings.Repeat("a", 300)); got != notFound {
269		t.Errorf("a 305-byte path rendered")
270	}
271	c := testCampaign(9965, modeEscrow, 0)
272	id, unstore := storeCampaign(c)
273	defer unstore()
274	for i := 1; i <= 25; i++ {
275		c.receipts.Set(seqid.ID(i).Binary(), &receipt{seq: uint64(i), donor: testutils.TestAddress("donor"), amount: 5})
276	}
277	if n := strings.Count(Render("c/"+id+"/donations?size=50"), "[receipt]("); n != 20 {
278		t.Errorf("?size=50: %d gifts on the page, want 20", n)
279	}
280	if n := strings.Count(Render("c/"+id+"/donations?page=2"), "[receipt]("); n != 5 {
281		t.Errorf("page 2: %d gifts, want 5", n)
282	}
283}
284
285// TestRenderGiveLinks: the one-click donate links appear only while the campaign is
286// open, listed, and Bolo isn't paused (API §5).
287func TestRenderGiveLinks(t *testing.T) {
288	c := testCampaign(9966, modeEscrow, 0)
289	id, unstore := storeCampaign(c)
290	defer unstore()
291	defer func() { paused = false }()
292	give := func() bool { return strings.Contains(Render("c/"+id), "## Give") }
293	if !give() {
294		t.Errorf("an open, listed campaign has no donate links")
295	}
296	paused = true
297	if give() {
298		t.Errorf("donate links while Bolo is paused")
299	}
300	paused = false
301	c.deadline = now() - 1
302	if give() {
303		t.Errorf("donate links past the deadline")
304	}
305	c.deadline = 0
306	c.state.status = statusClosed
307	if give() {
308		t.Errorf("donate links on a closed campaign")
309	}
310}
311
312// TestRenderActLinks: Withdraw is offered only while the escrow holds coins, and Close
313// whenever someone could close: the organizer while the campaign is open, anyone once
314// CanClose says so (API §5).
315func TestRenderActLinks(t *testing.T) {
316	c := testCampaign(9967, modeEscrow, 0)
317	id, unstore := storeCampaign(c)
318	defer unstore()
319	page := func() string { return Render("c/" + id) }
320	closeBy := func(who string) string { return "func=Close&id=" + id + "): " + who }
321	if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, closeBy("the organizer")) {
322		t.Errorf("an empty escrow offers Withdraw, or the organizer can't close:\n%s", p)
323	}
324	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5)))
325	if p := page(); !strings.Contains(p, "[Withdraw](") {
326		t.Errorf("an escrow holding coins offers no Withdraw:\n%s", p)
327	}
328	c.deadline = now() - 1 // passed, not recorded: anyone may close
329	if p := page(); !strings.Contains(p, closeBy("anyone")) {
330		t.Errorf("a passed deadline nobody recorded offers no Close to anyone:\n%s", p)
331	}
332	c.deadline, c.state.status = 0, statusClosed
333	if p := page(); strings.Contains(p, "[Close](") {
334		t.Errorf("a closed campaign offers Close:\n%s", p)
335	}
336	// Withdraw stops when the beneficiary's window ends; Finalize appears when it can.
337	c.state.raised, c.state.withdrawn, c.state.everWithdrawn, c.state.closedAt = 7_000_000, 2_000_000, true, now()
338	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the 5 GNOT the books hold
339	if p := page(); !strings.Contains(p, "[Withdraw](") || strings.Contains(p, "[Finalize](") {
340		t.Errorf("closed, the beneficiary's window running: no Withdraw, or a Finalize:\n%s", p)
341	}
342	c.state.closedAt = now() - c.params.ExitDelay
343	if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, "func=Finalize&id="+id+"): anyone") {
344		t.Errorf("closed, the beneficiary's window over: a Withdraw, or no Finalize:\n%s", p)
345	}
346}
347
348// TestRenderEmptyPages: a page with nothing to show says so, and says "None on this
349// page." on a later page or when a "More" follows — the home page once its read budget
350// went on passed deadlines, an address list whose newest are all delisted, a campaign's
351// own lists past their first page (API §5).
352func TestRenderEmptyPages(t *testing.T) {
353	org := testutils.TestAddress("emptyorg")
354	if home := Render("?cat=animals"); !strings.Contains(home, "No campaigns here yet.") {
355		t.Errorf("an empty category:\n%s", home)
356	}
357	if later := Render("?cat=animals&after=1"); !strings.Contains(later, "None on this page.") || strings.Contains(later, "[More](") {
358		t.Errorf("a later page with nothing on it:\n%s", later)
359	}
360	cs, unindex := indexCampaigns(9600, 4*listPage+1, org, org)
361	defer unindex()
362	for _, c := range cs {
363		c.deadline = now() - 1
364	}
365	if home := Render("?cat=animals"); !strings.Contains(home, "None on this page.") || !strings.Contains(home, "[More](") {
366		t.Errorf("a page of passed deadlines, with more to read:\n%s", home)
367	}
368	for _, c := range cs {
369		c.state.listed = false
370	}
371	for _, p := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} {
372		if page := Render(p); !strings.Contains(page, "None on this page.") || !strings.Contains(page, "[More](") {
373			t.Errorf("%s: a page of delisted campaigns, with more to come:\n%s", p, page)
374		}
375	}
376	other := "u/" + testutils.TestAddress("nobody").String() + "/organized"
377	if first, later := Render(other), Render(other+"?after=1"); !strings.Contains(first, "None.") || !strings.Contains(later, "None on this page.") {
378		t.Errorf("an empty address list, first and later pages:\n%s\n%s", first, later)
379	}
380	c := testCampaign(9969, modeEscrow, 0)
381	id, unstore := storeCampaign(c)
382	defer unstore()
383	for route, first := range map[string]string{"donations": "No gifts yet.", "ledger": "None.\n\n## Refunds\n\nNone.", "updates": "No updates yet."} {
384		later := strings.ReplaceAll(strings.ReplaceAll(first, "yet.", "on this page."), "None.", "None on this page.")
385		if p1, p2 := Render("c/"+id+"/"+route), Render("c/"+id+"/"+route+"?page=2"); !strings.Contains(p1, first) || !strings.Contains(p2, later) {
386			t.Errorf("an empty %s list, pages 1 and 2:\n%s\n%s", route, p1, p2)
387		}
388	}
389	var b strings.Builder
390	writeListed(&b, []CampaignView{{Listed: false}}, true)
391	if b.String() != "None on this page.\n\n" {
392		t.Errorf("a list of delisted campaigns with more to come: %q", b.String())
393	}
394	b.Reset()
395	writeListed(&b, nil, false)
396	if b.String() != "None.\n\n" {
397		t.Errorf("an empty list: %q", b.String())
398	}
399}
400
401// giveTo records a public gift of amount ugnot from donor to c, as Donate would for
402// the pages to show: its receipt, the recent gifts, the counts, the donor's
403// contribution, and the donor's list.
404func giveTo(c *campaign, seq uint64, donor address, amount int64) {
405	s := c.state
406	c.receipts.Set(seqid.ID(seq).Binary(), &receipt{seq: seq, donor: donor, amount: amount, at: now(), message: "Courage"})
407	s.recent[s.recentNext%recentSize] = seq
408	s.recentNext++
409	s.donationCount++
410	s.donorCount++
411	s.raised += amount
412	c.contributions.Set(donor.String(), &contribution{amount: amount, public: true})
413	byDonor.Set(donor.String()+"/"+c.id.Binary(), true)
414}
415
416// pageLinks returns the paths of a page's links to Bolo's own pages, and the
417// functions its transaction links call.
418func pageLinks(page string) (paths, funcs []string) {
419	at := "](" + webPath
420	for i := strings.Index(page, at); i >= 0; i = strings.Index(page, at) {
421		page = page[i+len(at):]
422		end := strings.Index(page, ")")
423		target := page[:end]
424		switch {
425		case target == "":
426			paths = append(paths, "")
427		case target[0] == ':':
428			paths = append(paths, target[1:])
429		case strings.HasPrefix(target, "$help&func="):
430			name, _, _ := strings.Cut(strings.TrimPrefix(target, "$help&func="), "&")
431			funcs = append(funcs, name)
432		}
433	}
434	return paths, funcs
435}
436
437// TestRenderLinks: every link a page writes leads to a page, every transaction link
438// calls one of Bolo's writes, and every prefix of every path renders a page too, as
439// gnoweb's header links each one (UI spec §4.13) — on campaigns of each kind.
440func TestRenderLinks(t *testing.T) {
441	org, ben, donor := testutils.TestAddress("linkorg"), testutils.TestAddress("linkben"), testutils.TestAddress("linkdonor")
442	cs, unindex := indexCampaigns(9700, 4, org, ben)
443	defer unindex()
444	open, direct, closed, settled := cs[0], cs[1], cs[2], cs[3]
445	direct.mode = modeDirect
446	closed.state.status, closed.state.closedAt, closed.state.closeReason = statusClosed, now(), reasonOrganizer
447	settled.state.status, settled.state.closedAt, settled.state.closeReason = statusSettled, now(), reasonOrganizer
448	dropActive(closed)
449	dropActive(settled)
450	for _, c := range cs {
451		giveTo(c, 1, donor, 5_000_000)
452		defer byDonor.Remove(donor.String() + "/" + c.id.Binary())
453	}
454	open.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, at: now(), body: "Started."})
455	open.state.updateCount = 1
456	open.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1_000_000, at: now(), to: ben})
457	open.state.withdrawn, open.state.everWithdrawn = 1_000_000, true
458	closed.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1_000_000, at: now(), donor: donor, kind: windowOpen})
459	closed.state.refunded = 1_000_000
460	for _, c := range cs { // the bank holds what the books say
461		if held := trackedBalance(c); held > 0 {
462			testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", held)))
463		}
464	}
465
466	writes := map[string]bool{"Donate": true, "Refund": true, "Withdraw": true, "Close": true, "Finalize": true, "PostUpdate": true, "CreateCampaign": true}
467	seen := map[string]bool{}
468	queue := []string{"", "about", "params", "u", "u?address=" + donor.String(), "u?address=nope", "c/9700/give?amount=7.5", "c/9700/give?amount=nope"}
469	for _, c := range cs {
470		queue = append(queue, "c/"+idString(c.id)+"/r/1")
471	}
472	for len(queue) > 0 {
473		path := queue[0]
474		queue = queue[1:]
475		if seen[path] {
476			continue
477		}
478		seen[path] = true
479		page := Render(path)
480		if page == notFound {
481			t.Errorf("%q: not found", path)
482			continue
483		}
484		paths, funcs := pageLinks(page)
485		for _, f := range funcs {
486			if !writes[f] {
487				t.Errorf("%q links to a transaction calling %q", path, f)
488			}
489		}
490		route, _, _ := strings.Cut(path, "?")
491		segs := strings.Split(route, "/")
492		for i := 1; i < len(segs); i++ {
493			paths = append(paths, strings.Join(segs[:i], "/"))
494		}
495		queue = append(queue, paths...)
496	}
497	for _, want := range []string{"c", "c/9700", "c/9700/r", "c/9700/give", "c/9700/donations", "c/9700/ledger", "c/9700/updates", "u", "u/" + org.String(), "u/" + ben.String(), "u/" + donor.String()} {
498		if !seen[want] {
499			t.Errorf("no link or prefix reaches %q", want)
500		}
501	}
502}
503
504// TestRenderPrefixes: the prefixes gnoweb's header links render the pages they stand
505// for: "c" the home page, "c/{id}/r" the campaign's gifts (UI spec §4.13).
506func TestRenderPrefixes(t *testing.T) {
507	c := testCampaign(9971, modeEscrow, 0)
508	id, unstore := storeCampaign(c)
509	defer unstore()
510	if Render("c") != Render("") || Render("c?cat=water") != Render("?cat=water") {
511		t.Errorf("c isn't the home page")
512	}
513	if Render("c/"+id+"/r") != Render("c/"+id+"/donations") || Render("c/"+id+"/r?page=2") != Render("c/"+id+"/donations?page=2") {
514		t.Errorf("c/%s/r isn't the gifts page", id)
515	}
516}
517
518// TestRenderLookup: "u" is a form to find an address, which submits back as
519// ?address=: a valid address gets its page, anything else the form again with one
520// fixed line, echoing nothing (API §5).
521func TestRenderLookup(t *testing.T) {
522	form := Render("u")
523	if !strings.Contains(form, "# Find an address") || !strings.Contains(form, "\n<gno-form path=\"u\">\n  <gno-input name=\"address\" placeholder=\"g1…\" />\n</gno-form>\n") || strings.Contains(form, "isn't") {
524		t.Errorf("the lookup page:\n%s", form)
525	}
526	good := testutils.TestAddress("lookedup").String()
527	if got := Render("u?address=" + good); got != Render("u/"+good) {
528		t.Errorf("a valid address doesn't get its page:\n%s", got)
529	}
530	for _, bad := range []string{"", "notanaddress", strings.ToUpper(good), good + "x", "%3Cb%3Eecho%3C/b%3E", "<b>echo</b>"} {
531		got := Render("u?address=" + bad)
532		if !strings.Contains(got, "That isn't a gno.land address.") || !strings.Contains(got, "<gno-form") {
533			t.Errorf("u?address=%s: no form, or no line saying why:\n%s", bad, got)
534		}
535		if bad != "" && strings.Contains(got, bad) {
536			t.Errorf("u?address=%s: the page echoes it:\n%s", bad, got)
537		}
538	}
539}
540
541// TestRenderGiveRoom: the donate links offer only what Donate would take — the presets
542// within the room the escrow cap leaves, and the room said — and none, with a banner,
543// when not even the smallest gift fits (API §5).
544func TestRenderGiveRoom(t *testing.T) {
545	c := testCampaign(9972, modeEscrow, 0)
546	id, unstore := storeCampaign(c)
547	defer unstore()
548	saved := escrowCap
549	defer func() { escrowCap = saved }()
550	presetLink := func(page string, ugnot int64) bool {
551		return strings.Contains(page, "&.send="+strconv.FormatInt(ugnot, 10)+"ugnot&")
552	}
553	escrowCap = 30_000_000
554	c.state.raised = 12_000_000 // 18 GNOT of room
555	page := Render("c/" + id)
556	if !presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) || presetLink(page, 20_000_000) || presetLink(page, 50_000_000) {
557		t.Errorf("presets past the room, or none within it:\n%s", page)
558	}
559	if !strings.Contains(page, "[Another amount](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0:c/"+id+"/give)") || !strings.Contains(page, "It can take 18 GNOT more for now") {
560		t.Errorf("no other amount, or the room isn't said:\n%s", page)
561	}
562	c.state.raised = 29_500_000 // half a GNOT: less than the smallest gift
563	page = Render("c/" + id)
564	if strings.Contains(page, "## Give") || !strings.Contains(page, "> [!NOTE] Full for now\n") {
565		t.Errorf("donate links with no room, or no banner:\n%s", page)
566	}
567	escrowCap = 0
568	if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "It can take") {
569		t.Errorf("no cap, yet a preset left out or a room said:\n%s", page)
570	}
571	escrowCap = 30_000_000
572	c.mode = modeDirect // the cap is escrow's
573	if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "Full for now") {
574		t.Errorf("a direct campaign held to the escrow cap:\n%s", page)
575	}
576	// The room is the cap less the tracked balance, never the bank's, and a preset
577	// under the smallest gift is left out too.
578	c.mode = modeEscrow
579	c.state.raised, c.state.withdrawn, c.state.refunded, c.state.everWithdrawn = 40_000_000, 20_000_000, 2_000_000, true // 18 GNOT tracked
580	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 27_000_000)))                             // and 9 sent outside Bolo
581	page = Render("c/" + id)
582	if !presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) || presetLink(page, 20_000_000) || !strings.Contains(page, "It can take 12 GNOT more for now") {
583		t.Errorf("the room isn't the cap less the tracked balance:\n%s", page)
584	}
585	savedMin := minDonation
586	minDonation = 7_000_000
587	if page = Render("c/" + id); presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) {
588		t.Errorf("a preset under the smallest gift:\n%s", page)
589	}
590	minDonation = savedMin
591	escrowCap = 23_000_000 // exactly 5 GNOT of room
592	if page = Render("c/" + id); !presetLink(page, 5_000_000) || presetLink(page, 10_000_000) {
593		t.Errorf("a preset equal to the room left out:\n%s", page)
594	}
595	escrowCap = 19_000_000 // room for exactly the smallest gift
596	if page = Render("c/" + id); !strings.Contains(page, "## Give") || presetLink(page, 5_000_000) || !strings.Contains(page, "It can take 1 GNOT more for now") {
597		t.Errorf("room for exactly the smallest gift:\n%s", page)
598	}
599}
600
601// TestRenderBanner: the campaign's page shows the one state banner the UI spec's state
602// matrix gives its state (§6), and none while nothing is out of the ordinary.
603func TestRenderBanner(t *testing.T) {
604	c := testCampaign(9973, modeEscrow, 0)
605	id, unstore := storeCampaign(c)
606	defer unstore()
607	defer func() { paused = false }()
608	banner := func() string {
609		page := Render("c/" + id)
610		if n := strings.Count("\n"+page, "\n> [!"); n > 1 {
611			t.Errorf("%d banners:\n%s", n, page)
612		}
613		i := strings.Index(page, "> [!")
614		if i < 0 {
615			return ""
616		}
617		return page[i : i+strings.Index(page[i:], "\n\n")]
618	}
619	if b := banner(); b != "" {
620		t.Errorf("an open campaign shows a banner: %q", b)
621	}
622	paused = true
623	if b := banner(); !strings.HasPrefix(b, "> [!CAUTION] Bolo is paused\n") {
624		t.Errorf("paused: %q", b)
625	}
626	paused = false
627	saved, savedMin := escrowCap, minDonation
628	escrowCap = 500_000 // below the smallest gift: no withdrawal makes room
629	if b := banner(); b != "> [!NOTE] Full for now\n> Bolo's escrow cap is set below its smallest gift, so no campaign in escrow can take a gift for now." {
630		t.Errorf("a cap below the smallest gift: %q", b)
631	}
632	escrowCap, c.state.raised = 2_000_000, 1_500_000 // half a GNOT of room, under the smallest gift
633	if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." {
634		t.Errorf("a full escrow: %q", b)
635	}
636	escrowCap = minDonation // a cap of exactly the smallest gift: a withdrawal makes room
637	if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." {
638		t.Errorf("a full escrow, its cap the smallest gift: %q", b)
639	}
640	escrowCap, minDonation, c.state.raised = saved, savedMin, 0
641	c.params.DormancyPeriod = 30 * secondsPerDay // the campaign's own, not the live setting's 90
642	c.createdAt = now() - c.params.DormancyPeriod
643	if b := banner(); b != "> [!NOTE] Inactive\n> No gift and no update for 30 days: anyone can close it now." {
644		t.Errorf("dormant: %q", b)
645	}
646	c.createdAt = now()
647	s := c.state
648	s.status, s.closedAt, s.closeReason, s.raised = statusClosed, now(), reasonOrganizer, 7_000_000
649	if b := banner(); !strings.HasPrefix(b, "> [!NOTE] Closed to new donations\n> Closed by its organizer on ") || !strings.Contains(b, "The beneficiary can withdraw the 7 GNOT held until ") || !strings.Contains(b, ", 60 days left; after that, donors can reclaim their share.") {
650		t.Errorf("closed, before the exit window: %q", b)
651	}
652	s.closedAt, s.everWithdrawn = now()-c.params.ExitDelay, true
653	if b := banner(); !strings.Contains(b, "The beneficiary didn't collect in time: a donor who hasn't reclaimed their share yet can, at any time.") {
654		t.Errorf("closed, the exit window open: %q", b)
655	}
656	s.closeReason = reasonDormant
657	if b := banner(); !strings.Contains(b, "after 30 days with no gift and no update.") {
658		t.Errorf("closed as dormant: %q", b)
659	}
660	s.closeReason, s.raised = reasonDeadline, 0
661	if b := banner(); !strings.HasSuffix(b, "It ended at its deadline, on "+when(s.closedAt)+".") {
662		t.Errorf("ended at its deadline, nothing held: %q", b)
663	}
664	s.status = statusSettled
665	if b := banner(); b != "> [!SUCCESS] Completed\n> Closed to new donations on "+when(s.closedAt)+", then settled: the organizer's deposit went back to them." {
666		t.Errorf("settled: %q", b)
667	}
668}
669
670// TestRenderMode: the mode in words says what it means now, with the refund link
671// while a refund window is open to some donor (UI spec §4.3).
672func TestRenderMode(t *testing.T) {
673	c := testCampaign(9974, modeEscrow, 0)
674	id, unstore := storeCampaign(c)
675	defer unstore()
676	mode := func() string {
677		page := Render("c/" + id)
678		i := strings.Index(page, "\n**Escrow**: ")
679		if i < 0 {
680			i = strings.Index(page, "\n**Direct**: ")
681		}
682		if i < 0 {
683			t.Fatalf("no mode line:\n%s", page)
684		}
685		return page[i+1 : i+1+strings.Index(page[i+1:], "\n")]
686	}
687	refund := " [Refund](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Refund&id=" + id + ")"
688	if m := mode(); m != "**Escrow**: the gifts stay in escrow until the beneficiary withdraws them. Until then, a donor can take their gift back." {
689		t.Errorf("escrow, nothing given: %q", m)
690	}
691	c.state.raised = 5_000_000
692	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the bank holds what the books say
693	if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) {
694		t.Errorf("escrow, gifts held, nothing withdrawn: %q", m)
695	}
696	c.state.status, c.state.closedAt = statusClosed, now() // closed, the beneficiary's window running
697	if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) {
698		t.Errorf("escrow, closed, nothing withdrawn, the window running: %q", m)
699	}
700	c.state.closedAt = now() - c.params.ExitDelay // never withdrawn, the window passed
701	if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
702		t.Errorf("escrow, the beneficiary's window passed with nothing withdrawn: %q", m)
703	}
704	c.state.status = statusSettled
705	if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
706		t.Errorf("escrow, settled with nothing withdrawn and coins held: %q", m)
707	}
708	c.state.refunded = 5_000_000 // every gift back: nothing held
709	if m := mode(); m != "**Escrow**: no gift is held any more." {
710		t.Errorf("escrow, settled, nothing held: %q", m)
711	}
712	c.state.status, c.state.closedAt = statusClosed, now()
713	if m := mode(); m != "**Escrow**: no gift is held any more." {
714		t.Errorf("escrow, closed, nothing held: %q", m)
715	}
716	c.state.status, c.state.closedAt, c.state.refunded = statusOpen, 0, 0
717	c.state.withdrawn, c.state.everWithdrawn = 2_000_000, true
718	if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." {
719		t.Errorf("escrow, after a withdrawal: %q", m)
720	}
721	c.state.status, c.state.closedAt = statusClosed, now()
722	if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." {
723		t.Errorf("escrow, closed after a withdrawal, the window running: %q", m)
724	}
725	c.state.closedAt = now() - c.params.ExitDelay
726	if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund {
727		t.Errorf("escrow, the exit window open: %q", m)
728	}
729	c.mode = modeDirect
730	if m := mode(); m != "**Direct**: each gift goes straight to the beneficiary, in the same transaction. Gifts can't be refunded." {
731		t.Errorf("direct: %q", m)
732	}
733}
734
735// TestProgressBar: the bar fills min(net, goal) of its width, its text the uncapped
736// percentage; with no goal there is none; a share never overflows (UI spec §5).
737func TestProgressBar(t *testing.T) {
738	fill := func(bar string) string {
739		i := strings.LastIndex(bar, "%3Crect%20width='")
740		rest := bar[i+len("%3Crect%20width='"):]
741		return rest[:strings.Index(rest, "'")]
742	}
743	for _, tc := range []struct {
744		net, goal   int64
745		width       int
746		alt, filled string
747	}{
748		{0, 400, barWide, "0%", "0"},
749		{90_000_000, 400_000_000, barWide, "22%", "225"},
750		{90_000_000, 400_000_000, barCard, "22%", "90"},
751		{496_000_000, 400_000_000, barWide, "124%", "1000"},
752		{1, 3, barWide, "33%", "333"},
753	} {
754		bar := progressBar(tc.net, tc.goal, tc.width)
755		if !strings.HasPrefix(bar, "!["+tc.alt+" of the goal](data:image/svg+xml;utf8,%3Csvg%20") || fill(bar) != tc.filled || !strings.Contains(bar, "width='"+strconv.Itoa(tc.width)+"'%20height='10'") {
756			t.Errorf("progressBar(%d, %d, %d) = %s", tc.net, tc.goal, tc.width, bar)
757		}
758	}
759	if bar := progressBar(5, 0, barWide); bar != "" {
760		t.Errorf("a bar with no goal: %s", bar)
761	}
762	if got := share(math.MaxInt64, 1, 1000); got <= 0 {
763		t.Errorf("share overflowed: %d", got)
764	}
765	if got := share(-5, 10, 100); got != 0 {
766		t.Errorf("share of a negative part: %d", got)
767	}
768}
769
770// TestRenderCards: campaigns are cards two to a row, a lone last one in a row of its
771// own with an empty column; a card says what a donor weighs (UI spec §4.13).
772func TestRenderCards(t *testing.T) {
773	var b strings.Builder
774	v := CampaignView{ID: "1", Title: "Well", Category: "water", Mode: modeEscrow, Status: statusOpen, NetRaised: 3_000_000, Goal: 10_000_000, DonorCount: 1, Bond: 5_000_000, Notice: "Under review"}
775	writeCards(&b, []CampaignView{v, v, v})
776	out := b.String()
777	if strings.Count(out, "<gno-columns>\n") != 2 || strings.Count(out, "</gno-columns>\n\n") != 2 || strings.Count(out, "\n<gno-columns-sep />\n") != 2 ||
778		strings.Contains(out, "<gno-columns-sep>") || !strings.HasSuffix(out, "<gno-columns-sep />\n</gno-columns>\n\n") {
779		t.Errorf("not two to a row:\n%s", out)
780	}
781	if !strings.Contains(out, "**3 GNOT** of 10 GNOT · 1 donor  \nEscrow · water · no deadline · bond 5 GNOT · **moderator's notice**\n") {
782		t.Errorf("a card's lines:\n%s", out)
783	}
784	b.Reset()
785	v.Goal, v.Deadline, v.Bond, v.Notice, v.Status = 0, now()+3*secondsPerDay+60, 0, "", statusOpen
786	writeCards(&b, []CampaignView{v, v})
787	if out = b.String(); strings.Contains(out, "data:image") || !strings.Contains(out, "**3 GNOT** raised · 1 donor  \nEscrow · water · 3 days left\n") || strings.HasSuffix(out, "<gno-columns-sep />\n</gno-columns>\n\n") {
788		t.Errorf("two cards with no goal:\n%s", out)
789	}
790	for status, word := range map[string]string{statusClosed: "closed to new donations", statusSettled: "completed"} {
791		v.Status = status
792		if got := timeWord(v); got != word {
793			t.Errorf("timeWord(%s) = %q", status, got)
794		}
795	}
796	if got := left(now() + secondsPerDay - 1); got != "less than a day left" {
797		t.Errorf("left, under a day: %q", got)
798	}
799}
800
801// TestRenderLedgerDirect: a direct campaign's money out says nothing goes out, and
802// its page doesn't link there (API §5).
803func TestRenderLedgerDirect(t *testing.T) {
804	c := testCampaign(9975, modeDirect, 0)
805	id, unstore := storeCampaign(c)
806	defer unstore()
807	if page := Render("c/" + id + "/ledger"); strings.Contains(page, "## Withdrawals") || !strings.Contains(page, "**Direct**: each gift went straight to the beneficiary, `"+c.beneficiary.String()+"`") {
808		t.Errorf("a direct campaign's money out:\n%s", page)
809	}
810	if page := Render("c/" + id); strings.Contains(page, "/ledger)") || strings.Contains(page, "| Held in escrow |") {
811		t.Errorf("a direct campaign's page links to money out, or shows an escrow:\n%s", page)
812	}
813}
814
815// TestRenderStart: the home page links to CreateCampaign's form, the deposit attached,
816// only while this version takes new campaigns; paused or sunset, it says why, and a
817// sunset version points to the registry, where new campaigns start (UI spec §4.13).
818func TestRenderStart(t *testing.T) {
819	defer func() { paused, sunset = false, false }()
820	start := "[Start a campaign](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=CreateCampaign&.send=" + strconv.FormatInt(params.CreationDeposit, 10) + "ugnot) — a " + gnot(params.CreationDeposit) + " deposit, returned when it completes · [Find an address](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0:u)\n"
821	if home := Render(""); !strings.Contains(home, start) {
822		t.Errorf("no link to start a campaign:\n%s", home)
823	}
824	paused = true
825	if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "Bolo is paused: new campaigns and gifts wait until it resumes · [Find an address]") {
826		t.Errorf("paused:\n%s", home)
827	}
828	paused, sunset = false, true
829	if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "This version of Bolo takes no new campaigns: [see where they start](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo) · [Find an address]") {
830		t.Errorf("sunset:\n%s", home)
831	}
832	paused = true // and sunset, which is for good
833	if home, params := Render(""), Render("params"); !strings.Contains(home, "This version of Bolo takes no new campaigns") || !strings.Contains(params, "- New campaigns: not on this version\n") {
834		t.Errorf("paused and sunset:\n%s\n%s", home, params)
835	}
836}
837
838// TestParseGNOT: an amount in GNOT parses only in its one grammar (API §5).
839func TestParseGNOT(t *testing.T) {
840	for s, want := range map[string]int64{"1": 1_000_000, "7.5": 7_500_000, "7%2C5": 7_500_000, "0.000001": 1, "0.5": 500_000, "999999999999": 999_999_999_999_000_000, "10.25": 10_250_000,
841		"+7.5+": 7_500_000, "++7.5": 7_500_000, "1%2C00": 1_000_000, "1%2C0000": 1_000_000, "1.000": 1_000_000} {
842		if got, ok := parseGNOT(s); !ok || got != want {
843			t.Errorf("parseGNOT(%q) = %d, %t; want %d", s, got, ok, want)
844		}
845	}
846	for _, s := range []string{"", ".5", "1.", "01", "00.5", "-1", "1e3", "1.1234567", "1,5", "7%2c5", "1 ", " 1", "1..5", "7.5.1", "1234567890123", "%2C5", "1%2C",
847		"1%2C000", "12%2C500", "+", "+-1", "7+5"} {
848		if got, ok := parseGNOT(s); ok {
849			t.Errorf("parseGNOT(%q) accepted: %d", s, got)
850		}
851	}
852}
853
854// TestRenderGive: another amount's page asks the amount in gnoweb's form and answers
855// one Donate would take with its one-click link; any other gets the form again with
856// what the campaign can take, echoing nothing; a campaign that takes no gift says so
857// (API §5).
858func TestRenderGive(t *testing.T) {
859	c := testCampaign(9976, modeEscrow, 0)
860	id, unstore := storeCampaign(c)
861	defer unstore()
862	saved := escrowCap
863	defer func() { escrowCap, paused = saved, false }()
864	escrowCap = 30_000_000
865	c.state.raised = 12_000_000 // 18 GNOT of room
866	page := func(q string) string { return Render("c/" + id + "/give" + q) }
867	form := "\n<gno-form path=\"c/" + id + "/give\">\n  <gno-input name=\"amount\" placeholder=\"7.5\" />\n</gno-form>\n"
868	if p := page(""); !strings.Contains(p, form) || !strings.Contains(p, "Any amount from 1 GNOT to 18 GNOT, in GNOT.") || strings.Contains(p, "isn't") {
869		t.Errorf("the page for another amount:\n%s", p)
870	}
871	link := "**[Give 7.5 GNOT](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Donate&.send=7500000ugnot&anonymous=false&id=" + id + "&message=&tip=0)**"
872	for _, q := range []string{"?amount=7.5", "?amount=7%2C5"} {
873		if p := page(q); !strings.Contains(p, link) || strings.Contains(p, "<gno-form") {
874			t.Errorf("%s: no link for 7.5 GNOT, or the form again:\n%s", q, p)
875		}
876	}
877	if p := page("?amount=18"); !strings.Contains(p, "&.send=18000000ugnot&") {
878		t.Errorf("the whole room refused:\n%s", p)
879	}
880	for _, bad := range []string{"0.5", "18.000001", "50", "zz9", "7.5.1", "%3Cb%3Eecho", "1234567890123"} {
881		p := page("?amount=" + bad)
882		if !strings.Contains(p, "That isn't an amount this campaign can take.") || !strings.Contains(p, form) || strings.Contains(p, "func=Donate") {
883			t.Errorf("?amount=%s: a link, or no form and line saying why:\n%s", bad, p)
884		}
885		if strings.Contains(p, bad) {
886			t.Errorf("?amount=%s: the page echoes it:\n%s", bad, p)
887		}
888	}
889	if p := page("?amount=1"); !strings.Contains(p, "&.send=1000000ugnot&") {
890		t.Errorf("exactly the smallest gift refused:\n%s", p)
891	}
892	escrowCap = 0
893	if p := page(""); !strings.Contains(p, "Any amount from 1 GNOT, in GNOT.") {
894		t.Errorf("no cap, yet a most said:\n%s", p)
895	}
896	// No link while Bolo is paused, the campaign is hidden or closed, or the cap leaves
897	// less room than the smallest gift: the page says so, and links back.
898	none := func(why string) {
899		if p := page("?amount=1"); strings.Contains(p, "func=Donate") || strings.Contains(p, "<gno-form") || !strings.Contains(p, "Bolo offers no gift link for this campaign now: its page says why.") {
900			t.Errorf("%s: a link, or the form:\n%s", why, p)
901		}
902	}
903	paused = true
904	none("paused")
905	paused = false
906	c.state.listed = false
907	none("hidden from listings")
908	c.state.listed = true
909	escrowCap = 12_500_000 // half a GNOT of room
910	none("no room")
911	escrowCap = 0
912	c.state.status = statusClosed
913	none("closed")
914}
915
916// TestRenderMoney: where the money is comes from the books — the escrow's coins sent
917// outside Bolo said apart — and in direct mode all went straight through, with no money
918// out on its receipt; the bond reads as at stake, its hold over, or slashed.
919func TestRenderMoney(t *testing.T) {
920	c := testCampaign(9977, modeEscrow, 0)
921	id, unstore := storeCampaign(c)
922	defer unstore()
923	donor := testutils.TestAddress("moneydonor")
924	giveTo(c, 1, donor, 40_000_000)
925	defer byDonor.Remove(donor.String() + "/" + c.id.Binary())
926	c.state.withdrawn, c.state.refunded, c.state.everWithdrawn = 20_000_000, 2_000_000, true
927	testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 27_000_000))) // 18 held, and 9 sent outside Bolo
928	page := Render("c/" + id)
929	for _, want := range []string{"| Given: 1 gift from 1 donor | 40 |\n", "| Refunded to donors | 2 |\n", "| **Raised**, net of refunds | **38** |\n",
930		"| Withdrawn by the beneficiary | 20 |\n", "| Held in escrow | 18 |\n", "its bank balance is the 18 GNOT held, plus 9 GNOT sent there outside Bolo"} {
931		if !strings.Contains(page, want) {
932			t.Errorf("missing %q:\n%s", want, page)
933		}
934	}
935	c.state.bond = 5_000_000
936	if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond the organizer staked, lost if the campaign is delisted for fraud — not a guarantee\n") {
937		t.Errorf("a bond at stake:\n%s", p)
938	}
939	c.state.status, c.state.closedAt, c.state.closeReason = statusClosed, now()-c.params.BondHold, reasonOrganizer
940	if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond the organizer staked, whose hold ended "+when(now())+": no longer at stake, it goes back to the organizer\n") {
941		t.Errorf("a bond whose hold ended:\n%s", p)
942	}
943	c.state.bondSlashed = true
944	if p := Render("c/" + id); !strings.Contains(p, "; a 5 GNOT bond, slashed\n") {
945		t.Errorf("a slashed bond:\n%s", p)
946	}
947
948	d := testCampaign(9978, modeDirect, 0)
949	did, undirect := storeCampaign(d)
950	defer undirect()
951	giveTo(d, 1, donor, 15_000_000)
952	defer byDonor.Remove(donor.String() + "/" + d.id.Binary())
953	if p := Render("c/" + did); !strings.Contains(p, "| Given: 1 gift from 1 donor | 15 |\n| Sent straight to the beneficiary | 15 |\n\n") || strings.Contains(p, "Held in escrow") {
954		t.Errorf("a direct campaign's money:\n%s", p)
955	}
956	if r := Render("c/" + did + "/r/1"); strings.Contains(r, "/ledger)") || !strings.Contains(r, "Every gift went straight to the beneficiary, `"+d.beneficiary.String()+"`, in the same transaction.") {
957		t.Errorf("a direct receipt:\n%s", r)
958	}
959}