Фикс: детерминированный роутинг PacketSearch на iOS без подмешивания фоновых результатов
This commit is contained in:
@@ -59,8 +59,8 @@
|
||||
/* Begin PBXFileReference section */
|
||||
0F43A41D5496A62870E307FC /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D5E6F708192A3B4C5 /* AttachmentParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AttachmentParityTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
|
||||
272B862BE4D99E7DD751CC3E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
2B3C4D5E6F708192A3B4C5D6 /* SearchParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchParityTests.swift; sourceTree = "<group>"; };
|
||||
4D3AF08B754B66DE17AF486D /* DBTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DBTestSupport.swift; sourceTree = "<group>"; };
|
||||
75BA8A97FE297E450BB1452E /* RosettaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RosettaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SchemaParityTests.swift; sourceTree = "<group>"; };
|
||||
|
||||
@@ -29,7 +29,7 @@ struct BubbleMetrics: Sendable {
|
||||
mainRadius: 16,
|
||||
auxiliaryRadius: 8,
|
||||
tailProtrusion: 6,
|
||||
defaultSpacing: 2 + screenPixel,
|
||||
defaultSpacing: screenPixel,
|
||||
mergedSpacing: 0,
|
||||
textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11),
|
||||
mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
|
||||
|
||||
194
Rosetta/Core/Layout/BubbleImageFactory.swift
Normal file
194
Rosetta/Core/Layout/BubbleImageFactory.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
import UIKit
|
||||
|
||||
/// Generates stretchable bubble images using Telegram's **exact** raster approach.
|
||||
///
|
||||
/// Telegram draws the tail via two ellipses + `.copy` blend mode erase in a CGContext.
|
||||
/// This cannot be replicated with vector paths (CAShapeLayer / UIBezierPath) because
|
||||
/// non-zero winding fill rule treats CCW subpaths as winding=-1 (filled, not erased).
|
||||
///
|
||||
/// Source: `ChatMessageBubbleImages.swift` lines 146-428 in Telegram-iOS.
|
||||
///
|
||||
/// Pipeline:
|
||||
/// 1. Draw body (rounded rect) + tail (bottomEllipse QuadCurves + topEllipse erase) in fixed 33pt system
|
||||
/// 2. Mirror horizontally for incoming
|
||||
/// 3. Make stretchable via 9-slice (`stretchableImage(withLeftCapWidth:topCapHeight:)`)
|
||||
/// 4. Cache all variants (mergeType × direction)
|
||||
enum BubbleImageFactory {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Pre-rendered stretchable images for all merge types, keyed by `BubbleMergeType`.
|
||||
struct ImageSet {
|
||||
let outgoing: [BubbleMergeType: UIImage]
|
||||
let incoming: [BubbleMergeType: UIImage]
|
||||
|
||||
func image(outgoing: Bool, mergeType: BubbleMergeType) -> UIImage? {
|
||||
outgoing ? self.outgoing[mergeType] : self.incoming[mergeType]
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all bubble images for the given colors.
|
||||
static func generate(
|
||||
outgoingColor: UIColor,
|
||||
incomingColor: UIColor,
|
||||
mainRadius: CGFloat = 16,
|
||||
auxiliaryRadius: CGFloat = 8
|
||||
) -> ImageSet {
|
||||
let types: [BubbleMergeType] = [
|
||||
.none, // single message — has tail
|
||||
.top(side: false), // first in group — no tail
|
||||
.both, // middle in group — no tail
|
||||
.bottom, // last in group — has tail
|
||||
]
|
||||
|
||||
var outgoing: [BubbleMergeType: UIImage] = [:]
|
||||
var incoming: [BubbleMergeType: UIImage] = [:]
|
||||
|
||||
for type in types {
|
||||
outgoing[type] = renderBubble(
|
||||
mergeType: type, incoming: false,
|
||||
fillColor: outgoingColor,
|
||||
maxRadius: mainRadius, minRadius: auxiliaryRadius
|
||||
)
|
||||
incoming[type] = renderBubble(
|
||||
mergeType: type, incoming: true,
|
||||
fillColor: incomingColor,
|
||||
maxRadius: mainRadius, minRadius: auxiliaryRadius
|
||||
)
|
||||
}
|
||||
|
||||
return ImageSet(outgoing: outgoing, incoming: incoming)
|
||||
}
|
||||
|
||||
// MARK: - Private: Telegram-exact rendering
|
||||
|
||||
/// Render a single stretchable bubble image.
|
||||
/// Exact port of `messageBubbleImage()` from `ChatMessageBubbleImages.swift`.
|
||||
private static func renderBubble(
|
||||
mergeType: BubbleMergeType,
|
||||
incoming: Bool,
|
||||
fillColor: UIColor,
|
||||
maxRadius: CGFloat,
|
||||
minRadius: CGFloat
|
||||
) -> UIImage {
|
||||
// ── Constants from Telegram ──
|
||||
let fixedDiameter: CGFloat = 33.0
|
||||
let strokeInset: CGFloat = 1.0
|
||||
let additionalInset: CGFloat = 1.0
|
||||
|
||||
let innerW = fixedDiameter + 6.0 // 39
|
||||
let innerH = fixedDiameter // 33
|
||||
let rawW = innerW + strokeInset * 2 // 41
|
||||
let rawH = innerH + strokeInset * 2 // 35
|
||||
let imgW = rawW + additionalInset * 2 // 43
|
||||
let imgH = rawH + additionalInset * 2 // 37
|
||||
|
||||
let imageSize = CGSize(width: imgW, height: imgH)
|
||||
|
||||
// Stretch points (9-slice)
|
||||
let outStretchX = Int(additionalInset + strokeInset + round(fixedDiameter / 2.0)) - 1 // 18
|
||||
let outStretchY = Int(additionalInset + strokeInset + round(fixedDiameter / 2.0)) // 19
|
||||
let inStretchX = Int(rawW) - outStretchX + Int(additionalInset) // 24
|
||||
|
||||
// Corner radii in OUTGOING orientation
|
||||
let (tl, tr, bl, br) = radii(for: mergeType, max: maxRadius, min: minRadius)
|
||||
let drawTail = BubbleGeometryEngine.hasTail(for: mergeType)
|
||||
|
||||
// Telegram ellipses
|
||||
let bottomEllipse = CGRect(x: 24, y: 16, width: 27, height: 17)
|
||||
let topEllipse = CGRect(x: 33, y: 14, width: 23, height: 21)
|
||||
|
||||
let scale = UIScreen.main.scale
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = scale
|
||||
format.opaque = false
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize, format: format)
|
||||
|
||||
let image = renderer.image { rendererCtx in
|
||||
let ctx = rendererCtx.cgContext
|
||||
|
||||
// ── Mirror for incoming (horizontal flip) ──
|
||||
// Telegram: scaleBy(x: incoming ? -1 : 1, y: -1)
|
||||
// The y:-1 is for Core Graphics coordinate flip.
|
||||
// In UIGraphicsImageRenderer the context is already UIKit-y-down,
|
||||
// so we only need horizontal flip for incoming.
|
||||
if incoming {
|
||||
ctx.translateBy(x: imageSize.width, y: 0)
|
||||
ctx.scaleBy(x: -1, y: 1)
|
||||
}
|
||||
|
||||
ctx.translateBy(x: additionalInset + strokeInset, y: additionalInset + strokeInset)
|
||||
|
||||
// ── Fill body + tail with the bubble color ──
|
||||
ctx.setFillColor(fillColor.cgColor)
|
||||
|
||||
// Body: rounded rect 33×33
|
||||
ctx.move(to: CGPoint(x: 0, y: tl))
|
||||
ctx.addArc(tangent1End: .zero, tangent2End: CGPoint(x: tl, y: 0), radius: tl)
|
||||
ctx.addLine(to: CGPoint(x: fixedDiameter - tr, y: 0))
|
||||
ctx.addArc(tangent1End: CGPoint(x: fixedDiameter, y: 0),
|
||||
tangent2End: CGPoint(x: fixedDiameter, y: tr), radius: tr)
|
||||
ctx.addLine(to: CGPoint(x: fixedDiameter, y: fixedDiameter - br))
|
||||
ctx.addArc(tangent1End: CGPoint(x: fixedDiameter, y: fixedDiameter),
|
||||
tangent2End: CGPoint(x: fixedDiameter - br, y: fixedDiameter), radius: br)
|
||||
ctx.addLine(to: CGPoint(x: bl, y: fixedDiameter))
|
||||
ctx.addArc(tangent1End: CGPoint(x: 0, y: fixedDiameter),
|
||||
tangent2End: CGPoint(x: 0, y: fixedDiameter - bl), radius: bl)
|
||||
ctx.addLine(to: CGPoint(x: 0, y: tl))
|
||||
ctx.fillPath()
|
||||
|
||||
// Tail (only for .none and .bottom)
|
||||
if drawTail {
|
||||
// bottomEllipse lower half — two QuadCurves
|
||||
if maxRadius >= 14.0 {
|
||||
ctx.move(to: CGPoint(x: bottomEllipse.minX, y: bottomEllipse.midY))
|
||||
ctx.addQuadCurve(
|
||||
to: CGPoint(x: bottomEllipse.midX, y: bottomEllipse.maxY),
|
||||
control: CGPoint(x: bottomEllipse.minX, y: bottomEllipse.maxY))
|
||||
ctx.addQuadCurve(
|
||||
to: CGPoint(x: bottomEllipse.maxX, y: bottomEllipse.midY),
|
||||
control: CGPoint(x: bottomEllipse.maxX, y: bottomEllipse.maxY))
|
||||
ctx.fillPath()
|
||||
} else {
|
||||
ctx.fill(CGRect(
|
||||
x: bottomEllipse.minX - 2, y: bottomEllipse.midY,
|
||||
width: bottomEllipse.width + 2, height: bottomEllipse.height / 2))
|
||||
}
|
||||
|
||||
// Connecting rect (fills gap between body center and bottomEllipse)
|
||||
ctx.fill(CGRect(
|
||||
x: fixedDiameter / 2, y: floor(fixedDiameter / 2),
|
||||
width: fixedDiameter / 2,
|
||||
height: ceil(bottomEllipse.midY) - floor(fixedDiameter / 2)))
|
||||
|
||||
// topEllipse ERASE — the key trick that only works in raster!
|
||||
ctx.setFillColor(UIColor.clear.cgColor)
|
||||
ctx.setBlendMode(.copy)
|
||||
ctx.fillEllipse(in: topEllipse)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Make stretchable (9-slice) ──
|
||||
let stretchX = incoming ? inStretchX : outStretchX
|
||||
return image.stretchableImage(withLeftCapWidth: stretchX, topCapHeight: outStretchY)
|
||||
}
|
||||
|
||||
/// Corner radii in outgoing orientation (Telegram convention).
|
||||
/// Same logic as `messageBubbleArguments()` in ChatMessageBubbleImages.swift.
|
||||
private static func radii(
|
||||
for mergeType: BubbleMergeType,
|
||||
max maxR: CGFloat,
|
||||
min minR: CGFloat
|
||||
) -> (CGFloat, CGFloat, CGFloat, CGFloat) {
|
||||
switch mergeType {
|
||||
case .none: return (maxR, maxR, maxR, maxR)
|
||||
case .both: return (maxR, minR, maxR, minR)
|
||||
case .bottom: return (maxR, minR, maxR, maxR)
|
||||
case .top(let side):
|
||||
return (maxR, maxR, side ? minR : maxR, minR)
|
||||
case .side: return (maxR, maxR, minR, minR)
|
||||
case .extracted: return (maxR, maxR, maxR, maxR)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,9 +185,14 @@ extension MessageCellLayout {
|
||||
let bottomPad: CGFloat = metrics.textInsets.bottom
|
||||
let leftPad: CGFloat = metrics.textInsets.left
|
||||
let rightPad: CGFloat = metrics.textInsets.right
|
||||
let statusTrailingCompensation: CGFloat = isTextMessage
|
||||
? max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||
: 0
|
||||
// Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space → rightPad-5=6pt compensation)
|
||||
// Incoming: timestamp at rightPad (11pt) from edge, same as text → 0pt compensation
|
||||
let statusTrailingCompensation: CGFloat
|
||||
if isTextMessage && config.isOutgoing {
|
||||
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
|
||||
} else {
|
||||
statusTrailingCompensation = 0
|
||||
}
|
||||
|
||||
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
|
||||
// Text is measured at the WIDEST possible constraint.
|
||||
@@ -343,8 +348,10 @@ extension MessageCellLayout {
|
||||
}
|
||||
|
||||
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
|
||||
bubbleH = max(bubbleH, 35)
|
||||
bubbleH = max(bubbleH, 37)
|
||||
}
|
||||
// Stretchable bubble image min height
|
||||
bubbleH = max(bubbleH, 37)
|
||||
|
||||
let totalH = groupGap + bubbleH
|
||||
|
||||
@@ -358,9 +365,18 @@ extension MessageCellLayout {
|
||||
// checkFrame.maxX = bubbleW - textStatusRightInset for text bubbles
|
||||
// tsFrame.maxX = checkFrame.minX - timeToCheckGap
|
||||
// checkFrame.minX = bubbleW - inset - checkW
|
||||
let metadataRightInset: CGFloat = isMediaMessage
|
||||
? 6
|
||||
: (isTextMessage ? textStatusLaneMetrics.textStatusRightInset : rightPad)
|
||||
let metadataRightInset: CGFloat
|
||||
if isMediaMessage {
|
||||
metadataRightInset = 6
|
||||
} else if isTextMessage {
|
||||
// Outgoing: 5pt (checkmarks fill the gap to rightPad)
|
||||
// Incoming: rightPad (11pt, same as text — no checkmarks to fill the gap)
|
||||
metadataRightInset = config.isOutgoing
|
||||
? textStatusLaneMetrics.textStatusRightInset
|
||||
: rightPad
|
||||
} else {
|
||||
metadataRightInset = rightPad
|
||||
}
|
||||
let metadataBottomInset: CGFloat = isMediaMessage ? 6 : bottomPad
|
||||
let statusEndX = bubbleW - metadataRightInset
|
||||
let statusEndY = bubbleH - metadataBottomInset
|
||||
|
||||
@@ -81,10 +81,9 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
syncBatchLock.unlock()
|
||||
return val
|
||||
}
|
||||
private let searchHandlersLock = NSLock()
|
||||
private let resultHandlersLock = NSLock()
|
||||
private let packetQueueLock = NSLock()
|
||||
private var searchResultHandlers: [UUID: (PacketSearch) -> Void] = [:]
|
||||
private let searchRouter = SearchPacketRouter()
|
||||
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
|
||||
|
||||
// Saved credentials for auto-reconnect
|
||||
@@ -145,6 +144,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
handshakeComplete = false
|
||||
clearPacketQueue()
|
||||
clearResultHandlers()
|
||||
searchRouter.resetPending()
|
||||
syncBatchLock.lock()
|
||||
_syncBatchActive = false
|
||||
syncBatchLock.unlock()
|
||||
@@ -184,6 +184,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
pingTimeoutTask = nil
|
||||
handshakeComplete = false
|
||||
heartbeatTask?.cancel()
|
||||
searchRouter.resetPending()
|
||||
connectionState = .connecting
|
||||
client.forceReconnect()
|
||||
}
|
||||
@@ -208,6 +209,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
Self.logger.info("⚡ Fast reconnect — state=\(self.connectionState.rawValue)")
|
||||
handshakeComplete = false
|
||||
heartbeatTask?.cancel()
|
||||
searchRouter.resetPending()
|
||||
connectionState = .connecting
|
||||
client.forceReconnect()
|
||||
}
|
||||
@@ -254,6 +256,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
pingTimeoutTask = nil
|
||||
handshakeComplete = false
|
||||
heartbeatTask?.cancel()
|
||||
searchRouter.resetPending()
|
||||
Task { @MainActor in
|
||||
// Guard: only downgrade to .connecting if reconnect hasn't already progressed.
|
||||
// forceReconnect() is called synchronously below — if it completes fast,
|
||||
@@ -268,6 +271,14 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
func sendSearchPacket(
|
||||
_ packet: PacketSearch,
|
||||
channel: SearchPacketChannel = .unscoped
|
||||
) {
|
||||
searchRouter.enqueueOutgoingRequest(channel: channel)
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
func sendPacket(_ packet: any Packet) {
|
||||
PerformanceLogger.shared.track("protocol.sendPacket")
|
||||
let id = String(type(of: packet).packetId, radix: 16)
|
||||
@@ -285,18 +296,15 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
// MARK: - Search Handlers (Android-like wait/unwait)
|
||||
|
||||
@discardableResult
|
||||
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
|
||||
let id = UUID()
|
||||
searchHandlersLock.lock()
|
||||
searchResultHandlers[id] = handler
|
||||
searchHandlersLock.unlock()
|
||||
return id
|
||||
func addSearchResultHandler(
|
||||
channel: SearchPacketChannel = .unscoped,
|
||||
_ handler: @escaping (PacketSearch) -> Void
|
||||
) -> UUID {
|
||||
searchRouter.addHandler(channel: channel, handler)
|
||||
}
|
||||
|
||||
func removeSearchResultHandler(_ id: UUID) {
|
||||
searchHandlersLock.lock()
|
||||
searchResultHandlers.removeValue(forKey: id)
|
||||
searchHandlersLock.unlock()
|
||||
searchRouter.removeHandler(id)
|
||||
}
|
||||
|
||||
// MARK: - Result Handlers (Android parity: waitPacket(0x02))
|
||||
@@ -347,6 +355,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
pingVerificationInProgress = false
|
||||
pingTimeoutTask?.cancel()
|
||||
pingTimeoutTask = nil
|
||||
self.searchRouter.resetPending()
|
||||
|
||||
Task { @MainActor in
|
||||
self.connectionState = .disconnected
|
||||
@@ -465,9 +474,11 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
case 0x03:
|
||||
if let p = packet as? PacketSearch {
|
||||
Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
|
||||
let routedChannel = routeIncomingSearchPacket(p)
|
||||
Self.logger.debug(
|
||||
"📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil), routed=\(String(describing: routedChannel))"
|
||||
)
|
||||
onSearchResult?(p)
|
||||
notifySearchResultHandlers(p)
|
||||
}
|
||||
case 0x05:
|
||||
if let p = packet as? PacketOnlineState {
|
||||
@@ -557,14 +568,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func notifySearchResultHandlers(_ packet: PacketSearch) {
|
||||
searchHandlersLock.lock()
|
||||
let handlers = Array(searchResultHandlers.values)
|
||||
searchHandlersLock.unlock()
|
||||
|
||||
for handler in handlers {
|
||||
handler(packet)
|
||||
}
|
||||
private func routeIncomingSearchPacket(_ packet: PacketSearch) -> SearchPacketChannel {
|
||||
searchRouter.dispatchIncomingResponse(packet)
|
||||
}
|
||||
|
||||
private func notifyResultHandlers(_ packet: PacketResult) {
|
||||
@@ -609,6 +614,7 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
case .needDeviceVerification:
|
||||
handshakeComplete = false
|
||||
Self.logger.info("Server requires device verification — approve this device from your other Rosetta app")
|
||||
searchRouter.resetPending()
|
||||
|
||||
Task { @MainActor in
|
||||
self.connectionState = .deviceVerificationRequired
|
||||
|
||||
112
Rosetta/Core/Network/Protocol/SearchPacketRouter.swift
Normal file
112
Rosetta/Core/Network/Protocol/SearchPacketRouter.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
enum SearchPacketChannel: Hashable, Sendable {
|
||||
case ui(UUID)
|
||||
case userInfo
|
||||
case unscoped
|
||||
|
||||
nonisolated static func == (lhs: SearchPacketChannel, rhs: SearchPacketChannel) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.ui(left), .ui(right)):
|
||||
return left == right
|
||||
case (.userInfo, .userInfo), (.unscoped, .unscoped):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case let .ui(id):
|
||||
hasher.combine(0)
|
||||
hasher.combine(id)
|
||||
case .userInfo:
|
||||
hasher.combine(1)
|
||||
case .unscoped:
|
||||
hasher.combine(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic router for PacketSearch responses.
|
||||
///
|
||||
/// Outgoing search requests enqueue their logical channel in FIFO order.
|
||||
/// Incoming responses are routed to the first pending channel, while `unscoped`
|
||||
/// handlers remain compatibility listeners that receive all routed responses.
|
||||
final class SearchPacketRouter {
|
||||
|
||||
private struct HandlerEntry {
|
||||
let channel: SearchPacketChannel
|
||||
let handler: (PacketSearch) -> Void
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var handlers: [UUID: HandlerEntry] = [:]
|
||||
private var pendingChannels: [SearchPacketChannel] = []
|
||||
|
||||
@discardableResult
|
||||
func addHandler(
|
||||
channel: SearchPacketChannel = .unscoped,
|
||||
_ handler: @escaping (PacketSearch) -> Void
|
||||
) -> UUID {
|
||||
let id = UUID()
|
||||
lock.lock()
|
||||
handlers[id] = HandlerEntry(channel: channel, handler: handler)
|
||||
lock.unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func removeHandler(_ id: UUID) {
|
||||
lock.lock()
|
||||
handlers.removeValue(forKey: id)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func enqueueOutgoingRequest(channel: SearchPacketChannel) {
|
||||
lock.lock()
|
||||
pendingChannels.append(channel)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func resetPending() {
|
||||
lock.lock()
|
||||
pendingChannels.removeAll()
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Routes one incoming response to the next pending channel (FIFO).
|
||||
/// Returns the routed channel for observability/logging.
|
||||
@discardableResult
|
||||
func dispatchIncomingResponse(_ packet: PacketSearch) -> SearchPacketChannel {
|
||||
let routedChannel: SearchPacketChannel
|
||||
let callbacks: [(PacketSearch) -> Void]
|
||||
|
||||
lock.lock()
|
||||
if pendingChannels.isEmpty {
|
||||
routedChannel = .unscoped
|
||||
} else {
|
||||
routedChannel = pendingChannels.removeFirst()
|
||||
}
|
||||
|
||||
callbacks = handlers.values
|
||||
.filter { $0.channel == .unscoped || $0.channel == routedChannel }
|
||||
.map(\.handler)
|
||||
lock.unlock()
|
||||
|
||||
for callback in callbacks {
|
||||
callback(packet)
|
||||
}
|
||||
|
||||
return routedChannel
|
||||
}
|
||||
|
||||
// MARK: - Test support
|
||||
|
||||
var pendingCount: Int {
|
||||
lock.lock()
|
||||
let count = pendingChannels.count
|
||||
lock.unlock()
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ struct LivePacketFlowSender: PacketFlowSending {
|
||||
}
|
||||
|
||||
struct LiveSearchResultDispatcher: SearchResultDispatching {
|
||||
private let channel: SearchPacketChannel
|
||||
|
||||
init(channel: SearchPacketChannel = .ui(UUID())) {
|
||||
self.channel = channel
|
||||
}
|
||||
|
||||
var connectionState: ConnectionState {
|
||||
ProtocolManager.shared.connectionState
|
||||
}
|
||||
@@ -44,12 +50,12 @@ struct LiveSearchResultDispatcher: SearchResultDispatching {
|
||||
}
|
||||
|
||||
func sendSearchPacket(_ packet: PacketSearch) {
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
ProtocolManager.shared.sendSearchPacket(packet, channel: channel)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addSearchResultHandler(_ handler: @escaping (PacketSearch) -> Void) -> UUID {
|
||||
ProtocolManager.shared.addSearchResultHandler(handler)
|
||||
ProtocolManager.shared.addSearchResultHandler(channel: channel, handler)
|
||||
}
|
||||
|
||||
func removeSearchResultHandler(_ id: UUID) {
|
||||
|
||||
@@ -1242,7 +1242,7 @@ final class SessionManager {
|
||||
var searchPacket = PacketSearch()
|
||||
searchPacket.privateKey = hash
|
||||
searchPacket.search = self.currentPublicKey
|
||||
ProtocolManager.shared.sendPacket(searchPacket)
|
||||
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
|
||||
}
|
||||
|
||||
// Reset sync state — if a previous connection dropped mid-sync,
|
||||
@@ -1661,7 +1661,7 @@ final class SessionManager {
|
||||
var searchPacket = PacketSearch()
|
||||
searchPacket.privateKey = hash
|
||||
searchPacket.search = publicKey
|
||||
ProtocolManager.shared.sendPacket(searchPacket)
|
||||
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
|
||||
}
|
||||
|
||||
private func requestSynchronize(cursor: Int64? = nil) {
|
||||
@@ -2025,7 +2025,7 @@ final class SessionManager {
|
||||
var searchPacket = PacketSearch()
|
||||
searchPacket.privateKey = privateKeyHash
|
||||
searchPacket.search = normalized
|
||||
ProtocolManager.shared.sendPacket(searchPacket)
|
||||
ProtocolManager.shared.sendSearchPacket(searchPacket, channel: .userInfo)
|
||||
}
|
||||
|
||||
/// Request user info for all existing dialog opponents after sync completes.
|
||||
@@ -2089,7 +2089,7 @@ final class SessionManager {
|
||||
/// This runs independently of ChatListViewModel's search UI handler.
|
||||
/// Also detects own profile in search results and updates SessionManager + AccountManager.
|
||||
private func setupUserInfoSearchHandler() {
|
||||
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in
|
||||
userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler(channel: .userInfo) { [weak self] packet in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ownKey = self.currentPublicKey
|
||||
|
||||
@@ -17,8 +17,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E
|
||||
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
|
||||
private static let timestampFont = UIFont.systemFont(ofSize: floor(textFont.pointSize * 11.0 / 17.0), weight: .regular)
|
||||
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let replyNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let replyTextFont = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
|
||||
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
|
||||
@@ -41,6 +41,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
|
||||
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
|
||||
private static let maxVisiblePhotoTiles = 5
|
||||
// Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail)
|
||||
private static let bubbleImages = BubbleImageFactory.generate(
|
||||
outgoingColor: outgoingColor,
|
||||
incomingColor: incomingColor
|
||||
)
|
||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 200
|
||||
@@ -49,9 +54,11 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// MARK: - Subviews (always present, hidden when unused)
|
||||
|
||||
// Bubble
|
||||
// Bubble — uses Telegram-exact stretchable image for the fill (raster tail)
|
||||
// + CAShapeLayer for shadow path (approximate, vector)
|
||||
private let bubbleView = UIView()
|
||||
private let bubbleLayer = CAShapeLayer()
|
||||
private let bubbleImageView = UIImageView()
|
||||
private let bubbleLayer = CAShapeLayer() // shadow only, fillColor = clear
|
||||
private let bubbleOutlineLayer = CAShapeLayer()
|
||||
|
||||
// Text (CoreText rendering — matches Telegram's CTTypesetter + CTRunDraw pipeline)
|
||||
@@ -131,8 +138,8 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
backgroundColor = .clear
|
||||
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
|
||||
|
||||
// Bubble
|
||||
bubbleLayer.fillColor = Self.outgoingColor.cgColor
|
||||
// Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top
|
||||
bubbleLayer.fillColor = UIColor.clear.cgColor
|
||||
bubbleLayer.fillRule = .nonZero
|
||||
bubbleLayer.shadowColor = UIColor.black.cgColor
|
||||
bubbleLayer.shadowOpacity = 0.12
|
||||
@@ -142,6 +149,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
bubbleOutlineLayer.fillColor = UIColor.clear.cgColor
|
||||
bubbleOutlineLayer.lineWidth = 1.0 / max(UIScreen.main.scale, 1)
|
||||
bubbleView.layer.insertSublayer(bubbleOutlineLayer, above: bubbleLayer)
|
||||
// Raster bubble image (Telegram-exact tail) — added last so it renders above outline
|
||||
bubbleImageView.contentMode = .scaleToFill
|
||||
bubbleView.addSubview(bubbleImageView)
|
||||
contentView.addSubview(bubbleView)
|
||||
|
||||
// Text (CoreTextLabel — no font/color/lines config; all baked into CoreTextTextLayout)
|
||||
@@ -168,7 +178,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
bubbleView.addSubview(clockMinView)
|
||||
|
||||
// Reply quote
|
||||
replyBar.layer.cornerRadius = 1.5
|
||||
replyBar.layer.cornerRadius = 4.0
|
||||
replyContainer.addSubview(replyBar)
|
||||
replyNameLabel.font = Self.replyNameFont
|
||||
replyContainer.addSubview(replyNameLabel)
|
||||
@@ -362,8 +372,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
deliveryFailedButton.isHidden = !(isOutgoing && message.deliveryStatus == .error)
|
||||
updateStatusBackgroundVisibility()
|
||||
|
||||
// Bubble color
|
||||
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
|
||||
// Bubble color (bubbleLayer is shadow-only; fill comes from bubbleImageView)
|
||||
photoContainer.backgroundColor = isOutgoing ? Self.outgoingColor : Self.incomingColor
|
||||
|
||||
// Reply quote
|
||||
@@ -438,20 +447,29 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
x: bubbleX, y: layout.groupGap,
|
||||
width: layout.bubbleSize.width, height: layout.bubbleSize.height
|
||||
)
|
||||
bubbleLayer.frame = bubbleView.bounds
|
||||
|
||||
let shapeRect: CGRect
|
||||
if layout.hasTail {
|
||||
if layout.isOutgoing {
|
||||
shapeRect = CGRect(x: 0, y: 0,
|
||||
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
|
||||
} else {
|
||||
shapeRect = CGRect(x: -tailProtrusion, y: 0,
|
||||
width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height)
|
||||
}
|
||||
// ── Raster bubble image (Telegram-exact tail via stretchable image) ──
|
||||
// Telegram includes tail space (6pt) in backgroundFrame for ALL bubbles,
|
||||
// not just tailed ones. This keeps right edges aligned in a group.
|
||||
// For non-tailed, the tail area is transparent in the stretchable image.
|
||||
let imageFrame: CGRect
|
||||
if layout.isOutgoing {
|
||||
imageFrame = CGRect(x: 0, y: 0,
|
||||
width: layout.bubbleSize.width + tailProtrusion,
|
||||
height: layout.bubbleSize.height)
|
||||
} else {
|
||||
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
|
||||
imageFrame = CGRect(x: -tailProtrusion, y: 0,
|
||||
width: layout.bubbleSize.width + tailProtrusion,
|
||||
height: layout.bubbleSize.height)
|
||||
}
|
||||
bubbleImageView.frame = imageFrame
|
||||
bubbleImageView.image = Self.bubbleImages.image(
|
||||
outgoing: layout.isOutgoing, mergeType: layout.mergeType
|
||||
)
|
||||
|
||||
// ── Vector shadow path (approximate shape, used only for shadow) ──
|
||||
bubbleLayer.frame = bubbleView.bounds
|
||||
let shapeRect = imageFrame
|
||||
bubbleLayer.path = BubblePathCache.shared.path(
|
||||
size: shapeRect.size, origin: shapeRect.origin,
|
||||
mergeType: layout.mergeType,
|
||||
@@ -468,8 +486,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.2)
|
||||
bubbleOutlineLayer.strokeColor = UIColor.clear.cgColor
|
||||
} else if layout.hasTail {
|
||||
// Tail path is appended as a second subpath; stroking it produces
|
||||
// a visible seam at the junction. Keep fill-only for tailed bubbles.
|
||||
bubbleLayer.shadowOpacity = 0.12
|
||||
bubbleLayer.shadowRadius = 0.6
|
||||
bubbleLayer.shadowOffset = CGSize(width: 0, height: 0.4)
|
||||
|
||||
@@ -34,8 +34,8 @@ final class ChatListViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||
self.searchDispatcher = searchDispatcher
|
||||
init(searchDispatcher: SearchResultDispatching? = nil) {
|
||||
self.searchDispatcher = searchDispatcher ?? LiveSearchResultDispatcher()
|
||||
configureRecentSearches()
|
||||
setupSearchCallback()
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ final class SearchViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(searchDispatcher: SearchResultDispatching = LiveSearchResultDispatcher()) {
|
||||
self.searchDispatcher = searchDispatcher
|
||||
init(searchDispatcher: SearchResultDispatching? = nil) {
|
||||
self.searchDispatcher = searchDispatcher ?? LiveSearchResultDispatcher()
|
||||
configureRecentSearches()
|
||||
setupSearchCallback()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
RosettaTests/SearchRoutingTests.swift
Normal file
93
RosettaTests/SearchRoutingTests.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
final class SearchRoutingTests: XCTestCase {
|
||||
func testResponseRoutesToMatchingChannel() {
|
||||
let router = SearchPacketRouter()
|
||||
let uiChannel = SearchPacketChannel.ui(UUID())
|
||||
|
||||
var uiHits = 0
|
||||
var userInfoHits = 0
|
||||
var unscopedHits = 0
|
||||
|
||||
_ = router.addHandler(channel: uiChannel) { _ in uiHits += 1 }
|
||||
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
|
||||
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
|
||||
|
||||
router.enqueueOutgoingRequest(channel: uiChannel)
|
||||
let firstRouted = router.dispatchIncomingResponse(PacketSearch())
|
||||
XCTAssertEqual(firstRouted, uiChannel)
|
||||
XCTAssertEqual(uiHits, 1)
|
||||
XCTAssertEqual(userInfoHits, 0)
|
||||
XCTAssertEqual(unscopedHits, 1)
|
||||
|
||||
router.enqueueOutgoingRequest(channel: .userInfo)
|
||||
let secondRouted = router.dispatchIncomingResponse(PacketSearch())
|
||||
XCTAssertEqual(secondRouted, .userInfo)
|
||||
XCTAssertEqual(uiHits, 1)
|
||||
XCTAssertEqual(userInfoHits, 1)
|
||||
XCTAssertEqual(unscopedHits, 2)
|
||||
}
|
||||
|
||||
func testInterleavingUiAndUserInfoDoesNotCrossTalk() {
|
||||
let router = SearchPacketRouter()
|
||||
let uiA = SearchPacketChannel.ui(UUID())
|
||||
let uiB = SearchPacketChannel.ui(UUID())
|
||||
|
||||
var uiAHits = 0
|
||||
var uiBHits = 0
|
||||
var userInfoHits = 0
|
||||
|
||||
_ = router.addHandler(channel: uiA) { _ in uiAHits += 1 }
|
||||
_ = router.addHandler(channel: uiB) { _ in uiBHits += 1 }
|
||||
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
|
||||
|
||||
router.enqueueOutgoingRequest(channel: uiA)
|
||||
router.enqueueOutgoingRequest(channel: .userInfo)
|
||||
router.enqueueOutgoingRequest(channel: uiB)
|
||||
|
||||
_ = router.dispatchIncomingResponse(PacketSearch())
|
||||
_ = router.dispatchIncomingResponse(PacketSearch())
|
||||
_ = router.dispatchIncomingResponse(PacketSearch())
|
||||
|
||||
XCTAssertEqual(uiAHits, 1)
|
||||
XCTAssertEqual(userInfoHits, 1)
|
||||
XCTAssertEqual(uiBHits, 1)
|
||||
}
|
||||
|
||||
func testResetPendingClearsRoutingState() {
|
||||
let router = SearchPacketRouter()
|
||||
let uiChannel = SearchPacketChannel.ui(UUID())
|
||||
|
||||
var uiHits = 0
|
||||
var unscopedHits = 0
|
||||
|
||||
_ = router.addHandler(channel: uiChannel) { _ in uiHits += 1 }
|
||||
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
|
||||
|
||||
router.enqueueOutgoingRequest(channel: uiChannel)
|
||||
XCTAssertEqual(router.pendingCount, 1)
|
||||
|
||||
router.resetPending()
|
||||
XCTAssertEqual(router.pendingCount, 0)
|
||||
|
||||
let routed = router.dispatchIncomingResponse(PacketSearch())
|
||||
XCTAssertEqual(routed, .unscoped)
|
||||
XCTAssertEqual(uiHits, 0)
|
||||
XCTAssertEqual(unscopedHits, 1)
|
||||
}
|
||||
|
||||
func testFallbackToUnscopedWhenNoPending() {
|
||||
let router = SearchPacketRouter()
|
||||
var unscopedHits = 0
|
||||
var userInfoHits = 0
|
||||
|
||||
_ = router.addHandler(channel: .unscoped) { _ in unscopedHits += 1 }
|
||||
_ = router.addHandler(channel: .userInfo) { _ in userInfoHits += 1 }
|
||||
|
||||
let routed = router.dispatchIncomingResponse(PacketSearch())
|
||||
XCTAssertEqual(routed, .unscoped)
|
||||
XCTAssertEqual(unscopedHits, 1)
|
||||
XCTAssertEqual(userInfoHits, 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user