eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
2import assert from "node:assert/strict";
3import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, convertLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js";
4import { emojify, emojiMatches } from "../web/static/emoji.js";
5
6let failures = 0;
7async 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).
18function roundtrip(text) {
19 return tokenizeText(text)
20 .map((t) => t.text)
21 .join("");
22}
23
24await 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
31await 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
38await 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
44await 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
51await 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
58await test("javascript: scheme is NOT linkified", () => {
59 const toks = tokenizeText("javascript:alert(1)");
60 assert.ok(!toks.some((t) => t.href));
61});
62
63await 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
68await test("newlines and text are preserved around links", () => {
69 const input = "line1\nhttps://x.com\nline3";
70 assert.equal(roundtrip(input), input);
71});
72
73await 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
79await 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
84await 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
91await test("an email's domain is not linkified", () => {
92 assert.ok(!tokenizeText("ping paul@rte.ie now").some((t) => t.href));
93});
94
95await 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
101await test("replySnippet returns trimmed, single-line text", () => {
102 assert.equal(replySnippet({ text: " hello world\n" }), "hello world");
103});
104
105await 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
111await test("replySnippet labels photo-only messages", () => {
112 assert.equal(replySnippet({ text: "", attachments: [{ type: "image/png" }] }), "πŸ“· Photo");
113});
114
115await test("replySnippet labels non-image attachments by name", () => {
116 assert.equal(replySnippet({ attachments: [{ type: "application/pdf", name: "spec.pdf" }] }), "πŸ“Ž spec.pdf");
117});
118
119await 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
125await test("replySnippet is empty for an empty message", () => {
126 assert.equal(replySnippet({}), "");
127 assert.equal(replySnippet(null), "");
128});
129
130await 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
137await 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
146await 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
155await 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
161await 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
167await 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
172await 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
178await test("emojiOnlyCount treats a ZWJ family / skin tone as one emoji", () => {
179 assert.equal(emojiOnlyCount("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"), 1);
180 assert.equal(emojiOnlyCount("πŸ‘πŸ½"), 1);
181});
182
183await test("emojiOnlyCount handles a flag (regional indicators) as one emoji", () => {
184 assert.equal(emojiOnlyCount("πŸ‡¬πŸ‡§"), 1);
185});
186
187await test("emojiOnlyCount allows whitespace between emoji", () => {
188 assert.equal(emojiOnlyCount("πŸ‘ πŸ‘"), 2);
189});
190
191await test("emojiOnlyCount is 0 for more than 3 emoji", () => {
192 assert.equal(emojiOnlyCount("πŸ‘πŸ‘πŸ‘πŸ‘"), 0);
193});
194
195await test("emojiOnlyCount is 0 when any text accompanies the emoji", () => {
196 assert.equal(emojiOnlyCount("hi πŸ‘"), 0);
197 assert.equal(emojiOnlyCount("πŸ‘ nice"), 0);
198});
199
200await 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.
208const richText = (s) => richTokens(s).map((t) => t.text).join("");
209
210await 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
218await test("richTokens: bold beats italic at a double asterisk", () => {
219 assert.deepEqual(richTokens("**b**"), [{ text: "b", mark: "b" }]);
220});
221
222await 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
238await 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
246await 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
253await test("richTokens: underscores mid-word do NOT italicize", () => {
254 assert.deepEqual(richTokens("my_file_name"), [{ text: "my_file_name" }]);
255});
256
257await 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
262await 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
268await 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
277await 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
282await 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
287await 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
292await 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
300await 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
305await 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
309await 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
318await 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
325await 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
331await test("emojify: unknown shortcodes are left untouched", () => {
332 assert.equal(emojify("a :notarealemoji: b"), "a :notarealemoji: b");
333});
334
335await test("emojify: a lone shortcode becomes a single emoji (jumbo-eligible)", () => {
336 assert.equal(emojiOnlyCount(emojify(":tada:")), 1);
337});
338
339await 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
343await test("typingSummary is empty when no one is typing", () => {
344 assert.equal(typingSummary([]), "");
345 assert.equal(typingSummary(null), "");
346});
347
348await 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
353await test("typingSummary collapses three or more typers", () => {
354 assert.equal(typingSummary(["A", "B", "C"]), "Several people are typing…");
355});
356
357await test("typingSummary ignores empty names", () => {
358 assert.equal(typingSummary(["Sophie", ""]), "Sophie is typing…");
359});
360
361await 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
372await test("autoGroupTitle ignores empty names", () => {
373 assert.equal(autoGroupTitle(["Paul", "", "Ciara"]), "Paul & Ciara");
374 assert.equal(autoGroupTitle(null), "Group");
375});
376
377await 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
385await test("nameList ignores empty names and non-arrays", () => {
386 assert.equal(nameList(["Alice", "", "Bob"]), "Alice and Bob");
387 assert.equal(nameList(null), "");
388});
389
390await 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
402await 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
412await 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
420await 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
433await 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
440await 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
445await 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
452await 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
461await 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
468await 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
480await 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
488await 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
496await 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
502await 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
518await 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
533await 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
545await 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
562console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
563process.exit(failures === 0 ? 0 : 1);
564