Фикс: детерминированный роутинг PacketSearch на iOS без подмешивания фоновых результатов

This commit is contained in:
2026-03-28 21:07:05 +05:00
parent 5af28b68a8
commit e49d224e6a
13 changed files with 613 additions and 62 deletions

View File

@@ -66,6 +66,43 @@ final class SearchParityTests: XCTestCase {
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 {
@@ -124,3 +161,74 @@ private final class MockSearchDispatcher: SearchResultDispatching {
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)
}
}
}