import XCTest @testable import Rosetta @MainActor final class SearchParityTests: XCTestCase { func testSearchViewModelAndChatListUseSameQueryNormalization() async { let searchDispatcher = MockSearchDispatcher() let chatDispatcher = MockSearchDispatcher() let searchVM = SearchViewModel(searchDispatcher: searchDispatcher) let chatVM = ChatListViewModel(searchDispatcher: chatDispatcher) searchVM.setSearchQuery(" @Alice ") chatVM.setSearchQuery(" @Alice ") try? await Task.sleep(for: .milliseconds(1200)) XCTAssertEqual(searchDispatcher.sentQueries, ["alice"]) XCTAssertEqual(chatDispatcher.sentQueries, ["alice"]) } func testSavedAliasesAndExactPublicKeyFallback() throws { let ownPair = try Self.makeKeyPair() let peerPair = try Self.makeKeyPair() let dialog = Self.makeDialog( account: ownPair.publicKeyHex, opponentKey: peerPair.publicKeyHex, username: "peer_user", title: "Peer User" ) let saved = SearchParityPolicy.localAugmentedUsers( query: "Saved Messages", currentPublicKey: ownPair.publicKeyHex, dialogs: [dialog] ) XCTAssertEqual(saved.count, 1) XCTAssertEqual(saved.first?.publicKey, ownPair.publicKeyHex) XCTAssertEqual(saved.first?.title, "Saved Messages") let exactPeer = SearchParityPolicy.localAugmentedUsers( query: "0x" + peerPair.publicKeyHex, currentPublicKey: ownPair.publicKeyHex, dialogs: [dialog] ) XCTAssertEqual(exactPeer.count, 1) XCTAssertEqual(exactPeer.first?.publicKey, peerPair.publicKeyHex) XCTAssertEqual(exactPeer.first?.username, "peer_user") } func testServerAndLocalMergeDedupesByPublicKeyWithServerPriority() { let key = "021111111111111111111111111111111111111111111111111111111111111111" let localOnlyKey = "022222222222222222222222222222222222222222222222222222222222222222" let server = [ SearchUser(username: "server_u", title: "Server Name", publicKey: key, verified: 2, online: 0), ] let local = [ SearchUser(username: "local_u", title: "Local Name", publicKey: key, verified: 0, online: 1), SearchUser(username: "local_only", title: "Local Only", publicKey: localOnlyKey, verified: 0, online: 1), ] let merged = SearchParityPolicy.mergeServerAndLocal(server: server, local: local) XCTAssertEqual(merged.count, 2) XCTAssertEqual(merged[0].publicKey, key) XCTAssertEqual(merged[0].title, "Server Name") XCTAssertEqual(merged[1].publicKey, localOnlyKey) } func testActiveUiSearchIgnoresBackgroundUserInfoPackets() async { let bus = ChannelSearchBus() let uiChannel = SearchPacketChannel.ui(UUID()) let uiDispatcher = ChannelBusSearchDispatcher(bus: bus, channel: uiChannel) let userInfoDispatcher = ChannelBusSearchDispatcher(bus: bus, channel: .userInfo) let vm = SearchViewModel(searchDispatcher: uiDispatcher) var backgroundRequest = PacketSearch() backgroundRequest.search = "02aa" userInfoDispatcher.sendSearchPacket(backgroundRequest) vm.setSearchQuery("alice") try? await Task.sleep(for: .milliseconds(1200)) XCTAssertEqual(uiDispatcher.sentQueries, ["alice"]) var backgroundPacket = PacketSearch() backgroundPacket.users = [ SearchUser(username: "random_user", title: "Random User", publicKey: "02aa", verified: 0, online: 0), ] userInfoDispatcher.emit(backgroundPacket) try? await Task.sleep(for: .milliseconds(80)) XCTAssertTrue(vm.searchResults.isEmpty) XCTAssertTrue(vm.isSearching) var uiPacket = PacketSearch() uiPacket.users = [ SearchUser(username: "alice_user", title: "Alice", publicKey: "02bb", verified: 1, online: 0), ] uiDispatcher.emit(uiPacket) try? await Task.sleep(for: .milliseconds(80)) XCTAssertEqual(vm.searchResults.count, 1) XCTAssertEqual(vm.searchResults.first?.username, "alice_user") XCTAssertFalse(vm.isSearching) } } private extension SearchParityTests { static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) { let mnemonic = try CryptoManager.shared.generateMnemonic() let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic) return (pair.privateKey.hexString, pair.publicKey.hexString) } static func makeDialog( account: String, opponentKey: String, username: String, title: String ) -> Dialog { Dialog( id: UUID().uuidString, account: account, opponentKey: opponentKey, opponentTitle: title, opponentUsername: username, lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0, isOnline: true, lastSeen: 0, verified: 0, iHaveSent: true, isPinned: false, isMuted: false, lastMessageFromMe: false, lastMessageDelivered: .delivered, lastMessageRead: true ) } } private final class MockSearchDispatcher: SearchResultDispatching { var connectionState: ConnectionState = .authenticated var privateHash: String? = "mock-private-hash" private(set) var sentQueries: [String] = [] private var handlers: [UUID: (PacketSearch) -> Void] = [:] func sendSearchPacket(_ packet: PacketSearch) { sentQueries.append(packet.search) } @discardableResult func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID { let id = UUID() handlers[id] = handler return id } func removeSearchResultHandler(_ id: UUID) { handlers.removeValue(forKey: id) } } @MainActor private final class ChannelBusSearchDispatcher: SearchResultDispatching { let connectionState: ConnectionState = .authenticated let privateHash: String? = "mock-private-hash" private(set) var sentQueries: [String] = [] private let bus: ChannelSearchBus private let channel: SearchPacketChannel init(bus: ChannelSearchBus, channel: SearchPacketChannel) { self.bus = bus self.channel = channel } func sendSearchPacket(_ packet: PacketSearch) { sentQueries.append(packet.search) bus.enqueue(channel: channel) } @discardableResult func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID { bus.addHandler(channel: channel, handler) } func removeSearchResultHandler(_ id: UUID) { bus.removeHandler(id) } func emit(_ packet: PacketSearch) { bus.dispatch(packet) } } private final class ChannelSearchBus { private struct HandlerEntry { let channel: SearchPacketChannel let handler: (PacketSearch) -> Void } private var handlers: [UUID: HandlerEntry] = [:] private var pendingChannels: [SearchPacketChannel] = [] @discardableResult func addHandler( channel: SearchPacketChannel, _ handler: @escaping (PacketSearch) -> Void ) -> UUID { let id = UUID() handlers[id] = HandlerEntry(channel: channel, handler: handler) return id } func removeHandler(_ id: UUID) { handlers.removeValue(forKey: id) } func enqueue(channel: SearchPacketChannel) { pendingChannels.append(channel) } func dispatch(_ packet: PacketSearch) { let routed = pendingChannels.isEmpty ? SearchPacketChannel.unscoped : pendingChannels.removeFirst() let callbacks = handlers.values .filter { $0.channel == .unscoped || $0.channel == routed } .map(\.handler) for callback in callbacks { callback(packet) } } }