PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/grants/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / grants
Files
9 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/grants/v0
gno
0.9

Files (9)

  • README.mdmarkdown
  • gnomod.tomltoml
  • grants.gnogno
  • lifecycle.gnogno
  • program.gnogno
  • render.gnogno
  • grants_test.gnogno
  • program_test.gnogno
  • render_test.gnogno
  • render_test.gnogno
    1package grants23import (4	"strings"5	"testing"67	"gno.land/p/nt/testutils/v0"8

    Functions

    not supported for pure packages by the node (vm/qfuncs)

    Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.

    "gno.land/p/nt/uassert/v0"
    9 "gno.land/p/nt/urequire/v0"
    10)
    11
    12var (
    13 erin = testutils.TestAddress("erin") // applicant, mid-delivery
    14 frank = testutils.TestAddress("frank") // donor
    15 grace = testutils.TestAddress("grace") // beneficiary of a request she did not file
    16)
    17
    18// demo drives a program through every state the renderer has to show: a
    19// membership change that executed, a donation, a grant carried over one
    20// dissent, a milestone released and paid, a proof the board refused, a second
    21// attempt still under review, and a request turned down flat.
    22//
    23// It uses the library's own API only, so if a rule changes under it the
    24// pinned pages below move and the diff says exactly what changed.
    25func demo() *Program {
    26 p := NewProgram("ugnot", alice, bob, carol)
    27 p.Fund(frank, 3000, 100)
    28
    29 m, _ := p.Board.SubmitMemberChange(alice, dave, true,
    30 "Reviewed six of the last eight applications from the outside. Give them the seat.", 100)
    31 p.Board.Vote(alice, m.ID, true, "Proposed it.", 101)
    32 p.Board.Vote(bob, m.ID, true, "No objection.", 102)
    33
    34 g, _ := p.Apply(erin, "Port the p/nt/avl benchmarks to gno",
    35 "Three weeks. A benchmark harness, a report on the tree rebalancing costs, "+
    36 "and a PR against the monorepo.",
    37 "harness merged:1200,report published:800", 110)
    38 p.Board.Vote(alice, g.ID, true, "Cheap for what it tells us.", 111)
    39 p.Board.Vote(bob, g.ID, false, "The second milestone is vague.", 112)
    40 p.Board.Vote(carol, g.ID, true, "Vague but bounded. Worth it.", 113)
    41 p.Board.Vote(dave, g.ID, true, "Agreed with carol.", 114)
    42
    43 p.Board.SubmitProof(erin, g.ID, 0, Proof{
    44 Kind: "url", Ref: "https://github.com/gnolang/gno/pull/9999",
    45 Note: "Harness merged, 14 benchmarks, runs in CI.", Height: 120,
    46 })
    47 p.Review(alice, g.ID, 0, true, "Merged, I reviewed it.", 121, 3000)
    48 p.Review(carol, g.ID, 0, true, "Confirmed.", 122, 3000)
    49 p.Review(dave, g.ID, 0, true, "Confirmed.", 123, 3000)
    50
    51 p.Board.SubmitProof(erin, g.ID, 1, Proof{Kind: "text", Ref: "it is basically done", Height: 130})
    52 p.Review(alice, g.ID, 1, false, "That is not a report.", 131, 1800)
    53 p.Review(bob, g.ID, 1, false, "Agreed, no numbers.", 132, 1800)
    54 p.Review(carol, g.ID, 1, false, "Same.", 133, 1800)
    55
    56 p.Board.SubmitProof(erin, g.ID, 1, Proof{
    57 Kind: "url", Ref: "https://example.com/avl-rebalancing-costs",
    58 Note: "Rewritten with the measurements.", Height: 140,
    59 })
    60 p.Review(alice, g.ID, 1, true, "Much better.", 141, 1800)
    61
    62 o, _ := p.ApplyFor(erin, grace, "Her account is empty, so she cannot pay the gas to file this herself.",
    63 "Translate the onboarding guide to Portuguese",
    64 "One pass over the eight pages, reviewed by a second speaker.",
    65 "translation merged:600", 145)
    66 p.Board.Vote(alice, o.ID, true, "Cheap, and we have no pt-BR page at all.", 146)
    67 p.Board.Vote(bob, o.ID, true, "Fine.", 147)
    68 p.Board.Vote(carol, o.ID, true, "Fine.", 148)
    69
    70 n, _ := p.Apply(mallory, "Rewrite gnoweb in a different framework",
    71 "Six months, one person, no migration plan.", "the rewrite:250000", 150)
    72 p.Board.Vote(alice, n.ID, false, "No migration plan and no second maintainer.", 151)
    73 p.Board.Vote(bob, n.ID, false, "Out of scope for this board.", 152)
    74 p.Board.Vote(carol, n.ID, false, "Same.", 153)
    75 return p
    76}
    77
    78// view wraps demo in a Renderer configured the way a realm would. Balance is
    79// 3000 donated minus the 1200 tranche already paid.
    80func view() Renderer {
    81 return Renderer{
    82 Program: demo(),
    83 Title: "Example grant board",
    84 Intro: "Anyone may ask this board for money.",
    85 Path: "gno.land/r/example/board/v0",
    86 Link: "/r/example/board/v0",
    87 Treasury: testutils.TestAddress("treasury"),
    88 Balance: 1800,
    89 Footer: "Built on [p/moul/grants/v0](/p/moul/grants/v0).",
    90 }
    91}
    92
    93func TestDemoReachedEveryStateTheseExamplesPin(t *testing.T) {
    94 p := demo()
    95 uassert.Equal(t, 4, p.Board.MemberCount(), "the membership change executed")
    96 uassert.Equal(t, 4, p.Board.Size())
    97 uassert.Equal(t, "completed", p.Board.Get(1).Status.String())
    98 uassert.Equal(t, "approved", p.Board.Get(2).Status.String())
    99 uassert.Equal(t, "approved", p.Board.Get(3).Status.String())
    100 uassert.Equal(t, "rejected", p.Board.Get(4).Status.String())
    101
    102 o := p.Board.Get(3)
    103 uassert.True(t, o.OnBehalf(), "request 3 is filed by erin for grace")
    104 uassert.Equal(t, grace.String(), o.Payee().String())
    105
    106 g := p.Board.Get(2)
    107 uassert.True(t, g.Milestones[0].Released, "one tranche paid")
    108 uassert.Equal(t, int64(1200), p.Disbursed())
    109 uassert.Equal(t, int64(1400), p.Committed(), "800 still owed on 2, 600 on 3")
    110 uassert.Equal(t, int64(3000), p.Raised())
    111 uassert.Equal(t, 1, len(p.Payments))
    112 uassert.Equal(t, 2, len(g.Milestones[1].Attempts), "one refused, one in flight")
    113 uassert.Equal(t, "refused", g.Milestones[1].Attempts[0].Outcome.String())
    114 uassert.Equal(t, "under review", g.Milestones[1].Attempts[1].Outcome.String())
    115}
    116
    117// Deliberately over an EMPTY program: the routing is what is under test, and
    118// rendering the full demo four times costs seconds of VM time for nothing.
    119func TestRenderUnknownPaths(t *testing.T) {
    120 v := Renderer{Program: NewProgram("ugnot", alice), Title: "Example grant board", Link: "/r/x/v0"}
    121 uassert.Equal(t, "# Example grant board\n\nThere is no request 99.\n", v.Render("request/99"))
    122 uassert.Equal(t, "# Example grant board\n\n`abc` is not a request number.\n", v.Render("request/abc"))
    123 uassert.True(t, len(v.Render("nope")) > 0)
    124 uassert.Equal(t, v.Render(""), v.Render("/"), "the root is the root either way")
    125}
    126
    127// ExampleRendererIndex pins the board page.
    128func ExampleRendererIndex() {
    129 print(view().Render(""))
    130 // Output:
    131 // # Example grant board
    132 //
    133 // Anyone may ask this board for money.
    134 //
    135 // ## Treasury
    136 //
    137 // | Field | Value |
    138 // |---|---|
    139 // | Address | `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` |
    140 // | Balance | 1800 ugnot |
    141 // | Promised | 1400 ugnot |
    142 // | Unpromised | 400 ugnot |
    143 // | Donated | 3000 ugnot |
    144 // | Paid out | 1200 ugnot |
    145 //
    146 // ## Board
    147 //
    148 // A decision needs a majority of the members eligible to cast a ballot on it,
    149 // recomputed every time: the party a decision is about never votes on it, so an
    150 // applicant who sits on the board shrinks the room rather than packing it.
    151 //
    152 // | Member | Joined at height |
    153 // |---|---|
    154 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | 102 |
    155 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | 0 |
    156 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | 0 |
    157 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | 0 |
    158 //
    159 // ## Requests
    160 //
    161 // | # | Kind | Title | For | Asking | Status | Ballots |
    162 // |---|---|---|---|---|---|---|
    163 // | [1](/r/example/board/v0:request/1) | membership | Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l | - | nothing | completed | 2 for, 0 against |
    164 // | [2](/r/example/board/v0:request/2) | grant | Port the p/nt/avl benchmarks to gno | `g1v4exjm…sj8d` | 2000 ugnot | approved | 3 for, 1 against |
    165 // | [3](/r/example/board/v0:request/3) | grant | Translate the onboarding guide to Portuguese | `g1vaexzc…qsw9` (filed by g1v4exjm…sj8d) | 600 ugnot | approved | 3 for, 0 against |
    166 // | [4](/r/example/board/v0:request/4) | grant | Rewrite gnoweb in a different framework | `g1d4skcm…cwc9` | 250000 ugnot | rejected | 0 for, 3 against |
    167 //
    168 // ## Where the money went
    169 //
    170 // [The ledger](/r/example/board/v0:ledger) lists every donation in and every tranche
    171 // out, with the height, the payee and the milestone it paid for.
    172 //
    173 // ## Calling it
    174 //
    175 // ```sh
    176 // # put money in
    177 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Fund -send 5000000ugnot ...
    178 // # ask for some
    179 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Apply \
    180 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
    181 // # ask on behalf of someone who cannot pay the gas to ask
    182 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func ApplyFor \
    183 // -args g1... -args 'their account is empty' \
    184 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
    185 // # decide (members only)
    186 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Vote -args 1 -args true -args 'reason' ...
    187 // # show your work, then get paid for it
    188 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func SubmitProof \
    189 // -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ...
    190 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Review -args 1 -args 0 -args true -args 'looks done' ...
    191 // ```
    192 //
    193 // Built on [p/moul/grants/v0](/p/moul/grants/v0).
    194}
    195
    196// ExampleRendererRequest pins the richest page: a grant mid-delivery with one
    197// tranche paid, a refused proof kept on the record, and a second attempt
    198// still being reviewed.
    199func ExampleRendererRequest() {
    200 print(view().Render("request/2"))
    201 // Output:
    202 // # Request 2: Port the p/nt/avl benchmarks to gno
    203 //
    204 // | Field | Value |
    205 // |---|---|
    206 // | Kind | grant |
    207 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
    208 // | Status | approved |
    209 // | Filed at height | 110 |
    210 // | Decided at height | 114 |
    211 // | Asking | 2000 ugnot |
    212 // | Paid so far | 1200 ugnot |
    213 // | Still owed | 800 ugnot |
    214 //
    215 // ## The ask
    216 //
    217 //
    218 //
    219 // Three weeks. A benchmark harness, a report on the tree rebalancing costs, and a PR against the monorepo.
    220 //
    221 //
    222 //
    223 // ## Ballots
    224 //
    225 // Needs 3 of 4 eligible members. Standing: 3 for, 1 against.
    226 //
    227 // | Member | Vote | Height | Reason |
    228 // |---|---|---|---|
    229 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 111 | Cheap for what it tells us\. |
    230 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | against | 112 | The second milestone is vague\. |
    231 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 113 | Vague but bounded\. Worth it\. |
    232 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | for | 114 | Agreed with carol\. |
    233 //
    234 // ## Milestones
    235 //
    236 // Earned in order. The applicant submits a proof, the board reviews that proof,
    237 // and the verdict that carries also moves the coins.
    238 //
    239 // ### 1. harness merged: 1200 ugnot
    240 //
    241 // Released at height 123, paid to `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d`.
    242 //
    243 // **Proof 1** (url, height 120, accepted)
    244 //
    245 // ```
    246 // https://github.com/gnolang/gno/pull/9999
    247 // ```
    248 //
    249 //
    250 //
    251 // Harness merged, 14 benchmarks, runs in CI.
    252 //
    253 //
    254 //
    255 // | Member | Vote | Height | Reason |
    256 // |---|---|---|---|
    257 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 121 | Merged, I reviewed it\. |
    258 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | accept | 122 | Confirmed\. |
    259 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | accept | 123 | Confirmed\. |
    260 //
    261 // ### 2. report published: 800 ugnot
    262 //
    263 // A proof is under review.
    264 //
    265 // **Proof 1** (text, height 130, refused)
    266 //
    267 // ```
    268 // it is basically done
    269 // ```
    270 //
    271 // | Member | Vote | Height | Reason |
    272 // |---|---|---|---|
    273 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | refuse | 131 | That is not a report\. |
    274 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | refuse | 132 | Agreed, no numbers\. |
    275 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | refuse | 133 | Same\. |
    276 //
    277 // **Proof 2** (url, height 140, under review)
    278 //
    279 // ```
    280 // https://example.com/avl-rebalancing-costs
    281 // ```
    282 //
    283 //
    284 //
    285 // Rewritten with the measurements.
    286 //
    287 //
    288 //
    289 // | Member | Vote | Height | Reason |
    290 // |---|---|---|---|
    291 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 141 | Much better\. |
    292}
    293
    294// ExampleRendererMembership pins a membership request, which asks for no
    295// money and therefore renders no milestones.
    296func ExampleRendererMembership() {
    297 print(view().Render("request/1"))
    298 // Output:
    299 // # Request 1: Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l
    300 //
    301 // | Field | Value |
    302 // |---|---|
    303 // | Kind | membership |
    304 // | Applicant | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` |
    305 // | Subject | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` |
    306 // | Effect | add to the board |
    307 // | Status | completed |
    308 // | Filed at height | 100 |
    309 // | Decided at height | 102 |
    310 //
    311 // ## The ask
    312 //
    313 //
    314 //
    315 // Reviewed six of the last eight applications from the outside. Give them the seat.
    316 //
    317 //
    318 //
    319 // ## Ballots
    320 //
    321 // Needs 2 of 3 eligible members. Standing: 2 for, 0 against.
    322 //
    323 // | Member | Vote | Height | Reason |
    324 // |---|---|---|---|
    325 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 101 | Proposed it\. |
    326 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 102 | No objection\. |
    327}
    328
    329// ExampleRendererLedger pins the money trail.
    330func ExampleRendererLedger() {
    331 print(view().Render("ledger"))
    332 // Output:
    333 // # Example grant board: the ledger
    334 //
    335 // Every coin in and every coin out, in the order it happened. Coins sent
    336 // straight to `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` land in the treasury
    337 // without appearing here, which is why `Fund` exists: it is the same transfer
    338 // with a name attached.
    339 //
    340 // ## In
    341 //
    342 // | Height | From | Amount |
    343 // |---|---|---|
    344 // | 100 | `g1veexzmntta047h6lta047h6lta047h6lthxxu5` | 3000 ugnot |
    345 //
    346 // Total in: 3000 ugnot
    347 //
    348 // ## Out
    349 //
    350 // | Height | Request | Milestone | To | Amount |
    351 // |---|---|---|---|---|
    352 // | 123 | [2](/r/example/board/v0:request/2) | 1 | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | 1200 ugnot |
    353 //
    354 // Total out: 1200 ugnot
    355 //
    356 // Balance now: 1800 ugnot. [Back to the board](/r/example/board/v0).
    357}
    358
    359// ExampleRendererOnBehalf pins a request one address filed for another: the
    360// beneficiary row, the callout carrying the applicant's reason, and the "For"
    361// column that says on the board page who the money would reach.
    362func ExampleRendererOnBehalf() {
    363 print(view().Render("request/3"))
    364 // Output:
    365 // # Request 3: Translate the onboarding guide to Portuguese
    366 //
    367 // | Field | Value |
    368 // |---|---|
    369 // | Kind | grant |
    370 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
    371 // | Beneficiary | `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9` |
    372 // | Status | approved |
    373 // | Filed at height | 145 |
    374 // | Decided at height | 148 |
    375 // | Asking | 600 ugnot |
    376 // | Paid so far | 0 ugnot |
    377 // | Still owed | 600 ugnot |
    378 //
    379 // ## Filed for someone else
    380 //
    381 // The applicant is not the payee: every tranche of this request pays
    382 // `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9`. Both addresses are barred from voting on it, and
    383 // either may submit a proof. The reason given:
    384 //
    385 // > Her account is empty, so she cannot pay the gas to file this herself\.
    386 //
    387 // Nothing checks that reason. It is a claim by the applicant, and the point of
    388 // printing it here is that a member can check it before voting.
    389 //
    390 // ## The ask
    391 //
    392 // One pass over the eight pages, reviewed by a second speaker.
    393 //
    394 // ## Ballots
    395 //
    396 // Needs 3 of 4 eligible members. Standing: 3 for, 0 against.
    397 //
    398 // | Member | Vote | Height | Reason |
    399 // |---|---|---|---|
    400 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 146 | Cheap, and we have no pt\-BR page at all\. |
    401 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 147 | Fine\. |
    402 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 148 | Fine\. |
    403 //
    404 // ## Milestones
    405 //
    406 // Earned in order. The applicant submits a proof, the board reviews that proof,
    407 // and the verdict that carries also moves the coins.
    408 //
    409 // ### 1. translation merged: 600 ugnot
    410 //
    411 // Waiting on a proof from the applicant.
    412}
    413
    414// injected is every dangerous markdown sequence a caller can type, in one
    415// string, so a slot that forgets to escape shows up as a live link or a
    416// gnoweb tag in the assertions below rather than on a deployed page.
    417const injected = "[click](https://evil.example) ![x](https://evil.example/p.png) " +
    418 "<gno-columns> | fake | cell | ```\nsecond line\n"
    419
    420// injectedShort is the same attack inside the 100-character limit a title and
    421// a milestone name are held to.
    422const injectedShort = "[click](https://evil.example) <gno-columns>|x|\nnext"
    423
    424// TestEveryCallerSuppliedStringIsEscaped drives one board through every slot
    425// a caller controls and asserts that none of them can reshape the page.
    426//
    427// The bar is not the same in every slot, on purpose. A one-line slot (a
    428// title, a milestone name, the reason on a ballot, the reason for filing on
    429// someone else's behalf) is chrome the page lays out, so nothing a caller
    430// typed may survive as markup there. A prose slot (the body of an
    431// application, the note on a proof) goes through sanitize.Block, which
    432// PRESERVES inline links and emphasis by design: a grant application whose
    433// link to the merged PR renders as literal text is a worse page, and the
    434// sanitizer's own contract is that prose formats. What Block still kills is
    435// everything structural, so the paragraph cannot leave its paragraph.
    436//
    437// Assertions are on the dangerous SEQUENCE, never on the exact escaped bytes:
    438// those belong to the sanitizer, they change when it changes, and a test that
    439// pins them fails for the wrong reason.
    440func TestEveryCallerSuppliedStringIsEscaped(t *testing.T) {
    441 p := NewProgram("ugnot", alice, bob)
    442 r, err := p.ApplyFor(erin, grace, injected, injectedShort, injected, injectedShort+":100", 10)
    443 urequire.NoError(t, err)
    444 p.Board.Vote(alice, r.ID, true, injected, 11)
    445 p.Board.Vote(bob, r.ID, true, injected, 12)
    446 urequire.NoError(t, p.Board.SubmitProof(erin, r.ID, 0, Proof{
    447 Kind: "url", Ref: injected, Note: injected, Height: 13,
    448 }))
    449 p.Board.Review(alice, r.ID, 0, false, injected, 14)
    450
    451 v := Renderer{Program: p, Title: "Board", Path: "gno.land/r/x/v0", Link: "/r/x/v0", Balance: 0}
    452
    453 // Every structural line of every page: a table row, a heading, a
    454 // blockquote. Nothing a caller typed may render as markup in one.
    455 for _, page := range []string{"", "request/1", "ledger"} {
    456 for _, line := range strings.Split(v.Render(page), "\n") {
    457 if !strings.HasPrefix(line, "|") && !strings.HasPrefix(line, "#") &&
    458 !strings.HasPrefix(line, ">") {
    459 continue
    460 }
    461 for _, dead := range []string{"](http", "![", "<gno-"} {
    462 uassert.False(t, strings.Contains(line, dead),
    463 "page "+page+" line still carries "+dead+": "+line)
    464 }
    465 }
    466 }
    467
    468 // A table row keeps exactly its own cells: an unescaped pipe would open a
    469 // column and an unescaped newline would end the row early, and both
    470 // reshape the table silently rather than failing.
    471 for _, line := range strings.Split(v.Render("request/1"), "\n") {
    472 if !strings.HasPrefix(line, "| `"+alice.String()+"`") {
    473 continue
    474 }
    475 uassert.Equal(t, 5, strings.Count(line, "|")-strings.Count(line, "\\|"),
    476 "the ballot row kept its four cells: "+line)
    477 }
    478
    479 // An escaper that ate the content would pass every assertion above.
    480 out := v.Render("request/1")
    481 uassert.True(t, strings.Contains(out, "click"), "the readable text survived")
    482 uassert.True(t, strings.Contains(out, "second line"), "the second line survived")
    483
    484 // The structural attack is dead in the prose slots too, even though the
    485 // link in them is deliberately alive.
    486 body := out[strings.Index(out, "## The ask"):strings.Index(out, "## Ballots")]
    487 uassert.False(t, strings.Contains(body, "\n<gno-"), "no gnoweb tag at a line start")
    488 uassert.False(t, strings.Contains(body, "\n| "), "prose cannot open a table")
    489 uassert.False(t, strings.Contains(body, "\n#"), "prose cannot forge a heading")
    490}
    491