// // MessageFormattingEngineTests.swift // bitchatTests // // Tests for MessageFormattingEngine regex patterns and utility functions. // This is free and unencumbered software released into the public domain. // import Testing import Foundation import SwiftUI import BitFoundation @testable import bitchat struct MessageFormattingEngineTests { // MARK: - Formatting Behavior Tests @MainActor @Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() { let senderPeerID = PeerID(str: "abcdef1234567890") let context = MockMessageFormattingContext( nickname: "carol", peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!] ) let message = BitchatMessage( id: "message-1", sender: "alice#a1b2", content: "hello #mesh https://example.com", timestamp: Date(timeIntervalSince1970: 1_700_000_000), isRelay: false, senderPeerID: senderPeerID ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil) } @MainActor @Test func formatMessage_systemMessageUsesSystemLayout() { let context = MockMessageFormattingContext(nickname: "carol") let message = BitchatMessage( id: "system-1", sender: "system", content: "connected", timestamp: Date(timeIntervalSince1970: 1_700_000_123), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark) #expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil) } @MainActor @Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() { let context = MockMessageFormattingContext( nickname: "me", selfMessageIDs: ["self-1"] ) let longContent = String(repeating: "a", count: 4_500) let message = BitchatMessage( id: "self-1", sender: "me#cafe", content: longContent, timestamp: Date(timeIntervalSince1970: 1_700_000_456), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]") #expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil) } @MainActor @Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() { let context = MockMessageFormattingContext(nickname: "carol") let message = BitchatMessage( id: "message-mention", sender: "alice", content: "hi @bob#a1b2", timestamp: Date(timeIntervalSince1970: 1_700_000_789), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]") } @MainActor @Test func formatHeader_formatsNormalAndSystemSenders() { let context = MockMessageFormattingContext(nickname: "carol") let normalMessage = BitchatMessage( id: "header-1", sender: "alice#a1b2", content: "hello", timestamp: Date(timeIntervalSince1970: 1_700_001_000), isRelay: false ) let systemMessage = BitchatMessage( id: "header-2", sender: "system", content: "notice", timestamp: Date(timeIntervalSince1970: 1_700_001_111), isRelay: false ) let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light) let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark) #expect(String(normalHeader.characters) == "<@alice#a1b2> ") #expect(String(systemHeader.characters) == "system") } // MARK: - Mention Extraction Tests @Test func extractMentions_singleMention() { let content = "Hello @alice how are you?" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["alice"]) } @Test func extractMentions_multipleMentions() { let content = "@alice and @bob are chatting with @charlie" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.count == 3) #expect(mentions.contains("alice")) #expect(mentions.contains("bob")) #expect(mentions.contains("charlie")) } @Test func extractMentions_mentionWithSuffix() { let content = "Hey @alice#a1b2 check this out" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["alice#a1b2"]) } @Test func extractMentions_noMentions() { let content = "Just a regular message with no mentions" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.isEmpty) } @Test func extractMentions_unicodeNickname() { let content = "Hello @日本語 and @émile" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions.count == 2) #expect(mentions.contains("日本語")) #expect(mentions.contains("émile")) } @Test func extractMentions_mentionWithUnderscore() { let content = "Thanks @user_name_123" let mentions = MessageFormattingEngine.extractMentions(from: content) #expect(mentions == ["user_name_123"]) } @Test func extractMentions_emailNotCaptured() { // Email addresses should not be captured as mentions let content = "Contact me at test@example.com" let mentions = MessageFormattingEngine.extractMentions(from: content) // The regex will capture "example" after @ in email - this is expected behavior // as the regex doesn't distinguish email addresses #expect(mentions.count == 1) } // MARK: - Cashu Token Detection Tests @Test func containsCashuToken_validTokenA() { let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" #expect(MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_validTokenB() { let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" #expect(MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_noToken() { let content = "Just a regular message about cashews" #expect(!MessageFormattingEngine.containsCashuToken(content)) } @Test func containsCashuToken_tooShort() { let content = "Invalid: cashuAshort" #expect(!MessageFormattingEngine.containsCashuToken(content)) } // MARK: - Regex Pattern Tests @Test func hashtagPattern_standaloneHashtag() { let content = "#bitcoin is great" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func hashtagPattern_multipleHashtags() { let content = "#bitcoin #lightning #nostr" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) #expect(matches.count == 3) } @Test func hashtagPattern_hashInMiddleOfWord() { let content = "test#notahashtag" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) // This will match because the regex doesn't check for word boundaries #expect(matches.count == 1) } @Test func bolt11Pattern_mainnet() { let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func bolt11Pattern_testnet() { let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func lnurlPattern_valid() { let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func lightningSchemePattern_valid() { let content = "Click: lightning:lnbc10u1example" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range) #expect(matches.count == 1) } @Test func cashuPattern_valid() { let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0=" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range) #expect(matches.count == 1) } // MARK: - URL Detection Tests @Test func linkDetector_httpURL() { let content = "Check out http://example.com" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 1) } @Test func linkDetector_httpsURL() { let content = "Visit https://example.com/path?query=value" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 1) } @Test func linkDetector_multipleURLs() { let content = "See https://a.com and http://b.com" let nsContent = content as NSString let range = NSRange(location: 0, length: nsContent.length) let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] #expect(matches.count == 2) } // MARK: - String Extension Tests @Test func splitSuffix_withSuffix() { let name = "alice#a1b2" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "#a1b2") } @Test func splitSuffix_withoutSuffix() { let name = "alice" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "") } @Test func splitSuffix_withAtPrefix() { let name = "@alice#a1b2" let (base, suffix) = name.splitSuffix() #expect(base == "alice") #expect(suffix == "#a1b2") } @Test func hasVeryLongToken_noLongToken() { let content = "Short words only here" #expect(!content.hasVeryLongToken(threshold: 50)) } @Test func hasVeryLongToken_withLongToken() { let longToken = String(repeating: "a", count: 100) let content = "Here is a \(longToken) token" #expect(content.hasVeryLongToken(threshold: 50)) } @Test func hasVeryLongToken_exactThreshold() { let exactToken = String(repeating: "a", count: 50) let content = "Token: \(exactToken)" // Exactly at threshold DOES trigger (uses >= comparison) #expect(content.hasVeryLongToken(threshold: 50)) } @Test func isLongForDisplay_doesNotIgnoreCashuLinks() { let cashu = "cashuA" + String(repeating: "a", count: 40) let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu #expect(content.extractCashuLinks().count == 1) #expect(content.isLongForDisplay()) } @MainActor @Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() { let context = MockMessageFormattingContext(nickname: "carol") let cashu = "cashuA" + String(repeating: "a", count: 40) let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100) let message = BitchatMessage( id: "long-cashu", sender: "alice", content: longContent, timestamp: Date(timeIntervalSince1970: 1_700_000_999), isRelay: false ) let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) #expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]") } } @MainActor private final class MockMessageFormattingContext: MessageFormattingContext { let nickname: String private let selfMessageIDs: Set private let peerURLs: [PeerID: URL] init( nickname: String, selfMessageIDs: Set = [], peerURLs: [PeerID: URL] = [:] ) { self.nickname = nickname self.selfMessageIDs = selfMessageIDs self.peerURLs = peerURLs } func isSelfMessage(_ message: BitchatMessage) -> Bool { selfMessageIDs.contains(message.id) } func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .red } func peerURL(for peerID: PeerID) -> URL? { peerURLs[peerID] } }