Transaction
4307823B4500B9…900150312877
Block 405,672 · index 3 · indexed
Summary
- Hash
- 4307823B4500B960A36510F0709AA11C3793A47AEB3F07C0A7BC900150312877
- Block
- 405,672
- Size
- 27771 bytes
- Gas used
- 35,400,872 / 83,398,600
- Fee
- 250195ugnot
- Status
- success
Messages
- Attached funds
- 11000000ugnot
- Package
- gno.land/p/moul/reactions/v0
Arguments · 11
- #1reactions
- #2README.md
- #3# `gno.land/p/moul/reactions/v0` The engine behind an embeddable reaction block: pages, tallies, a closed emoji palette, and the markdown that shows them. No realm state, no chain reads, no authority decisions. The realm on top owns all three. Live realm: [`gno.land/r/moul/reactions/v0`](https://github.com/moul/gno-contracts/tree/main/r/moul/reactions). ## The model A `Board` maps a **page key** to a `Page`. A page holds exactly three things: | | | |---|---| | a count per palette key | what the strip shows | | the **current** reaction of each address | so a tally counts addresses and not clicks, and a reader can change their mind or take it back | | the last reaction | which key, from whom, at what height | There is no text field anywhere. That is what makes the whole thing runnable with no moderation: reactions come from a closed set of six, so there is nothing to take down. ```go b := reactions.NewBoard() b.React("gno.land/r/moul/home", caller, reactions.KeyFire, height) // false if rejected b.Unreact("gno.land/r/moul/home", caller) // false if there was none p := b.Page("gno.land/r/moul/home") // nil when untouched, and nil is safe to use p.Count(reactions.KeyFire) // 1 p.Total() // addresses reacting, not clicks p.Of(caller) // "fire", or "" for none key, who, height := p.Last() md := reactions.Block(realmPath, page, p) // the whole widget ``` Every `Page` read is nil-safe and answers as if the page were empty, so a host realm renders the block without ever checking whether the page exists. ## Three decisions worth knowing before you use it **A reaction key is an ASCII name, not a glyph.** `"up"`, not `👍`. A name survives a URL query parameter, an avl key and a markdown escaper with no encoding question anywhere, and the palette can change a glyph without rewriting stored state. `Emoji(key)` is the presentation half. **No glyph in the palette uses U+FE0F or a zero-width joiner.** The link title goes through `ui.Inline`, which strips exactly those, so a glyph needing a variation selector would come out the other side as its monochrome form. ❤️ is the casualty; `heart` is 💜, a codepoint that is colour on its own. Pinned by `TestEmojiSurviveTheEscaper`. **`Unreact` does not rewind the last-reaction line.** It records an event that happened, and recomputing it would mean walking every address on the page, so `Page.Last` keeps naming a reaction nobody holds any more. What is *shown* is a separate decision, and `Summary` makes it: a page whose reactors have all left renders as empty, not as a withdrawn reaction. Gating on the last key alone would print "last 👍 by `g1…` · 0 reactions" above an empty strip, which contradicts itself. `TestSummaryGoesEmptyWhenEveryoneLeaves` pins the pair. ## Page keys A page key is a package path, and `ValidPage` is what bounds this package's exposure: at least one `/`, no empty element, and only lowercase ASCII letters, digits, `.`, `-`, `_`, `:` and `/`, up to `MaxPageLen` bytes. That excludes every character a markdown escaper exists for, plus every byte above ASCII, so a stored key can never be a bidi run, a zero-width joiner or an image. `:` is in the set so a host realm can key one block per article on the path it already serves: `gno.land/r/you/blog:hello-world`. This package only ever puts a key in a URL. A realm that shows the key **as text** escapes it there; `ValidPage` and `ui.Inline` protect against different mistakes. ## Not to be confused with [`r/moul/x/daily/reactions`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/daily/reactions) is an older, unrelated experiment: a single self-contained board with its own "topics", no embedding, and no way for a reader to change or remove a reaction. Nothing is shared between the two. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/p/moul/reactions/v0" gno = "0.9"
- #6palette.gno
- #7package reactions // The palette: the closed set of reactions, and the only moderation policy // this package has. // // It is closed on purpose. A free-text reaction is a comment, a comment needs // moderation, and moderation is the thing worth not building. Six is enough to // say something and few enough to fit on one line on a phone. // // Every glyph here is a SINGLE codepoint with no variation selector (no // U+FE0F) and no zero-width joiner. That is a constraint, not a coincidence: // these strings pass through a markdown escaper that strips zero-width and // bidi characters, and a glyph that needs U+FE0F to render would come out the // other side as its bare monochrome form. ❤️ is the obvious casualty, which is // why "heart" is 💜 (U+1F49C), a codepoint that is a colour emoji on its own. const ( // KeyUp is the default, first in the strip. KeyUp = "up" // KeyHeart is affection rather than agreement. KeyHeart = "heart" // KeyFire is "this is good". KeyFire = "fire" // KeyParty is a congratulation. KeyParty = "party" // KeyRocket is "ship it". KeyRocket = "rocket" // KeyEyes is "I am watching this", the only one that is not praise. KeyEyes = "eyes" ) // order is the display order of the palette, which is also the order the // strip renders in. A slice and not a map range: gno map iteration order is // unspecified, and a Render that reshuffles between identical calls is a // consensus bug. var order = []string{KeyUp, KeyHeart, KeyFire, KeyParty, KeyRocket, KeyEyes} // palette maps a key to its glyph. Written as \U escapes rather than literal // emoji so that the source says exactly which codepoint it means and a review // diff cannot hide a lookalike. var palette = map[string]string{ KeyUp: "\U0001F44D", // 👍 KeyHeart: "\U0001F49C", // 💜 KeyFire: "\U0001F525", // 🔥 KeyParty: "\U0001F389", // 🎉 KeyRocket: "\U0001F680", // 🚀 KeyEyes: "\U0001F440", // 👀 } // Keys returns the palette keys in display order. The returned slice is a copy, // so a caller cannot reorder the palette for everyone else. func Keys() []string { out := make([]string, len(order)) copy(out, order) return out } // Valid reports whether key names a reaction in the palette. func Valid(key string) bool { _, ok := palette[key] return ok } // Emoji returns the glyph for a palette key, or "" when the key is not one. func Emoji(key string) string { return palette[key] }
- #8reactions.gno
- #9// Package reactions is the engine behind an embeddable reaction block: the // one-click "👍 3 · 🔥 1" strip a realm drops into its own Render, keyed on // the PAGE it is showing rather than on the realm that stores it. // // That indirection is the whole idea, and it is the one the web already // settled on: a single hosted widget, embedded verbatim everywhere, deciding // what to show from the page identifier it is handed. One realm holds every // tally, every host realm ships the same two lines, and no host realm stores // anything. // // # The model // // A [Board] maps a page key to a [Page]. A page holds three things and // deliberately nothing else: // // - a count per palette key, // - the CURRENT reaction of each address, so a tally counts addresses and // not clicks, and so a reader can change their mind, // - the last reaction: which key, from whom, at what height. // // There is no text anywhere, which is the point. Text needs moderation; // a closed palette of six emoji does not. // // # Keys are names, not glyphs // // A reaction is stored and addressed as an ASCII name ("up", "fire"), and the // glyph is presentation. A name survives a URL query parameter, an avl key and // a markdown escaper without a single encoding question, and the palette can // gain a glyph without rewriting stored state. // // # Page keys // // A page key is a package path (the full one, chain domain included, as // unsafe.CurrentRealm().PkgPath() reports it). [ValidPage] is what bounds this // package's exposure: a key is path-shaped, ASCII, and at most [MaxPageLen] // bytes, so a stored key can never be a markdown payload, a bidi run or an // unbounded blob. This package only ever puts it in a URL; a realm that shows // the key as text escapes it there, because a validator and an escaper protect // against different mistakes. package reactions import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/nt/avl/v0" ) // MaxPageLen is the longest page key accepted. Comfortably past the longest // real package path (the deepest in moul/gno-contracts is 34 bytes) and short // enough that a key can never be a payload. const MaxPageLen = 120 // Board is a set of pages, each with its own tally. The zero value is not // usable; call [NewBoard]. type Board struct { pages *avl.Tree // page key -> *Page } // NewBoard returns an empty board. func NewBoard() *Board { return &Board{pages: avl.NewTree()} } // Page is one page's tally. // // Every read method is nil-safe and answers as if the page were empty, so a // caller can hand [Board.Page]'s result straight to [Block] without checking. type Page struct { counts *avl.Tree // palette key -> int by *avl.Tree // address string -> palette key lastKey string lastWho address lastAt int64 } // React records who's reaction on page, replacing whatever they had there // before. It reports whether anything was stored: false means the page key or // the palette key was rejected, and nothing changed. // // height is passed in rather than read from the chain, so this package stays // testable without a realm and so the caller decides what "when" means. func (b *Board) React(page string, who address, key string, height int64) bool { if !ValidPage(page) || !Valid(key) { return false } p := b.page(page) // Replacing a reaction decrements the old bucket first. Without this a // reader who clicks twice counts twice, and the tally stops meaning // "how many addresses", which is the only thing it is allowed to mean. if prev := p.Of(who); prev != "" { if prev == key { // Same key again: still record it as the last reaction, since the // click happened, but leave the counts alone. p.lastKey, p.lastWho, p.lastAt = key, who, height return true } p.add(prev, -1) } p.by.Set(who.String(), key) p.add(key, 1) p.lastKey, p.lastWho, p.lastAt = key, who, height return true } // Unreact removes who's reaction from page. It reports whether there was one. // // The last-reaction line is deliberately NOT rewound: it records an event that // happened, and recomputing it would mean walking every address on the page. // [Page.Last] therefore keeps naming a reaction nobody holds any more, which is // the honest answer to "what happened here last". What is shown is a separate // decision, and [Summary] makes it: a page with no reactors left renders as // empty, not as a withdrawn reaction. func (b *Board) Unreact(page string, who address) bool { p := b.Page(page) if p == nil { return false } prev := p.Of(who) if prev == "" { return false } p.by.Remove(who.String()) p.add(prev, -1) return true } // Page returns the tally for a page, or nil when nothing was ever recorded // there. The nil is safe to use: every [Page] read method handles it. func (b *Board) Page(page string) *Page { v := b.pages.Get(page) if v == nil { return nil } return v.(*Page) } // page returns the tally for a page, creating it if needed. func (b *Board) page(key string) *Page { if p := b.Page(key); p != nil { return p } p := &Page{counts: avl.NewTree(), by: avl.NewTree()} b.pages.Set(key, p) return p } // Pages reports how many pages have ever been reacted to. func (b *Board) Pages() int { return b.pages.Size() } // Each visits every page in key order until fn returns true. func (b *Board) Each(fn func(page string, p *Page) bool) { b.pages.Iterate("", "", func(key string, value any) bool { return fn(key, value.(*Page)) }) } // add changes a bucket by delta, dropping it when it reaches zero so an // emptied page leaves no residue behind. func (p *Page) add(key string, delta int) { n := p.Count(key) + delta if n <= 0 { p.counts.Remove(key) return } p.counts.Set(key, n) } // Count reports how many addresses currently hold this reaction. func (p *Page) Count(key string) int { if p == nil { return 0 } v := p.counts.Get(key) if v == nil { return 0 } return v.(int) } // Total is the number of addresses reacting to this page. It equals the sum of // every bucket, because an address holds at most one reaction. func (p *Page) Total() int { if p == nil { return 0 } return p.by.Size() } // Of returns who's current reaction key, or "" when they have none. func (p *Page) Of(who address) string { if p == nil { return "" } v := p.by.Get(who.String()) if v == nil { return "" } return v.(string) } // Last returns the most recent reaction: its palette key, who made it, and the // height it was made at. The key is "" when the page has never been reacted to. // // It can name a reaction that is no longer counted, because [Board.Unreact] // does not rewind it. That is the honest answer to "what happened here last", // which is the question the line under the block asks. func (p *Page) Last() (key string, who address, height int64) { if p == nil { return "", "", 0 } return p.lastKey, p.lastWho, p.lastAt } // ValidPage reports whether page is an acceptable key. // // The rule is "a gnoweb render path, and nothing that is not one": at least one // "/", no empty element, no leading or trailing "/", and only lowercase ASCII // letters, digits, ".", "-", "_", ":" and "/". That excludes every character a // markdown escaper exists for, plus every byte above ASCII, so a stored key // cannot carry a bidi run, a zero-width joiner or an image. // // Two of the exclusions are STRUCTURAL and not about markdown, so widening this // set is not free. gnoweb parses a render path as `<path>[:<args>][$<webargs>]` // and cuts on "$" BEFORE it cuts on ":" (gno.land/pkg/gnoweb/weburl/url.go, // ParseFromURL, read 2026-09-28), so a "$" in a key would truncate it on the // way back in, and a "%" would be eaten by the unescape that follows. ":" is // safe only because that second cut takes the FIRST colon, which leaves the // rest of the key intact however many it carries. // // ":" is in the set because a host realm keying the block per article wants the // path it already serves: "gno.land/r/you/blog:hello-world". func ValidPage(page string) bool { if page == "" || len(page) > MaxPageLen { return false } if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") || strings.Contains(page, "//") || !strings.Contains(page, "/") { return false } for i := 0; i < len(page); i++ { c := page[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '/' || c == '.' || c == '-' || c == '_' || c == ':': default: return false } } return true } // Block renders the embeddable widget: the clickable palette with its counts, // then one line saying what happened here last. // // realmPath is the realm that owns React, given as the full package path from // its gnomod.toml module line. It is a parameter and not a constant because // this package is the engine and not the deployment: a second reactions realm, // on another chain or in a test, renders through the same code. // // page appears only in URL positions here: as a txlink query argument, which // txlink percent-encodes, and inside [PageURL]. Nothing renders it as text, and // [ValidPage]'s charset is what keeps the [PageURL] form safe. A realm showing // the key itself must still run it through ui.Inline. // // The result ends with a single newline and contains exactly one blank line, // so it drops into a host realm's Render between two sections without a // separator of its own. func Block(realmPath, page string, p *Page) string { var b strings.Builder for i, key := range order { if i > 0 { b.WriteString(" ") } title := palette[key] if n := p.Count(key); n > 0 { title += " " + strconv.Itoa(n) } b.WriteString(ui.ActionIn(realmPath, title, "React", "page", page, "key", key)) } b.WriteString("\n\n") b.WriteString(md.Italic(Summary(realmPath, page, p))) b.WriteString("\n") return b.String() } // Summary is the one line under the block: the last reaction, and a link to // the page's own view on the reactions realm. Plain markdown, no wrapper, so a // caller can put it somewhere else. func Summary(realmPath, page string, p *Page) string { key, who, _ := p.Last() n := p.Total() // Two different ways to have nothing to say, and both have to be checked. // key == "" is a page nobody ever touched. n == 0 is a page whose reactors // have all left: [Board.Unreact] deliberately does not rewind the last // reaction, so key still names one. Gating on key alone would print // "last <emoji> by <addr> - 0 reactions" above an empty strip, which // contradicts itself and keeps attributing a reaction its author took back. if key == "" || n == 0 { return "no reactions yet" } label := strconv.Itoa(n) + " reaction" if n != 1 { label += "s" } return "last " + palette[key] + " by " + ui.Addr(who) + " · " + md.Link(label, PageURL(realmPath, page)) } // RealmURL is the gnoweb path of a realm given as a package path. // // The chain domain is the first element of a package path and a gnoweb path is // the rest of it. Derived here rather than read from chain/runtime so this // package keeps no chain import of its own and the rule stays in one place. // Returns "" for something that is not a package path. func RealmURL(realmPath string) string { i := strings.Index(realmPath, "/") if i < 0 { return "" } return realmPath[i:] } // PageURL is the link to one page's view on the reactions realm: [RealmURL] // with the page as the gnoweb render argument after a colon. func PageURL(realmPath, page string) string { base := RealmURL(realmPath) if base == "" { return "" } return base + ":" + page }
- #10reactions_test.gno
- #11package reactions import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) const ( realmPath = "gno.land/r/test/reactions/v0" homePage = "gno.land/r/moul/home" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) func TestValidPage(t *testing.T) { cases := []struct { page string want bool }{ {"gno.land/r/moul/home", true}, {"gno.land/p/moul/kit/ui/v0", true}, {"gno.land/r/moul/x/daily/b58demo", true}, {"a/b", true}, {"gno.land/r/you/blog:hello-world", true}, // one page per article {"", false}, {"nolslash", false}, {"/leading", false}, {"trailing/", false}, {"double//slash", false}, {"gno.land/r/MOUL/home", false}, // uppercase is not a path here {"gno.land/r/moul/home ", false}, // a space is not path-shaped {"gno.land/r/[x](http://e.vil)", false}, // a markdown link cannot be a key {"gno.land/r/moul/home", false}, // bidi override {strings.Repeat("a/", MaxPageLen) + "b", false}, // over the cap {strings.Repeat("a", MaxPageLen-2) + "/b", true}, // exactly at the cap {strings.Repeat("a", MaxPageLen-1) + "/b", false}, // one byte over } for _, tc := range cases { uassert.Equal(t, tc.want, ValidPage(tc.page), tc.page) } } func TestPaletteIsClosed(t *testing.T) { uassert.Equal(t, 6, len(Keys())) for _, k := range Keys() { uassert.True(t, Valid(k), k) uassert.NotEqual(t, "", Emoji(k), k) } uassert.False(t, Valid("poop")) uassert.Equal(t, "", Emoji("poop")) // Keys returns a copy: reordering it must not reorder the palette. ks := Keys() ks[0] = "clobbered" uassert.Equal(t, KeyUp, Keys()[0]) } // TestNilPageReadsAsEmpty is what lets a host realm render the block without // ever checking whether the page exists. func TestNilPageReadsAsEmpty(t *testing.T) { var p *Page uassert.Equal(t, 0, p.Count(KeyUp)) uassert.Equal(t, 0, p.Total()) uassert.Equal(t, "", p.Of(alice)) key, who, at := p.Last() uassert.Equal(t, "", key) uassert.Equal(t, address(""), who) uassert.Equal(t, int64(0), at) } func TestReactCountsAddressesNotClicks(t *testing.T) { b := NewBoard() uassert.True(t, b.React(homePage, alice, KeyUp, 100)) uassert.True(t, b.React(homePage, alice, KeyUp, 101)) uassert.True(t, b.React(homePage, alice, KeyUp, 102)) p := b.Page(homePage) uassert.Equal(t, 1, p.Count(KeyUp), "three clicks from one address is one reaction") uassert.Equal(t, 1, p.Total()) // The repeat still counts as the last event, because it happened. _, _, at := p.Last() uassert.Equal(t, int64(102), at) } func TestChangingReactionMovesTheCount(t *testing.T) { b := NewBoard() b.React(homePage, alice, KeyUp, 100) b.React(homePage, bob, KeyUp, 101) b.React(homePage, alice, KeyFire, 102) p := b.Page(homePage) uassert.Equal(t, 1, p.Count(KeyUp)) uassert.Equal(t, 1, p.Count(KeyFire)) uassert.Equal(t, 2, p.Total(), "changing a reaction never changes the head count") uassert.Equal(t, KeyFire, p.Of(alice)) uassert.Equal(t, KeyUp, p.Of(bob)) } func TestUnreact(t *testing.T) { b := NewBoard() b.React(homePage, alice, KeyUp, 100) uassert.False(t, b.Unreact("gno.land/r/never/touched", alice), "no page") uassert.False(t, b.Unreact(homePage, bob), "no reaction of bob's") uassert.True(t, b.Unreact(homePage, alice)) uassert.False(t, b.Unreact(homePage, alice), "removing twice is not an error, just false") p := b.Page(homePage) uassert.Equal(t, 0, p.Count(KeyUp), "an emptied bucket leaves no residue") uassert.Equal(t, 0, p.Total()) // Last is deliberately not rewound: it records what happened. key, who, _ := p.Last() uassert.Equal(t, KeyUp, key) uassert.Equal(t, alice, who) } func TestReactRejects(t *testing.T) { b := NewBoard() uassert.False(t, b.React("not a path", alice, KeyUp, 1), "bad page") uassert.False(t, b.React(homePage, alice, "poop", 1), "key outside the palette") uassert.Equal(t, 0, b.Pages(), "a rejected reaction creates no page") } func TestPagesAreIndependentAndOrdered(t *testing.T) { b := NewBoard() b.React("gno.land/r/moul/home", alice, KeyUp, 1) b.React("gno.land/r/moul/blog", bob, KeyFire, 2) b.React("gno.land/r/moul/blog", carol, KeyFire, 3) uassert.Equal(t, 2, b.Pages()) uassert.Equal(t, 1, b.Page("gno.land/r/moul/home").Total()) uassert.Equal(t, 2, b.Page("gno.land/r/moul/blog").Total()) var seen []string b.Each(func(page string, p *Page) bool { seen = append(seen, page) return false }) uassert.Equal(t, "gno.land/r/moul/blog,gno.land/r/moul/home", strings.Join(seen, ","), "Each walks in key order, so a Render built from it is stable") } func TestEachStopsOnTrue(t *testing.T) { b := NewBoard() b.React("gno.land/r/a/one", alice, KeyUp, 1) b.React("gno.land/r/b/two", alice, KeyUp, 2) n := 0 b.Each(func(page string, p *Page) bool { n++ return true }) uassert.Equal(t, 1, n) } func TestRealmAndPageURL(t *testing.T) { uassert.Equal(t, "/r/test/reactions/v0", RealmURL(realmPath)) uassert.Equal(t, "", RealmURL("nodomain")) uassert.Equal(t, "/r/test/reactions/v0:gno.land/r/moul/home", PageURL(realmPath, homePage)) uassert.Equal(t, "", PageURL("nodomain", homePage)) } // TestBlockShape pins the embed byte for byte. It is a Test and not an Example // because the block contains a blank line, and gno collapses consecutive blank // lines inside an // Output: block. func TestBlockShape(t *testing.T) { b := NewBoard() empty := "[\U0001F44D](/r/test/reactions/v0$help&func=React&key=up&page=gno.land%2Fr%2Fmoul%2Fhome) " + "[\U0001F49C](/r/test/reactions/v0$help&func=React&key=heart&page=gno.land%2Fr%2Fmoul%2Fhome) " + "[\U0001F525](/r/test/reactions/v0$help&func=React&key=fire&page=gno.land%2Fr%2Fmoul%2Fhome) " + "[\U0001F389](/r/test/reactions/v0$help&func=React&key=party&page=gno.land%2Fr%2Fmoul%2Fhome) " + "[\U0001F680](/r/test/reactions/v0$help&func=React&key=rocket&page=gno.land%2Fr%2Fmoul%2Fhome) " + "[\U0001F440](/r/test/reactions/v0$help&func=React&key=eyes&page=gno.land%2Fr%2Fmoul%2Fhome)" + "\n\n*no reactions yet*\n" uassert.Equal(t, empty, Block(realmPath, homePage, b.Page(homePage)), "a page nobody has reacted to still renders the whole strip") b.React(homePage, alice, KeyUp, 100) b.React(homePage, bob, KeyUp, 101) b.React(homePage, carol, KeyFire, 102) got := Block(realmPath, homePage, b.Page(homePage)) uassert.True(t, strings.Contains(got, "[\U0001F44D 2]"), "the count rides in the link title") uassert.True(t, strings.Contains(got, "[\U0001F525 1]")) uassert.True(t, strings.Contains(got, "[\U0001F389]"), "an empty bucket shows no zero") uassert.True(t, strings.Contains(got, "last \U0001F525 by ")) uassert.True(t, strings.Contains(got, "[3 reactions](/r/test/reactions/v0:gno.land/r/moul/home)")) uassert.True(t, strings.HasSuffix(got, "*\n"), "one trailing newline, so it drops into a Render") } func TestSummaryPluralises(t *testing.T) { b := NewBoard() b.React(homePage, alice, KeyUp, 100) uassert.True(t, strings.Contains(Summary(realmPath, homePage, b.Page(homePage)), "[1 reaction]")) b.React(homePage, bob, KeyUp, 101) uassert.True(t, strings.Contains(Summary(realmPath, homePage, b.Page(homePage)), "[2 reactions]")) } // TestEmojiSurviveTheEscaper is the reason the palette holds no U+FE0F and no // zero-width joiner: ui.ActionIn escapes the link title, and that escaper // strips exactly those. A glyph that does not survive it renders as a // monochrome box on every page that embeds the block. func TestEmojiSurviveTheEscaper(t *testing.T) { b := NewBoard() for _, k := range Keys() { got := Block(realmPath, homePage, b.Page(homePage)) uassert.True(t, strings.Contains(got, "["+Emoji(k)+"]"), k) } } // TestSummaryGoesEmptyWhenEveryoneLeaves is the interaction between the two // halves of Last: it is deliberately not rewound by Unreact, so the summary has // to decide for itself whether there is anything left to summarise. Gating on // the last key alone would print "last 👍 by g1… · 0 reactions" above an empty // strip, which contradicts itself and keeps attributing a reaction its author // took back. func TestSummaryGoesEmptyWhenEveryoneLeaves(t *testing.T) { b := NewBoard() b.React(homePage, alice, KeyUp, 100) b.Unreact(homePage, alice) p := b.Page(homePage) uassert.Equal(t, 0, p.Total(), "the tally is empty") key, _, _ := p.Last() uassert.Equal(t, KeyUp, key, "and Last still records the event, by design") uassert.Equal(t, "no reactions yet", Summary(realmPath, homePage, p), "so the summary shows the empty state rather than a withdrawn reaction") uassert.True(t, strings.HasSuffix(Block(realmPath, homePage, p), "*no reactions yet*\n")) }
Result log
msg:0,success:true,log:,events:[]