import XCTest @testable import LChatCore // The @mention render-layer scan (Mentions.swift) — the native twin of the // web's renderRichText mention rules (apps/chat.js), so both feeds agree on // exactly which "@…" runs are mentions. final class MentionsTests: XCTestCase { private func index(me: String = "me") -> MentionIndex { MentionIndex(entries: [ .init(label: "Ada", uid: "u1", display: "Ada", tappable: true), .init(label: "Ada Lovelace", uid: "u2", display: "Ada L", tappable: true), .init(label: "Bob", uid: "u3", display: "Bobby", tappable: true), .init(label: "Me", uid: me, display: "Me", tappable: false), ]) } func testBasicMention() { let segs = index().split("hi @Bob!") XCTAssertEqual(segs, [ .text("hi "), .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), .text("!"), ]) } func testLongestLabelWins() { // "@Ada Lovelace" must resolve to the two-word member, never "Ada"+text. let segs = index().split("@Ada Lovelace hello") guard case .mention(let e) = segs.first else { return XCTFail("no mention") } XCTAssertEqual(e.uid, "u2") XCTAssertEqual(segs.dropFirst().first, .text(" hello")) } func testMidWordAndEmailNeverMatch() { // The web's prevCh guard: /[\w@.]/ blocks mid-word @, emails, and // domain-ish text. XCTAssertEqual(index().split("mail@Bob.com"), [.text("mail@Bob.com")]) XCTAssertEqual(index().split("x@Bob"), [.text("x@Bob")]) XCTAssertEqual(index().split("a.@Bob"), [.text("a.@Bob")]) } func testTrailingWordCharBlocks() { // (?![\w]) — "@Bobcat" is not a mention of Bob… XCTAssertEqual(index().split("@Bobcat"), [.text("@Bobcat")]) // …but trailing punctuation is fine. guard case .mention(let e)? = index().split("@Bob,").first else { return XCTFail("no mention") } XCTAssertEqual(e.uid, "u3") } func testStartAndAfterWhitespaceMatch() { XCTAssertEqual(index().split("@Bob").count, 1) let segs = index().split("(@Bob)") XCTAssertEqual(segs, [ .text("("), .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), .text(")"), ]) } func testSelfMentionNotTappable() { guard case .mention(let e)? = index().split("@Me too").first else { return XCTFail("no mention") } XCTAssertFalse(e.tappable) } func testAdjacentMentions() { // "@Ada@Bob" — the web's regex leaves the second as plain text (its // prev char is a word char / the chip boundary). XCTAssertEqual(index().split("@Ada@Bob"), [ .mention(.init(label: "Ada", uid: "u1", display: "Ada", tappable: true)), .text("@Bob"), ]) // Separated by a space both match. let both = index().split("@Ada @Bob") XCTAssertEqual(both.count, 3) } func testUnknownNameStaysText() { XCTAssertEqual(index().split("@Nobody here"), [.text("@Nobody here")]) } func testMayMentionFastPath() { XCTAssertFalse(index().mayMention("no at sign")) XCTAssertTrue(index().mayMention("hi @Bob")) XCTAssertFalse(MentionIndex(entries: []).mayMention("hi @Bob")) } func testRevChangesWithDisplay() { // The memo key: a rename (display change) must produce a new rev. let a = index() let b = MentionIndex(entries: [ .init(label: "Ada", uid: "u1", display: "Ada", tappable: true), .init(label: "Ada Lovelace", uid: "u2", display: "Ada L", tappable: true), .init(label: "Bob", uid: "u3", display: "Robert", tappable: true), .init(label: "Me", uid: "me", display: "Me", tappable: false), ]) XCTAssertNotEqual(a.rev, b.rev) } func testUnicodeTextAroundMentions() { let segs = index().split("héllo 👋 @Bob — ça va?") XCTAssertEqual(segs, [ .text("héllo 👋 "), .mention(.init(label: "Bob", uid: "u3", display: "Bobby", tappable: true)), .text(" — ça va?"), ]) } func testMentionLinkRoundTrip() { let uid = "user id/with?odd#chars" guard let url = MentionLink.url(uid: uid) else { return XCTFail("no url") } XCTAssertEqual(MentionLink.uid(from: url), uid) XCTAssertNil(MentionLink.uid(from: URL(string: "https://example.com/x")!)) } }