mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
Per review: MessageFormattingEngine and ChatMessageFormatter each grew their own copy of the sort-then-drop-overlaps pass, which risks drifting apart later. Extract it into a single generic helper, MessageFormattingEngine.resolveOverlappingMatches, keyed by an NSRange extractor closure so it works over both formatters' different match representations (a ContentMatch struct vs. a (range, type) tuple) without unifying those types. Add direct unit tests for the extracted helper (nested overlap dropped, out-of-order input sorted internally, adjacent-but-non-overlapping ranges both kept) since ChatMessageFormatter's copy wasn't independently unit-testable without a live ChatViewModel.
445 lines
18 KiB
Swift
445 lines
18 KiB
Swift
//
|
|
// 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
|
|
@Test func formatMessage_urlContainingCashuTokenIsNotDuplicated() {
|
|
// Bug: a URL match and a Cashu match are never cross-checked for
|
|
// overlap, only each independently against mentions. A URL whose
|
|
// path embeds a "cashuA..."-shaped segment (a perfectly ordinary
|
|
// mint redemption link) produces two overlapping matches; the
|
|
// single linear rendering pass then re-renders the nested cashu
|
|
// substring a second time and, because it walks `lastEnd` backwards,
|
|
// re-renders the URL's own trailing segment a third time.
|
|
let context = MockMessageFormattingContext(nickname: "carol")
|
|
let cashuToken = "cashuA" + String(repeating: "a", count: 44)
|
|
let url = "https://mint.example.com/redeem/\(cashuToken)/confirm"
|
|
let content = "redeem link \(url) now"
|
|
let message = BitchatMessage(
|
|
id: "url-cashu",
|
|
sender: "alice",
|
|
content: content,
|
|
timestamp: Date(timeIntervalSince1970: 1_700_001_111),
|
|
isRelay: false
|
|
)
|
|
|
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
|
|
|
#expect(String(formatted.characters) == "<@alice> \(content) [\(message.formattedTimestamp)]")
|
|
}
|
|
|
|
// MARK: - resolveOverlappingMatches Tests
|
|
//
|
|
// Direct coverage for the shared helper `ChatMessageFormatter` also
|
|
// calls, since its own match-collection logic (a hand-copy of
|
|
// findAllMatches above) isn't independently unit-testable without a
|
|
// live ChatViewModel.
|
|
|
|
@Test func resolveOverlappingMatches_dropsANestedOverlap() {
|
|
let outer = (range: NSRange(location: 0, length: 10), type: "outer")
|
|
let nested = (range: NSRange(location: 3, length: 2), type: "nested")
|
|
|
|
let resolved = MessageFormattingEngine.resolveOverlappingMatches([outer, nested]) { $0.range }
|
|
|
|
#expect(resolved.map(\.type) == ["outer"])
|
|
}
|
|
|
|
@Test func resolveOverlappingMatches_keepsNonOverlappingMatchesSortedByStart() {
|
|
let first = (range: NSRange(location: 0, length: 3), type: "first")
|
|
let second = (range: NSRange(location: 5, length: 3), type: "second")
|
|
|
|
// Passed out of order; the helper must sort internally.
|
|
let resolved = MessageFormattingEngine.resolveOverlappingMatches([second, first]) { $0.range }
|
|
|
|
#expect(resolved.map(\.type) == ["first", "second"])
|
|
}
|
|
|
|
@Test func resolveOverlappingMatches_adjacentNonOverlappingMatchesAreBothKept() {
|
|
// A match starting exactly where the previous one ends is not an
|
|
// overlap (NSRange upper bound is exclusive).
|
|
let first = (range: NSRange(location: 0, length: 5), type: "first")
|
|
let adjacent = (range: NSRange(location: 5, length: 5), type: "adjacent")
|
|
|
|
let resolved = MessageFormattingEngine.resolveOverlappingMatches([first, adjacent]) { $0.range }
|
|
|
|
#expect(resolved.map(\.type) == ["first", "adjacent"])
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockMessageFormattingContext: MessageFormattingContext {
|
|
let nickname: String
|
|
private let selfMessageIDs: Set<String>
|
|
private let peerURLs: [PeerID: URL]
|
|
|
|
init(
|
|
nickname: String,
|
|
selfMessageIDs: Set<String> = [],
|
|
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]
|
|
}
|
|
}
|