| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // Parity gate for RichText.swift against web/static/format.js β the SAME cases |
| 5 | // as test/format.test.mjs, so a message formats identically in both feeds. If |
| 6 | // one of these fails after a format.js change, update RichText.swift in the |
| 7 | // same change (and vice versa). |
| 8 | final class RichTextTests: XCTestCase { |
| 9 | private func links(_ s: String) -> [String] { |
| 10 | richTokens(s).compactMap { if case .link(_, let url) = $0 { return url.absoluteString } else { return nil } } |
| 11 | } |
| 12 | |
| 13 | func testPlainTextPassesThrough() { |
| 14 | XCTAssertEqual(richTokens("just a normal message"), [.text("just a normal message")]) |
| 15 | } |
| 16 | |
| 17 | func testHttpsLinkSplits() { |
| 18 | XCTAssertEqual(richTokens("see https://example.com/page now"), [ |
| 19 | .text("see "), .link("https://example.com/page", URL(string: "https://example.com/page")!), .text(" now"), |
| 20 | ]) |
| 21 | } |
| 22 | |
| 23 | func testWwwGetsScheme() { |
| 24 | XCTAssertEqual(links("go to www.example.com"), ["https://www.example.com"]) |
| 25 | } |
| 26 | |
| 27 | func testTrailingPunctuationStripped() { |
| 28 | XCTAssertEqual(links("look at https://example.com."), ["https://example.com"]) |
| 29 | XCTAssertEqual(links("(https://example.com)"), ["https://example.com"]) |
| 30 | } |
| 31 | |
| 32 | func testJavascriptSchemeIsNotALink() { |
| 33 | XCTAssertEqual(links("javascript:alert(1)"), []) |
| 34 | } |
| 35 | |
| 36 | func testMultipleLinks() { |
| 37 | XCTAssertEqual(links("a https://one.com b http://two.com c"), |
| 38 | ["https://one.com", "http://two.com"]) |
| 39 | } |
| 40 | |
| 41 | func testBareDomainsWithKnownTLDs() { |
| 42 | XCTAssertEqual(links("see rte.ie for news"), ["https://rte.ie"]) |
| 43 | XCTAssertEqual(links("on guardian.co.uk today"), ["https://guardian.co.uk"]) |
| 44 | XCTAssertEqual(links("rte.ie/news here"), ["https://rte.ie/news"]) |
| 45 | } |
| 46 | |
| 47 | func testFileNamesAndEmailsAreNotLinks() { |
| 48 | for s in ["run node.js today", "open index.html now", "see main.go there", "ping paul@rte.ie now"] { |
| 49 | XCTAssertEqual(links(s), [], s) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func testInlineFormatting() { |
| 54 | XCTAssertEqual(richTokens("a **bold** move"), [.text("a "), .bold("bold"), .text(" move")]) |
| 55 | XCTAssertEqual(richTokens("so *italic* here"), [.text("so "), .italic("italic"), .text(" here")]) |
| 56 | XCTAssertEqual(richTokens("it exists _to test_ :-/"), [.text("it exists "), .italic("to test"), .text(" :-/")]) |
| 57 | XCTAssertEqual(richTokens("some `code` inline"), [.text("some "), .code("code"), .text(" inline")]) |
| 58 | XCTAssertEqual(richTokens("well ~nope~ then"), [.text("well "), .strike("nope"), .text(" then")]) |
| 59 | } |
| 60 | |
| 61 | // test/format.test.mjs "triple asterisk is combined bold italic" |
| 62 | func testTripleAsteriskIsCombinedBoldItalic() { |
| 63 | XCTAssertEqual(richTokens("***both***"), [.boldItalic("both")]) |
| 64 | XCTAssertEqual(richTokens("a ***b c*** d"), [.text("a "), .boldItalic("b c"), .text(" d")]) |
| 65 | // Neighbours don't bleed: bi next to bold, and markers still can't span lines. |
| 66 | XCTAssertEqual(richTokens("***x*** **y**"), [.boldItalic("x"), .text(" "), .bold("y")]) |
| 67 | XCTAssertEqual(richTokens("***a\nb***"), [.text("***a\nb***")]) |
| 68 | } |
| 69 | |
| 70 | func testUnderscoreItalicsNeedWordBoundaries() { |
| 71 | // file_names must not italicize (format.js's lookbehind/lookahead). |
| 72 | XCTAssertEqual(richTokens("see file_name_here"), [.text("see file_name_here")]) |
| 73 | } |
| 74 | |
| 75 | func testLinksAreNeverFormattedInside() { |
| 76 | // The underscore inside the URL must not become italics. |
| 77 | XCTAssertEqual(links("https://example.com/a_b_c"), ["https://example.com/a_b_c"]) |
| 78 | } |
| 79 | |
| 80 | // ---- blockTokens β the eight cases from test/format.test.mjs ---------- |
| 81 | |
| 82 | func testBlockPlainTextIsOneUntouchedBlock() { |
| 83 | XCTAssertEqual(blockTokens("hello\n\nworld"), [.p("hello\n\nworld")]) |
| 84 | XCTAssertEqual(blockTokens(""), [.p("")]) |
| 85 | } |
| 86 | |
| 87 | func testBlockFencedCodeMarkersVerbatim() { |
| 88 | XCTAssertEqual(blockTokens("look:\n```\nif (a > b) { *notBold* }\n```\nnice"), [ |
| 89 | .p("look:"), .code("if (a > b) { *notBold* }", lang: ""), .p("nice"), |
| 90 | ]) |
| 91 | } |
| 92 | |
| 93 | func testBlockFenceCapturesLanguage() { |
| 94 | XCTAssertEqual(blockTokens("```js\nconst x = 1;\n```"), |
| 95 | [.code("const x = 1;", lang: "js")]) |
| 96 | } |
| 97 | |
| 98 | func testBlockUnterminatedFenceRunsToEnd() { |
| 99 | XCTAssertEqual(blockTokens("```\nline one\nline two"), |
| 100 | [.code("line one\nline two", lang: "")]) |
| 101 | } |
| 102 | |
| 103 | func testBlockInlineBackticksAreNotAFence() { |
| 104 | XCTAssertEqual(blockTokens("```js``` opens a fence"), |
| 105 | [.p("```js``` opens a fence")]) |
| 106 | } |
| 107 | |
| 108 | func testBlockConsecutiveQuoteLinesMerge() { |
| 109 | XCTAssertEqual(blockTokens("> two roads diverged\n> in a yellow wood\nso true"), [ |
| 110 | .quote("two roads diverged\nin a yellow wood"), .p("so true"), |
| 111 | ]) |
| 112 | } |
| 113 | |
| 114 | func testBlockBareQuoteContinuesAsBlankLine() { |
| 115 | XCTAssertEqual(blockTokens("> first\n>\n> second"), [.quote("first\n\nsecond")]) |
| 116 | } |
| 117 | |
| 118 | func testBlockMidLineAngleIsNotAQuote() { |
| 119 | XCTAssertEqual(blockTokens("5 > 3 and 2 > 1"), [.p("5 > 3 and 2 > 1")]) |
| 120 | } |
| 121 | |
| 122 | func testBlockBlankLinesAroundBlocksDropped() { |
| 123 | XCTAssertEqual(blockTokens("intro\n\n> quoted\n\noutro"), [ |
| 124 | .p("intro"), .quote("quoted"), .p("outro"), |
| 125 | ]) |
| 126 | } |
| 127 | |
| 128 | // ---- youtubeId β the shapes from test/format.test.mjs ------------------ |
| 129 | |
| 130 | func testYouTubeIdParsesCommonShapes() { |
| 131 | func yt(_ s: String) -> (String, Int)? { |
| 132 | youtubeId(URL(string: s)!).map { ($0.id, $0.start) } |
| 133 | } |
| 134 | XCTAssertEqual(yt("https://www.youtube.com/watch?v=dQw4w9WgXcQ")?.0, "dQw4w9WgXcQ") |
| 135 | XCTAssertEqual(yt("https://youtu.be/dQw4w9WgXcQ")?.0, "dQw4w9WgXcQ") |
| 136 | XCTAssertEqual(yt("https://m.youtube.com/watch?v=dQw4w9WgXcQ&pp=xyz")?.0, "dQw4w9WgXcQ") |
| 137 | XCTAssertEqual(yt("https://www.youtube.com/shorts/dQw4w9WgXcQ")?.0, "dQw4w9WgXcQ") |
| 138 | XCTAssertEqual(yt("https://www.youtube.com/embed/dQw4w9WgXcQ")?.0, "dQw4w9WgXcQ") |
| 139 | XCTAssertEqual(yt("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90")?.1, 90) |
| 140 | XCTAssertEqual(yt("https://youtu.be/dQw4w9WgXcQ?t=1h2m3s")?.1, 3723) |
| 141 | XCTAssertNil(yt("https://example.com/watch?v=dQw4w9WgXcQ")) |
| 142 | XCTAssertNil(yt("https://www.youtube.com/watch?v=short")) |
| 143 | } |
| 144 | |
| 145 | func testEmojiOnlyCount() { |
| 146 | XCTAssertEqual(emojiOnlyCount("π"), 1) |
| 147 | XCTAssertEqual(emojiOnlyCount("ππ"), 2) |
| 148 | XCTAssertEqual(emojiOnlyCount("πππ"), 3) |
| 149 | XCTAssertEqual(emojiOnlyCount("π¨βπ©βπ§βπ¦"), 1, "ZWJ family is one emoji") |
| 150 | XCTAssertEqual(emojiOnlyCount("ππ½"), 1, "skin tone is one emoji") |
| 151 | XCTAssertEqual(emojiOnlyCount("π¬π§"), 1, "flag is one emoji") |
| 152 | XCTAssertEqual(emojiOnlyCount("π π"), 2, "whitespace between emoji allowed") |
| 153 | XCTAssertEqual(emojiOnlyCount("ππππ"), 0, "more than 3 renders normally") |
| 154 | XCTAssertEqual(emojiOnlyCount("hi π"), 0) |
| 155 | XCTAssertEqual(emojiOnlyCount("π nice"), 0) |
| 156 | XCTAssertEqual(emojiOnlyCount("plain text"), 0) |
| 157 | XCTAssertEqual(emojiOnlyCount(""), 0) |
| 158 | } |
| 159 | |
| 160 | // The web twin's regression (format.test.mjs): emphasis markers around a |
| 161 | // URL must never ride into the href β "**github.com/apps/x**" gets a |
| 162 | // clean link, stars left as literal text. |
| 163 | func testEmphasisMarkersStayOutOfHrefs() { |
| 164 | let toks = tokenizeText("go to **github.com/apps/eleven-messenger** and install") |
| 165 | let link = toks.first(where: { $0.href != nil }) |
| 166 | XCTAssertEqual(link?.text, "github.com/apps/eleven-messenger") |
| 167 | XCTAssertEqual(link?.href?.absoluteString.contains("*"), false) |
| 168 | XCTAssertEqual(toks.map(\.text).joined(), "go to **github.com/apps/eleven-messenger** and install") |
| 169 | |
| 170 | let toks2 = tokenizeText("see https://docs.github.com/rest/issues#list**") |
| 171 | let link2 = toks2.first(where: { $0.href != nil }) |
| 172 | XCTAssertEqual(link2?.text, "https://docs.github.com/rest/issues#list") |
| 173 | } |
| 174 | } |
| 175 | |