| 1 | // Tests for text formatting (link detection, file sizes). node test/format.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, convertLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js"; |
| 4 | import { emojify, emojiMatches } from "../web/static/emoji.js"; |
| 5 | |
| 6 | let failures = 0; |
| 7 | async function test(name, fn) { |
| 8 | try { |
| 9 | await fn(); |
| 10 | console.log("PASS " + name); |
| 11 | } catch (e) { |
| 12 | console.log("FAIL " + name + " β " + (e && e.message)); |
| 13 | failures++; |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | // Reconstructing the token texts must always equal the input (no data lost). |
| 18 | function roundtrip(text) { |
| 19 | return tokenizeText(text) |
| 20 | .map((t) => t.text) |
| 21 | .join(""); |
| 22 | } |
| 23 | |
| 24 | await test("plain text has no links and round-trips", () => { |
| 25 | const toks = tokenizeText("just a normal message"); |
| 26 | assert.equal(toks.length, 1); |
| 27 | assert.equal(toks[0].href, undefined); |
| 28 | assert.equal(roundtrip("just a normal message"), "just a normal message"); |
| 29 | }); |
| 30 | |
| 31 | await test("https URL becomes a link", () => { |
| 32 | const toks = tokenizeText("see https://example.com/page now"); |
| 33 | const link = toks.find((t) => t.href); |
| 34 | assert.equal(link.text, "https://example.com/page"); |
| 35 | assert.equal(link.href, "https://example.com/page"); |
| 36 | }); |
| 37 | |
| 38 | await test("bare www. gets an https href", () => { |
| 39 | const link = tokenizeText("go to www.example.com").find((t) => t.href); |
| 40 | assert.equal(link.text, "www.example.com"); |
| 41 | assert.equal(link.href, "https://www.example.com"); |
| 42 | }); |
| 43 | |
| 44 | await test("trailing punctuation is not part of the link", () => { |
| 45 | const toks = tokenizeText("look at https://example.com."); |
| 46 | const link = toks.find((t) => t.href); |
| 47 | assert.equal(link.href, "https://example.com"); |
| 48 | assert.equal(roundtrip("look at https://example.com."), "look at https://example.com."); |
| 49 | }); |
| 50 | |
| 51 | await test("a URL in parentheses keeps the paren out of the link", () => { |
| 52 | const toks = tokenizeText("(https://example.com)"); |
| 53 | const link = toks.find((t) => t.href); |
| 54 | assert.equal(link.href, "https://example.com"); |
| 55 | assert.equal(roundtrip("(https://example.com)"), "(https://example.com)"); |
| 56 | }); |
| 57 | |
| 58 | await test("javascript: scheme is NOT linkified", () => { |
| 59 | const toks = tokenizeText("javascript:alert(1)"); |
| 60 | assert.ok(!toks.some((t) => t.href)); |
| 61 | }); |
| 62 | |
| 63 | await test("multiple links in one message", () => { |
| 64 | const links = tokenizeText("a https://one.com b http://two.com c").filter((t) => t.href); |
| 65 | assert.equal(links.length, 2); |
| 66 | }); |
| 67 | |
| 68 | await test("newlines and text are preserved around links", () => { |
| 69 | const input = "line1\nhttps://x.com\nline3"; |
| 70 | assert.equal(roundtrip(input), input); |
| 71 | }); |
| 72 | |
| 73 | await test("bare domain (no scheme) becomes a link", () => { |
| 74 | const link = tokenizeText("see rte.ie for news").find((t) => t.href); |
| 75 | assert.equal(link.text, "rte.ie"); |
| 76 | assert.equal(link.href, "https://rte.ie"); |
| 77 | }); |
| 78 | |
| 79 | await test("bare multi-part domain links, and a bare domain with a path", () => { |
| 80 | assert.equal(tokenizeText("on guardian.co.uk today").find((t) => t.href).href, "https://guardian.co.uk"); |
| 81 | assert.equal(tokenizeText("rte.ie/news here").find((t) => t.href).href, "https://rte.ie/news"); |
| 82 | }); |
| 83 | |
| 84 | await test("file names / abbreviations / versions are NOT linkified", () => { |
| 85 | for (const s of ["node.js", "index.html", "main.go", "see e.g. this", "v1.2.3"]) { |
| 86 | assert.ok(!tokenizeText(s).some((t) => t.href), s); |
| 87 | assert.equal(roundtrip(s), s); |
| 88 | } |
| 89 | }); |
| 90 | |
| 91 | await test("an email's domain is not linkified", () => { |
| 92 | assert.ok(!tokenizeText("ping paul@rte.ie now").some((t) => t.href)); |
| 93 | }); |
| 94 | |
| 95 | await test("formatBytes is human readable", () => { |
| 96 | assert.equal(formatBytes(500), "500 B"); |
| 97 | assert.equal(formatBytes(1536), "1.5 KB"); |
| 98 | assert.equal(formatBytes(5 * 1024 * 1024), "5.0 MB"); |
| 99 | }); |
| 100 | |
| 101 | await test("replySnippet returns trimmed, single-line text", () => { |
| 102 | assert.equal(replySnippet({ text: " hello world\n" }), "hello world"); |
| 103 | }); |
| 104 | |
| 105 | await test("replySnippet truncates long text with an ellipsis", () => { |
| 106 | const s = replySnippet({ text: "a".repeat(200) }, 10); |
| 107 | assert.equal(s.length, 10); |
| 108 | assert.ok(s.endsWith("β¦")); |
| 109 | }); |
| 110 | |
| 111 | await test("replySnippet labels photo-only messages", () => { |
| 112 | assert.equal(replySnippet({ text: "", attachments: [{ type: "image/png" }] }), "π· Photo"); |
| 113 | }); |
| 114 | |
| 115 | await test("replySnippet labels non-image attachments by name", () => { |
| 116 | assert.equal(replySnippet({ attachments: [{ type: "application/pdf", name: "spec.pdf" }] }), "π spec.pdf"); |
| 117 | }); |
| 118 | |
| 119 | await test("replySnippet caps an over-long attachment name", () => { |
| 120 | const s = replySnippet({ attachments: [{ type: "application/pdf", name: "x".repeat(200) }] }, 20); |
| 121 | assert.equal(s.length, 20); |
| 122 | assert.ok(s.startsWith("π ") && s.endsWith("β¦")); |
| 123 | }); |
| 124 | |
| 125 | await test("replySnippet is empty for an empty message", () => { |
| 126 | assert.equal(replySnippet({}), ""); |
| 127 | assert.equal(replySnippet(null), ""); |
| 128 | }); |
| 129 | |
| 130 | await test("reactionLine quotes what was reacted to", () => { |
| 131 | assert.equal( |
| 132 | reactionLine("James", { type: "reaction", emoji: "π", on: true, targetText: "buy milk" }), |
| 133 | "James reacted π to βbuy milkβ", |
| 134 | ); |
| 135 | }); |
| 136 | |
| 137 | await test("reactionLine speaks for an un-react too", () => { |
| 138 | // The list must say the same thing after a reload as it did live, and on |
| 139 | // reload the un-react event is all it has. |
| 140 | assert.equal( |
| 141 | reactionLine("James", { type: "reaction", emoji: "β€οΈ", on: false, targetText: "shipped it" }), |
| 142 | "James removed β€οΈ from βshipped itβ", |
| 143 | ); |
| 144 | }); |
| 145 | |
| 146 | await test("convertLine says what a conversion did, pluralised and grouped", () => { |
| 147 | // A conversion has no text of its own, so without this the chat-list row |
| 148 | // would go blank the way a reaction's used to. |
| 149 | assert.equal(convertLine("James", 3), "James moved 3 replies into a thread"); |
| 150 | assert.equal(convertLine("You", 1), "You moved a reply into a thread", "never '1 replies'"); |
| 151 | assert.equal(convertLine("James", 1204), "James moved 1,204 replies into a thread"); |
| 152 | assert.equal(convertLine("", 2), "Someone moved 2 replies into a thread", "an unresolved actor still reads"); |
| 153 | }); |
| 154 | |
| 155 | await test("reactionLine falls back to the bare form without a snippet", () => { |
| 156 | // Reactions sent before targetText shipped β the primary path on rollout. |
| 157 | assert.equal(reactionLine("James", { type: "reaction", emoji: "π", on: true }), "James reacted π"); |
| 158 | assert.equal(reactionLine("James", { type: "reaction", emoji: "π", on: false }), "James removed π"); |
| 159 | }); |
| 160 | |
| 161 | await test("reactionLine clamps a long quote", () => { |
| 162 | const line = reactionLine("Jo", { emoji: "π", on: true, targetText: "a".repeat(200) }, 10); |
| 163 | assert.ok(line.startsWith("Jo reacted π to β")); |
| 164 | assert.ok(line.endsWith("β¦β")); |
| 165 | }); |
| 166 | |
| 167 | await test("reactionLine takes the caller's actor, including βYouβ", () => { |
| 168 | assert.equal(reactionLine("You", { emoji: "π", on: true, targetText: "hi" }), "You reacted π to βhiβ"); |
| 169 | assert.equal(reactionLine("", { emoji: "π", on: true }), "Someone reacted π"); |
| 170 | }); |
| 171 | |
| 172 | await test("emojiOnlyCount counts 1β3 all-emoji messages", () => { |
| 173 | assert.equal(emojiOnlyCount("π"), 1); |
| 174 | assert.equal(emojiOnlyCount("ππ"), 2); |
| 175 | assert.equal(emojiOnlyCount("πππ"), 3); |
| 176 | }); |
| 177 | |
| 178 | await test("emojiOnlyCount treats a ZWJ family / skin tone as one emoji", () => { |
| 179 | assert.equal(emojiOnlyCount("π¨βπ©βπ§βπ¦"), 1); |
| 180 | assert.equal(emojiOnlyCount("ππ½"), 1); |
| 181 | }); |
| 182 | |
| 183 | await test("emojiOnlyCount handles a flag (regional indicators) as one emoji", () => { |
| 184 | assert.equal(emojiOnlyCount("π¬π§"), 1); |
| 185 | }); |
| 186 | |
| 187 | await test("emojiOnlyCount allows whitespace between emoji", () => { |
| 188 | assert.equal(emojiOnlyCount("π π"), 2); |
| 189 | }); |
| 190 | |
| 191 | await test("emojiOnlyCount is 0 for more than 3 emoji", () => { |
| 192 | assert.equal(emojiOnlyCount("ππππ"), 0); |
| 193 | }); |
| 194 | |
| 195 | await test("emojiOnlyCount is 0 when any text accompanies the emoji", () => { |
| 196 | assert.equal(emojiOnlyCount("hi π"), 0); |
| 197 | assert.equal(emojiOnlyCount("π nice"), 0); |
| 198 | }); |
| 199 | |
| 200 | await test("emojiOnlyCount is 0 for empty or whitespace-only text", () => { |
| 201 | assert.equal(emojiOnlyCount(""), 0); |
| 202 | assert.equal(emojiOnlyCount(" "), 0); |
| 203 | assert.equal(emojiOnlyCount(null), 0); |
| 204 | }); |
| 205 | |
| 206 | // Reconstructing richTokens text must equal the input (no data lost) β markers |
| 207 | // are consumed, so compare against the input with markers stripped. |
| 208 | const richText = (s) => richTokens(s).map((t) => t.text).join(""); |
| 209 | |
| 210 | await test("richTokens: bold / italic / strike / code", () => { |
| 211 | assert.deepEqual(richTokens("**big**"), [{ text: "big", mark: "b" }]); |
| 212 | assert.deepEqual(richTokens("*em*"), [{ text: "em", mark: "i" }]); |
| 213 | assert.deepEqual(richTokens("_em_"), [{ text: "em", mark: "i" }]); |
| 214 | assert.deepEqual(richTokens("~no~"), [{ text: "no", mark: "s" }]); |
| 215 | assert.deepEqual(richTokens("`x = 1`"), [{ text: "x = 1", mark: "code" }]); |
| 216 | }); |
| 217 | |
| 218 | await test("richTokens: bold beats italic at a double asterisk", () => { |
| 219 | assert.deepEqual(richTokens("**b**"), [{ text: "b", mark: "b" }]); |
| 220 | }); |
| 221 | |
| 222 | await test("richTokens: triple asterisk is combined bold italic", () => { |
| 223 | assert.deepEqual(richTokens("***both***"), [{ text: "both", mark: "bi" }]); |
| 224 | assert.deepEqual(richTokens("a ***b c*** d"), [ |
| 225 | { text: "a " }, |
| 226 | { text: "b c", mark: "bi" }, |
| 227 | { text: " d" }, |
| 228 | ]); |
| 229 | // Neighbours don't bleed: bi next to bold, and markers still can't span lines. |
| 230 | assert.deepEqual(richTokens("***x*** **y**"), [ |
| 231 | { text: "x", mark: "bi" }, |
| 232 | { text: " " }, |
| 233 | { text: "y", mark: "b" }, |
| 234 | ]); |
| 235 | assert.deepEqual(richTokens("***a\nb***"), [{ text: "***a\nb***" }]); |
| 236 | }); |
| 237 | |
| 238 | await test("richTokens: mixes plain text, a mark, and a link", () => { |
| 239 | const t = richTokens("hi *there* see https://x.com"); |
| 240 | assert.deepEqual(t[0], { text: "hi " }); |
| 241 | assert.deepEqual(t[1], { text: "there", mark: "i" }); |
| 242 | assert.equal(t[2].text, " see "); |
| 243 | assert.equal(t[t.length - 1].href, "https://x.com"); |
| 244 | }); |
| 245 | |
| 246 | await test("richTokens: no formatting inside a URL", () => { |
| 247 | const t = richTokens("https://x.com/a_b_c"); |
| 248 | assert.equal(t.length, 1); |
| 249 | assert.equal(t[0].href, "https://x.com/a_b_c"); |
| 250 | assert.equal(t[0].mark, undefined); |
| 251 | }); |
| 252 | |
| 253 | await test("richTokens: underscores mid-word do NOT italicize", () => { |
| 254 | assert.deepEqual(richTokens("my_file_name"), [{ text: "my_file_name" }]); |
| 255 | }); |
| 256 | |
| 257 | await test("richTokens: plain text passes through unmarked", () => { |
| 258 | assert.deepEqual(richTokens("just text"), [{ text: "just text" }]); |
| 259 | assert.equal(richText("a *b* c `d` e"), "a b c d e"); |
| 260 | }); |
| 261 | |
| 262 | await test("blockTokens: plain text is one untouched p block", () => { |
| 263 | assert.deepEqual(blockTokens("hello\n\nworld"), [{ type: "p", text: "hello\n\nworld" }]); |
| 264 | assert.deepEqual(blockTokens(""), [{ type: "p", text: "" }]); |
| 265 | assert.deepEqual(blockTokens(null), [{ type: "p", text: "" }]); |
| 266 | }); |
| 267 | |
| 268 | await test("blockTokens: fenced code block, markers verbatim inside", () => { |
| 269 | const blocks = blockTokens("look:\n```\nif (a > b) { *notBold* }\n```\nnice"); |
| 270 | assert.deepEqual(blocks, [ |
| 271 | { type: "p", text: "look:" }, |
| 272 | { type: "code", text: "if (a > b) { *notBold* }", lang: "" }, |
| 273 | { type: "p", text: "nice" }, |
| 274 | ]); |
| 275 | }); |
| 276 | |
| 277 | await test("blockTokens: fence captures a language tag", () => { |
| 278 | const blocks = blockTokens("```js\nconst x = 1;\n```"); |
| 279 | assert.deepEqual(blocks, [{ type: "code", text: "const x = 1;", lang: "js" }]); |
| 280 | }); |
| 281 | |
| 282 | await test("blockTokens: unterminated fence runs to the end", () => { |
| 283 | const blocks = blockTokens("```\nline one\nline two"); |
| 284 | assert.deepEqual(blocks, [{ type: "code", text: "line one\nline two", lang: "" }]); |
| 285 | }); |
| 286 | |
| 287 | await test("blockTokens: inline backticks on one line are not a fence", () => { |
| 288 | const blocks = blockTokens("```js``` opens a fence"); |
| 289 | assert.deepEqual(blocks, [{ type: "p", text: "```js``` opens a fence" }]); |
| 290 | }); |
| 291 | |
| 292 | await test("blockTokens: consecutive > lines merge into one quote", () => { |
| 293 | const blocks = blockTokens("> two roads diverged\n> in a yellow wood\nso true"); |
| 294 | assert.deepEqual(blocks, [ |
| 295 | { type: "quote", text: "two roads diverged\nin a yellow wood" }, |
| 296 | { type: "p", text: "so true" }, |
| 297 | ]); |
| 298 | }); |
| 299 | |
| 300 | await test("blockTokens: bare > continues a quote as a blank line", () => { |
| 301 | const blocks = blockTokens("> first\n>\n> second"); |
| 302 | assert.deepEqual(blocks, [{ type: "quote", text: "first\n\nsecond" }]); |
| 303 | }); |
| 304 | |
| 305 | await test("blockTokens: mid-line > is not a quote", () => { |
| 306 | assert.deepEqual(blockTokens("5 > 3 and 2 > 1"), [{ type: "p", text: "5 > 3 and 2 > 1" }]); |
| 307 | }); |
| 308 | |
| 309 | await test("blockTokens: blank lines around blocks are dropped", () => { |
| 310 | const blocks = blockTokens("intro\n\n> quoted\n\noutro"); |
| 311 | assert.deepEqual(blocks, [ |
| 312 | { type: "p", text: "intro" }, |
| 313 | { type: "quote", text: "quoted" }, |
| 314 | { type: "p", text: "outro" }, |
| 315 | ]); |
| 316 | }); |
| 317 | |
| 318 | await test("firstLink finds a URL but ignores those inside code fences", () => { |
| 319 | assert.equal(firstLink("```\nhttps://example.com/in-code\n```\nsee https://example.com/real"), |
| 320 | "https://example.com/real"); |
| 321 | assert.equal(firstLink("> see https://example.com/quoted"), "https://example.com/quoted"); |
| 322 | assert.equal(firstLink("```\nhttps://example.com/only-code\n```"), null); |
| 323 | }); |
| 324 | |
| 325 | await test("emojify: known shortcodes β emoji, incl. +1 / -1", () => { |
| 326 | assert.equal(emojify("nice :tada:"), "nice π"); |
| 327 | assert.equal(emojify(":+1: / :-1:"), "π / π"); |
| 328 | assert.equal(emojify(":fire::fire:"), "π₯π₯"); |
| 329 | }); |
| 330 | |
| 331 | await test("emojify: unknown shortcodes are left untouched", () => { |
| 332 | assert.equal(emojify("a :notarealemoji: b"), "a :notarealemoji: b"); |
| 333 | }); |
| 334 | |
| 335 | await test("emojify: a lone shortcode becomes a single emoji (jumbo-eligible)", () => { |
| 336 | assert.equal(emojiOnlyCount(emojify(":tada:")), 1); |
| 337 | }); |
| 338 | |
| 339 | await test("emojify: URLs and times are not mangled", () => { |
| 340 | assert.equal(emojify("see https://x.com at 10:30"), "see https://x.com at 10:30"); |
| 341 | }); |
| 342 | |
| 343 | await test("typingSummary is empty when no one is typing", () => { |
| 344 | assert.equal(typingSummary([]), ""); |
| 345 | assert.equal(typingSummary(null), ""); |
| 346 | }); |
| 347 | |
| 348 | await test("typingSummary names one and two typers", () => { |
| 349 | assert.equal(typingSummary(["Sophie"]), "Sophie is typingβ¦"); |
| 350 | assert.equal(typingSummary(["Sophie", "James"]), "Sophie and James are typingβ¦"); |
| 351 | }); |
| 352 | |
| 353 | await test("typingSummary collapses three or more typers", () => { |
| 354 | assert.equal(typingSummary(["A", "B", "C"]), "Several people are typingβ¦"); |
| 355 | }); |
| 356 | |
| 357 | await test("typingSummary ignores empty names", () => { |
| 358 | assert.equal(typingSummary(["Sophie", ""]), "Sophie is typingβ¦"); |
| 359 | }); |
| 360 | |
| 361 | await test("autoGroupTitle lists members and collapses past three", () => { |
| 362 | assert.equal(autoGroupTitle([]), "Group"); |
| 363 | assert.equal(autoGroupTitle(["Paul"]), "Paul"); |
| 364 | assert.equal(autoGroupTitle(["Paul", "Ciara"]), "Paul & Ciara"); |
| 365 | assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan"]), "Paul, Ciara & Luan"); |
| 366 | assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan", "Mabel"]), "Paul, Ciara & 2 more"); |
| 367 | // The worked example: two shown + the rest counted. |
| 368 | const many = ["Paul", "Ciara", ...Array.from({ length: 24 }, (_, i) => "P" + i)]; |
| 369 | assert.equal(autoGroupTitle(many), "Paul, Ciara & 24 more"); |
| 370 | }); |
| 371 | |
| 372 | await test("autoGroupTitle ignores empty names", () => { |
| 373 | assert.equal(autoGroupTitle(["Paul", "", "Ciara"]), "Paul & Ciara"); |
| 374 | assert.equal(autoGroupTitle(null), "Group"); |
| 375 | }); |
| 376 | |
| 377 | await test("nameList joins names naturally with 'and'", () => { |
| 378 | assert.equal(nameList([]), ""); |
| 379 | assert.equal(nameList(["Alice"]), "Alice"); |
| 380 | assert.equal(nameList(["Alice", "Bob"]), "Alice and Bob"); |
| 381 | assert.equal(nameList(["Alice", "Bob", "Dave"]), "Alice, Bob and Dave"); |
| 382 | assert.equal(nameList(["Alice", "Bob", "Carol", "Dave"]), "Alice, Bob, Carol and Dave"); |
| 383 | }); |
| 384 | |
| 385 | await test("nameList ignores empty names and non-arrays", () => { |
| 386 | assert.equal(nameList(["Alice", "", "Bob"]), "Alice and Bob"); |
| 387 | assert.equal(nameList(null), ""); |
| 388 | }); |
| 389 | |
| 390 | await test("membershipLine renders joins from the viewer's point of view", () => { |
| 391 | const names = { u1: "Paul", u2: "Alice", u3: "Bob" }; |
| 392 | const label = (id) => names[id] || "Someone"; |
| 393 | // A bystander sees everyone by name. |
| 394 | assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u9", label), "Paul added Alice and Bob"); |
| 395 | // A newcomer sees "you" for themselves. |
| 396 | assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u3", label), "Paul added Alice and you"); |
| 397 | assert.equal(membershipLine({ uid: "u1", join: ["u3"] }, "u3", label), "Paul added you"); |
| 398 | // The person who did it sees "You". |
| 399 | assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u1", label), "You added Alice and Bob"); |
| 400 | }); |
| 401 | |
| 402 | await test("membershipLine renders a self-serve join as 'joined', not 'added themselves'", () => { |
| 403 | const names = { u1: "Paul" }; |
| 404 | const label = (id) => names[id] || "Someone"; |
| 405 | // Joining an open group via Discover: join = [the actor themselves]. |
| 406 | assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u9", label), "Paul joined"); |
| 407 | assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u1", label), "You joined"); |
| 408 | // A uid-less legacy payload keeps the added-list rendering. |
| 409 | assert.equal(membershipLine({ name: "Paul", join: ["u2"] }, "u9", label), "Paul added Someone"); |
| 410 | }); |
| 411 | |
| 412 | await test("membershipLine renders removals from the viewer's point of view", () => { |
| 413 | const names = { u1: "Paul", u2: "Alice", u3: "Bob" }; |
| 414 | const label = (id) => names[id] || "Someone"; |
| 415 | assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u9", label), "Paul removed Bob"); |
| 416 | assert.equal(membershipLine({ uid: "u1", removed: ["u2", "u3"] }, "u1", label), "You removed Alice and Bob"); |
| 417 | assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u3", label), "Paul removed you"); |
| 418 | }); |
| 419 | |
| 420 | await test("membershipLine renders leaves and ignores non-membership payloads", () => { |
| 421 | const label = (id) => ({ u1: "Paul" })[id] || "Someone"; |
| 422 | assert.equal(membershipLine({ uid: "u1", left: true }, "u9", label), "Paul left"); |
| 423 | assert.equal(membershipLine({ uid: "u1", left: true }, "u1", label), "You left"); |
| 424 | // No uid β fall back to the baked name. |
| 425 | assert.equal(membershipLine({ name: "Paul", left: true }, "u9", label), "Paul left"); |
| 426 | // Not a membership event. |
| 427 | assert.equal(membershipLine({ uid: "u1", text: "hello" }, "u9", label), null); |
| 428 | assert.equal(membershipLine({ uid: "u1", join: [] }, "u9", label), null); |
| 429 | assert.equal(membershipLine({ uid: "u1", removed: [] }, "u9", label), null); |
| 430 | assert.equal(membershipLine(null, "u9", label), null); |
| 431 | }); |
| 432 | |
| 433 | await test("emojiMatches ranks prefix hits and finds :tada:", () => { |
| 434 | const m = emojiMatches("tada"); |
| 435 | assert.equal(m[0].emoji, "π"); |
| 436 | assert.equal(m[0].code, "tada"); |
| 437 | assert.equal(emojiMatches("").length, 0); // no query β no menu |
| 438 | }); |
| 439 | |
| 440 | await test("emojiMatches de-dupes aliases to the same glyph", () => { |
| 441 | const glyphs = emojiMatches("smi").map((m) => m.emoji); |
| 442 | assert.equal(new Set(glyphs).size, glyphs.length); // no repeated emoji |
| 443 | }); |
| 444 | |
| 445 | await test("emojify: shrug, facepalm, strong and lol shortcodes are known", () => { |
| 446 | assert.equal(emojify(":shrug:"), "π€·"); |
| 447 | assert.equal(emojify(":facepalm:"), "π€¦"); |
| 448 | assert.equal(emojify(":strong:"), "πͺ"); |
| 449 | assert.equal(emojify(":lol:"), "π"); |
| 450 | }); |
| 451 | |
| 452 | await test("youtubeId parses the common URL shapes", () => { |
| 453 | assert.deepEqual(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 454 | assert.deepEqual(youtubeId("https://youtu.be/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 455 | assert.deepEqual(youtubeId("https://m.youtube.com/watch?v=dQw4w9WgXcQ&pp=xyz"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 456 | assert.deepEqual(youtubeId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 457 | assert.deepEqual(youtubeId("https://www.youtube.com/live/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 458 | assert.deepEqual(youtubeId("https://www.youtube.com/embed/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 }); |
| 459 | }); |
| 460 | |
| 461 | await test("youtubeId parses start offsets", () => { |
| 462 | assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90").start, 90); |
| 463 | assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90s").start, 90); |
| 464 | assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s").start, 3723); |
| 465 | assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45").start, 45); |
| 466 | }); |
| 467 | |
| 468 | await test("youtubeId rejects non-video and lookalike URLs", () => { |
| 469 | assert.equal(youtubeId("https://www.youtube.com/@somechannel"), null); |
| 470 | assert.equal(youtubeId("https://www.youtube.com/playlist?list=PLx"), null); |
| 471 | assert.equal(youtubeId("https://notyoutube.com/watch?v=dQw4w9WgXcQ"), null); |
| 472 | assert.equal(youtubeId("https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ"), null); |
| 473 | assert.equal(youtubeId("https://www.youtube.com/watch?v=too-short"), null); |
| 474 | assert.equal(youtubeId("not a url"), null); |
| 475 | assert.equal(youtubeId("ftp://youtu.be/dQw4w9WgXcQ"), null); |
| 476 | }); |
| 477 | |
| 478 | // ββ changelog cleaning (the Admin app's update stream) ββ |
| 479 | |
| 480 | await test("changelogParagraphs drops the π€ authorship marker", () => { |
| 481 | assert.deepEqual(changelogParagraphs("π€ Unread dots now sync across your devices."), [ |
| 482 | "Unread dots now sync across your devices.", |
| 483 | ]); |
| 484 | // A person marker is stripped the same way. |
| 485 | assert.deepEqual(changelogParagraphs("π¨ Hand-written note."), ["Hand-written note."]); |
| 486 | }); |
| 487 | |
| 488 | await test("changelogParagraphs collapses hard wraps but keeps paragraph breaks", () => { |
| 489 | const note = "π€ Your space can now keep itself up\nto date. Admins get a new\nUpdates section.\n\nUpdates take seconds."; |
| 490 | assert.deepEqual(changelogParagraphs(note), [ |
| 491 | "Your space can now keep itself up to date. Admins get a new Updates section.", |
| 492 | "Updates take seconds.", |
| 493 | ]); |
| 494 | }); |
| 495 | |
| 496 | await test("changelogParagraphs handles no marker, blank, and non-string", () => { |
| 497 | assert.deepEqual(changelogParagraphs("Plain note, no marker."), ["Plain note, no marker."]); |
| 498 | assert.deepEqual(changelogParagraphs(" "), []); |
| 499 | assert.deepEqual(changelogParagraphs(undefined), []); |
| 500 | }); |
| 501 | |
| 502 | await test("emphasis markers around a URL never ride into the href", () => { |
| 503 | // A bot writing "**github.com/apps/eleven-messenger**" gets a clean link, |
| 504 | // stars left as literal text (bolding ACROSS a link needs the real Markdown |
| 505 | // parser, tracked separately). |
| 506 | const toks = tokenizeText("go to **github.com/apps/eleven-messenger** and install"); |
| 507 | const link = toks.find((t) => t.href); |
| 508 | assert.ok(link, "URL still linkifies next to emphasis markers"); |
| 509 | assert.equal(link.text, "github.com/apps/eleven-messenger"); |
| 510 | assert.ok(!link.href.includes("*"), "no emphasis markers in the href"); |
| 511 | assert.equal(toks.map((t) => t.text).join(""), "go to **github.com/apps/eleven-messenger** and install"); |
| 512 | const toks2 = tokenizeText("see https://docs.github.com/rest/issues#list**"); |
| 513 | const link2 = toks2.find((t) => t.href); |
| 514 | assert.equal(link2.text, "https://docs.github.com/rest/issues#list"); |
| 515 | assert.ok(!link2.href.endsWith("*")); |
| 516 | }); |
| 517 | |
| 518 | await test("formatCountdown: whole units, ceiled, never optimistic", () => { |
| 519 | const MIN = 60000; |
| 520 | assert.equal(formatCountdown(0), "under a minute"); |
| 521 | assert.equal(formatCountdown(30 * 1000), "under a minute"); |
| 522 | assert.equal(formatCountdown(MIN), "under a minute"); |
| 523 | assert.equal(formatCountdown(MIN + 1), "2m"); // ceil: the label outlives the space, not vice versa |
| 524 | assert.equal(formatCountdown(45 * MIN), "45m"); |
| 525 | assert.equal(formatCountdown(59 * MIN), "59m"); |
| 526 | assert.equal(formatCountdown(59 * MIN + 1), "1h"); // 59m + a tick ceils to the hour |
| 527 | assert.equal(formatCountdown(60 * MIN), "1h"); |
| 528 | assert.equal(formatCountdown(60 * MIN + 1), "1h 1m"); |
| 529 | assert.equal(formatCountdown(23 * 60 * MIN + 40 * MIN), "23h 40m"); |
| 530 | assert.equal(formatCountdown(24 * 60 * MIN), "24h"); |
| 531 | }); |
| 532 | |
| 533 | await test("bare domains glued mid-token stay plain (the lookbehind rule)", () => { |
| 534 | // Mid-word, email, and mid-hyphenated-token candidates never link β and a |
| 535 | // hyphenated host still links whole, from its clean start. |
| 536 | assert.ok(!tokenizeText("x_aaa.com").some((t) => t.href)); |
| 537 | // Glued after a stray hyphen counts as mid-token too (deliberate: hyphens |
| 538 | // are part of the candidate's own charset, so they bound it on both sides). |
| 539 | assert.ok(!tokenizeText("see -a.com").some((t) => t.href)); |
| 540 | const hy = tokenizeText("try my-site.com now").find((t) => t.href); |
| 541 | assert.equal(hy && hy.text, "my-site.com"); |
| 542 | assert.equal(roundtrip("x_aaa.com and my-site.com"), "x_aaa.com and my-site.com"); |
| 543 | }); |
| 544 | |
| 545 | await test("tokenizing a huge unbroken paste is linear, not quadratic (the render-freeze regression)", () => { |
| 546 | // A pasted key/hash/log line is one long run of word chars. The bare-domain |
| 547 | // scan used to make a fresh full-run attempt at every position β O(nΒ²), |
| 548 | // seconds of locked main thread per render at message-size (~45k chars). |
| 549 | // The lookbehind fails mid-run starts in O(1); 256k chars must be instant. |
| 550 | // The 2s bound is ~50Γ the observed linear time and ~1/40th of quadratic β |
| 551 | // generous for slow CI, unmissable if the regression returns. |
| 552 | const runs = ["a".repeat(256000), "a-".repeat(128000), "5".repeat(256000)]; |
| 553 | const t0 = performance.now(); |
| 554 | for (const s of runs) { |
| 555 | tokenizeText(s); |
| 556 | richTokens(s); |
| 557 | } |
| 558 | const ms = performance.now() - t0; |
| 559 | assert.ok(ms < 2000, `expected linear-time tokenize, took ${ms.toFixed(0)}ms`); |
| 560 | }); |
| 561 | |
| 562 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 563 | process.exit(failures === 0 ? 0 : 1); |
| 564 | |