import XCTest @testable import Rosetta // MARK: - Reply Preview Text Tests (ChatDetailView.replyPreviewText parity) /// Tests for reply preview text logic — cross-platform parity with Android/Desktop. /// Android: `ChatDetailInput.kt` lines 653-679 /// Desktop: `constructLastMessageTextByAttachments.ts` /// /// Covers ALL 3 iOS display contexts: /// 1. Reply bar input — `ChatDetailView.replyPreviewText(for:)` /// 2. Reply bar render — `ChatDetailView.replyBar(for:)` inline closure /// 3. Reply quote bubble — `MessageCellView.replyQuoteView(reply:outgoing:)` /// Plus: chat list last message — `DialogRepository.updateDialogFromMessages()` final class ReplyPreviewTextTests: XCTestCase { // MARK: - Helpers /// Standalone replica of `ChatDetailView.replyPreviewText(for:)`. /// Must match the real implementation exactly. private func replyPreviewText(for message: ChatMessage) -> String { let attachmentLabel: String? = { for att in message.attachments { switch att.type { case .image: return "Photo" case .file: let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview) if !parsed.fileName.isEmpty { return parsed.fileName } return att.id.isEmpty ? "File" : att.id case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" case .voice: return "Voice message" } } return nil }() let visibleText: String = { let stripped = message.text .trimmingCharacters(in: .whitespacesAndNewlines) .filter { !$0.isASCII || $0.asciiValue! >= 0x20 } if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" } return stripped }() let visibleTextDecoded = EmojiParser.replaceShortcodes(in: visibleText) if attachmentLabel != nil, !visibleTextDecoded.isEmpty { return visibleTextDecoded } if let label = attachmentLabel { return label } if !visibleTextDecoded.isEmpty { return visibleTextDecoded } return "" } /// Standalone replica of SessionManager in-app banner preview logic. /// Intentionally mirrors current behavior for regression coverage. private func inAppBannerPreviewText(text: String, attachments: [MessageAttachment]) -> String { if !text.isEmpty { return EmojiParser.replaceShortcodes(in: text) } if let firstAtt = attachments.first { switch firstAtt.type { case .image: return "Photo" case .file: return "File" case .voice: return "Voice message" case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" default: return "Attachment" } } return "New message" } /// Standalone replica of NativeMessageList reply text cache logic for UIKit cells. /// When outer message is empty, it is treated as forwarded wrapper (no quote text shown). private func nativeReplyCacheText(outerMessageText: String, reply: ReplyMessageData) -> String? { let displayText = MessageCellLayout.isGarbageOrEncrypted(outerMessageText) ? "" : outerMessageText guard !displayText.isEmpty else { return nil } let trimmed = reply.message.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return EmojiParser.replaceShortcodes(in: reply.message) } if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" } /// Standalone replica of `MessageCellView.replyQuoteView` preview logic. /// Uses `ReplyMessageData` + `ReplyAttachmentData` (Int type), not `MessageAttachment`. private func replyQuotePreviewText(for reply: ReplyMessageData) -> String { let trimmed = reply.message.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { return EmojiParser.replaceShortcodes(in: reply.message) } if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" } if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" } if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" } if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" } if reply.attachments.contains(where: { $0.type == AttachmentType.voice.rawValue }) { return "Voice message" } if reply.attachments.contains(where: { $0.type == AttachmentType.call.rawValue }) { return "Call" } return "Attachment" } /// Standalone replica of `DialogRepository.updateDialogFromMessages` preview logic. private func chatListPreviewText(text: String, attachments: [MessageAttachment]) -> String { let textIsEmpty = text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || Self.isGarbageText(text) if textIsEmpty, let firstAtt = attachments.first { switch firstAtt.type { case .image: return "Photo" case .file: return "File" case .avatar: return "Avatar" case .messages: return "Forwarded message" case .call: return "Call" case .voice: return "Voice message" } } else if textIsEmpty { return "" } return text } /// Mirror of `DialogRepository.isGarbageText`. private static func isGarbageText(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return true } // Detect encrypted payload: ivBase64:ctBase64 format let parts = trimmed.components(separatedBy: ":") if parts.count == 2 { let base64Chars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+/=")) let bothBase64 = parts.allSatisfy { part in part.count >= 16 && part.unicodeScalars.allSatisfy { base64Chars.contains($0) } } if bothBase64 { return true } } if trimmed.hasPrefix("CHNK:") { return true } let validCharacters = trimmed.unicodeScalars.filter { scalar in scalar.value != 0xFFFD && scalar.value > 0x1F && scalar.value != 0x7F && !CharacterSet.controlCharacters.contains(scalar) } return validCharacters.isEmpty } private func makeMessage( text: String = "", attachments: [MessageAttachment] = [] ) -> ChatMessage { ChatMessage( id: UUID().uuidString, fromPublicKey: "sender", toPublicKey: "recipient", text: text, timestamp: Int64(Date().timeIntervalSince1970 * 1000), deliveryStatus: .delivered, isRead: true, attachments: attachments ) } private func makeAttachment(type: AttachmentType, preview: String = "", id: String = "") -> MessageAttachment { MessageAttachment(id: id.isEmpty ? UUID().uuidString : id, preview: preview, blob: "", type: type) } private func makeReply( message: String = "", attachments: [ReplyAttachmentData] = [] ) -> ReplyMessageData { ReplyMessageData( message_id: UUID().uuidString, publicKey: "sender", message: message, timestamp: Int64(Date().timeIntervalSince1970 * 1000), attachments: attachments ) } private func makeReplyAttachment(type: Int, preview: String = "") -> ReplyAttachmentData { ReplyAttachmentData(id: UUID().uuidString, type: type, preview: preview, blob: "") } // ========================================================================= // MARK: - Reply Bar Preview (ChatDetailView.replyPreviewText) // ========================================================================= // MARK: Photo func testReplyBar_PhotoNoCaption() { let msg = makeMessage(attachments: [makeAttachment(type: .image)]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") } func testReplyBar_PhotoWithCaption() { let msg = makeMessage(text: "Nice sunset", attachments: [makeAttachment(type: .image)]) XCTAssertEqual(replyPreviewText(for: msg), "Nice sunset") } func testReplyBar_PhotoWithWhitespaceCaption() { let msg = makeMessage(text: " \n ", attachments: [makeAttachment(type: .image)]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") } func testReplyBar_MultiplePhotos() { let msg = makeMessage(attachments: [ makeAttachment(type: .image), makeAttachment(type: .image), makeAttachment(type: .image) ]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") } // MARK: File func testReplyBar_FileWithFilename() { let att = makeAttachment(type: .file, preview: "tag::12345::document.pdf") let msg = makeMessage(attachments: [att]) XCTAssertEqual(replyPreviewText(for: msg), "document.pdf") } func testReplyBar_FileNoPreview() { let att = makeAttachment(type: .file, id: "file_123") let msg = makeMessage(attachments: [att]) XCTAssertEqual(replyPreviewText(for: msg), "file_123") } func testReplyBar_FileEmptyIdAndPreview() { let att = MessageAttachment(id: "", preview: "", blob: "", type: .file) let msg = makeMessage(attachments: [att]) XCTAssertEqual(replyPreviewText(for: msg), "File") } func testReplyBar_FileWithCaption() { let att = makeAttachment(type: .file, preview: "tag::500::notes.txt") let msg = makeMessage(text: "Here are the notes", attachments: [att]) XCTAssertEqual(replyPreviewText(for: msg), "Here are the notes") } // MARK: Avatar func testReplyBar_Avatar() { let msg = makeMessage(attachments: [makeAttachment(type: .avatar)]) XCTAssertEqual(replyPreviewText(for: msg), "Avatar") } func testReplyBar_AvatarWithCaptionUsesCaption() { let msg = makeMessage(text: "Profile pic", attachments: [makeAttachment(type: .avatar)]) XCTAssertEqual(replyPreviewText(for: msg), "Profile pic") } // MARK: Forward func testReplyBar_Forward() { let msg = makeMessage(attachments: [makeAttachment(type: .messages)]) XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message") } func testReplyBar_ForwardWithCaptionUsesCaption() { let msg = makeMessage(text: "See this", attachments: [makeAttachment(type: .messages)]) XCTAssertEqual(replyPreviewText(for: msg), "See this") } // MARK: Call (Android/Desktop parity) func testReplyBar_Call() { let msg = makeMessage(attachments: [makeAttachment(type: .call)]) XCTAssertEqual(replyPreviewText(for: msg), "Call") } func testReplyBar_CallWithCaptionUsesCaption() { let msg = makeMessage(text: "Missed you", attachments: [makeAttachment(type: .call)]) XCTAssertEqual(replyPreviewText(for: msg), "Missed you") } func testReplyBar_VoiceMessage() { let msg = makeMessage(attachments: [makeAttachment(type: .voice)]) XCTAssertEqual(replyPreviewText(for: msg), "Voice message") } func testReplyBar_VoiceWithCaptionUsesCaption() { let msg = makeMessage(text: "Listen", attachments: [makeAttachment(type: .voice)]) XCTAssertEqual(replyPreviewText(for: msg), "Listen") } // MARK: Text func testReplyBar_TextOnly() { let msg = makeMessage(text: "Hello world") XCTAssertEqual(replyPreviewText(for: msg), "Hello world") } func testReplyBar_TextShortcodesAreDecodedToEmoji() { let msg = makeMessage(text: "ok :emoji_1f44d:") let preview = replyPreviewText(for: msg) XCTAssertFalse(preview.contains(":emoji_")) XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) } func testReplyBar_Empty() { let msg = makeMessage() XCTAssertEqual(replyPreviewText(for: msg), "") } // MARK: Priority func testReplyBar_ImagePriorityOverCall() { let msg = makeMessage(attachments: [ makeAttachment(type: .image), makeAttachment(type: .call) ]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") } func testReplyBar_ImagePriorityOverFile() { let msg = makeMessage(attachments: [ makeAttachment(type: .image), makeAttachment(type: .file, preview: "tag::100::doc.pdf") ]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") } func testReplyBar_FilePriorityOverAvatar() { let msg = makeMessage(attachments: [ makeAttachment(type: .file, preview: "tag::100::doc.pdf"), makeAttachment(type: .avatar) ]) XCTAssertEqual(replyPreviewText(for: msg), "doc.pdf") } // ========================================================================= // MARK: - Reply Quote Bubble (MessageCellView.replyQuoteView) // ========================================================================= func testQuote_PhotoNoText() { let reply = makeReply(attachments: [makeReplyAttachment(type: 0)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") } func testQuote_PhotoWithText() { let reply = makeReply(message: "Check this out", attachments: [makeReplyAttachment(type: 0)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Check this out") } func testQuote_File() { let reply = makeReply(attachments: [makeReplyAttachment(type: 2)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "File") } func testQuote_Avatar() { let reply = makeReply(attachments: [makeReplyAttachment(type: 3)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Avatar") } func testQuote_Forward() { let reply = makeReply(attachments: [makeReplyAttachment(type: 1)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Forwarded message") } func testQuote_Call() { let reply = makeReply(attachments: [makeReplyAttachment(type: 4)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Call") } func testQuote_VoiceMessage() { let reply = makeReply(attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Voice message") } func testQuote_UnknownType() { let reply = makeReply(attachments: [makeReplyAttachment(type: 99)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Attachment") } func testQuote_TextPriority() { let reply = makeReply(message: "Some text", attachments: [makeReplyAttachment(type: 4)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Some text") } func testQuote_TextShortcodesAreDecodedToEmoji() { let reply = makeReply(message: "ok :emoji_1f44d:", attachments: []) let preview = replyQuotePreviewText(for: reply) XCTAssertFalse(preview.contains(":emoji_")) XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) } func testQuote_WhitespaceText() { let reply = makeReply(message: " ", attachments: [makeReplyAttachment(type: 0)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") } // ========================================================================= // MARK: - Chat List Preview (DialogRepository) // ========================================================================= func testChatList_Photo() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .image)]), "Photo") } func testChatList_File() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .file)]), "File") } func testChatList_Avatar() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .avatar)]), "Avatar") } func testChatList_Forward() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .messages)]), "Forwarded message") } func testChatList_Call() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .call)]), "Call") } func testChatList_Voice() { XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .voice)]), "Voice message") } func testChatList_TextWins() { XCTAssertEqual(chatListPreviewText(text: "Hello", attachments: [makeAttachment(type: .image)]), "Hello") } func testChatList_EmptyNoAttachment() { XCTAssertEqual(chatListPreviewText(text: "", attachments: []), "") } func testChatList_GarbageTextShowsAttachment() { // U+FFFD replacement chars = garbage text → should show attachment label let garbage = "\u{FFFD}\u{FFFD}\u{FFFD}" XCTAssertEqual(chatListPreviewText(text: garbage, attachments: [makeAttachment(type: .image)]), "Photo") } func testChatList_ControlCharsShowsAttachment() { let control = "\u{01}\u{02}\u{03}" XCTAssertEqual(chatListPreviewText(text: control, attachments: [makeAttachment(type: .call)]), "Call") } // ========================================================================= // MARK: - In-App Banner Preview (SessionManager) // ========================================================================= func testInAppBanner_TextShortcodesAreDecodedToEmoji() { let input = "ok :emoji_1f44d:" let preview = inAppBannerPreviewText(text: input, attachments: []) XCTAssertFalse(preview.contains(":emoji_")) XCTAssertTrue(preview.contains(EmojiParser.unifiedToEmoji("1f44d"))) } func testInAppBanner_VoiceAttachmentLabel() { let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .voice)]) XCTAssertEqual(preview, "Voice message") } func testInAppBanner_AvatarAttachmentLabel() { let preview = inAppBannerPreviewText(text: "", attachments: [makeAttachment(type: .avatar)]) XCTAssertEqual(preview, "Avatar") } // ========================================================================= // MARK: - Native Reply Cache (NativeMessageList UIKit path) // ========================================================================= func testNativeReplyCache_VoiceAttachmentLabel() { let reply = makeReply(message: "", attachments: [makeReplyAttachment(type: AttachmentType.voice.rawValue)]) XCTAssertEqual(nativeReplyCacheText(outerMessageText: "fix", reply: reply), "Voice message") } func testNativeReplyCache_DecodesEmojiShortcodes() { let reply = makeReply(message: "ok :emoji_1f44d:", attachments: []) XCTAssertEqual(nativeReplyCacheText(outerMessageText: "text", reply: reply), "ok 👍") } // ========================================================================= // MARK: - AttachmentType JSON Decode Fault Tolerance // ========================================================================= func testDecode_UnknownTypeFallsBackToImage() throws { let json = #"[{"id":"test","preview":"","blob":"","type":99}]"# let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) XCTAssertEqual(decoded.count, 1) XCTAssertEqual(decoded[0].type, .image) } func testDecode_NegativeTypeFallsBackToImage() throws { let json = #"[{"id":"test","preview":"","blob":"","type":-1}]"# let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) XCTAssertEqual(decoded.count, 1) XCTAssertEqual(decoded[0].type, .image) } func testDecode_MixedKnownAndUnknown() throws { let json = #"[{"id":"a","preview":"","blob":"","type":0},{"id":"b","preview":"","blob":"","type":99}]"# let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) XCTAssertEqual(decoded.count, 2) XCTAssertEqual(decoded[0].type, .image) XCTAssertEqual(decoded[1].type, .image) } func testDecode_AllKnownTypes() throws { let json = """ [{"id":"a","preview":"","blob":"","type":0},\ {"id":"b","preview":"","blob":"","type":1},\ {"id":"c","preview":"","blob":"","type":2},\ {"id":"d","preview":"","blob":"","type":3},\ {"id":"e","preview":"","blob":"","type":4}] """ let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) XCTAssertEqual(decoded.count, 5) XCTAssertEqual(decoded[0].type, .image) XCTAssertEqual(decoded[1].type, .messages) XCTAssertEqual(decoded[2].type, .file) XCTAssertEqual(decoded[3].type, .avatar) XCTAssertEqual(decoded[4].type, .call) } func testDecode_EmptyArrayIsValid() throws { let json = "[]" let decoded = try JSONDecoder().decode([MessageAttachment].self, from: Data(json.utf8)) XCTAssertEqual(decoded.count, 0) } // MARK: - Encode → Decode Round-Trip func testRoundTrip_AllTypes() throws { let original: [MessageAttachment] = [ MessageAttachment(id: "1", preview: "tag::hash", blob: "data", type: .image), MessageAttachment(id: "2", preview: "", blob: "", type: .messages), MessageAttachment(id: "3", preview: "tag::500::doc.pdf", blob: "", type: .file), MessageAttachment(id: "4", preview: "", blob: "", type: .avatar), MessageAttachment(id: "5", preview: "60", blob: "", type: .call), ] let data = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode([MessageAttachment].self, from: data) XCTAssertEqual(decoded, original) } // ========================================================================= // MARK: - Cross-Context Consistency // ========================================================================= /// Verify all 3 contexts produce the same label for each type when text is empty. func testAllContextsAgree_Photo() { let msg = makeMessage(attachments: [makeAttachment(type: .image)]) let reply = makeReply(attachments: [makeReplyAttachment(type: 0)]) XCTAssertEqual(replyPreviewText(for: msg), "Photo") XCTAssertEqual(replyQuotePreviewText(for: reply), "Photo") XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Photo") } func testAllContextsAgree_File() { // Note: reply bar shows filename, quote/chatlist show "File" let reply = makeReply(attachments: [makeReplyAttachment(type: 2)]) XCTAssertEqual(replyQuotePreviewText(for: reply), "File") XCTAssertEqual(chatListPreviewText(text: "", attachments: [makeAttachment(type: .file)]), "File") } func testAllContextsAgree_Avatar() { let msg = makeMessage(attachments: [makeAttachment(type: .avatar)]) let reply = makeReply(attachments: [makeReplyAttachment(type: 3)]) XCTAssertEqual(replyPreviewText(for: msg), "Avatar") XCTAssertEqual(replyQuotePreviewText(for: reply), "Avatar") XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Avatar") } func testAllContextsAgree_Forward() { let msg = makeMessage(attachments: [makeAttachment(type: .messages)]) let reply = makeReply(attachments: [makeReplyAttachment(type: 1)]) XCTAssertEqual(replyPreviewText(for: msg), "Forwarded message") XCTAssertEqual(replyQuotePreviewText(for: reply), "Forwarded message") XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Forwarded message") } func testAllContextsAgree_Call() { let msg = makeMessage(attachments: [makeAttachment(type: .call)]) let reply = makeReply(attachments: [makeReplyAttachment(type: 4)]) XCTAssertEqual(replyPreviewText(for: msg), "Call") XCTAssertEqual(replyQuotePreviewText(for: reply), "Call") XCTAssertEqual(chatListPreviewText(text: "", attachments: msg.attachments), "Call") } }