// // PerformanceBaselineTests.swift // bitchatTests // // Performance baselines for hot paths so regressions are measured, not // guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with // NO recorded baselines — these tests record timing in CI logs but never // fail on timing, so they cannot flake. // // Each benchmark builds its fixtures OUTSIDE the measure block with fixed // seeds/content so every iteration does deterministic work: no network, // no Tor, no sleeps. // // Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable; // runs by default. // import XCTest import SwiftUI import BitFoundation @testable import bitchat @MainActor final class PerformanceBaselineTests: XCTestCase { override func setUpWithError() throws { try XCTSkipIf( ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1", "Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES" ) } override func tearDown() { // Drain main-queue tasks spawned by coordinators (handlePublicMessage // hops etc.) so they don't bleed into the next test's measure block. RunLoop.current.run(until: Date().addingTimeInterval(0.05)) super.tearDown() } /// Reports one human-readable throughput line per benchmark so CI logs /// are readable without parsing XCTest's measure output. The same line is /// appended to the file named by `BITCHAT_PERF_LOG` (if set): under /// `swift test --parallel` the runner swallows stdout of passing tests, /// so the CI floor gate (scripts/check-perf-floors.sh) reads the file. private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) { guard !samples.isEmpty else { return } let avg = samples.reduce(0, +) / Double(samples.count) let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity let line = String( format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)", name, opsPerSec, unit, avg * 1000, operations, samples.count ) print(line) Self.appendToPerfLog(line) } private static var perfLogPath: String? { let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"] return (path?.isEmpty ?? true) ? nil : path } /// Appends with `O_APPEND` because `swift test --parallel` may split this /// class across worker processes that write concurrently. The file is /// append-only (CI workspaces start fresh); delete it between local runs /// if you reuse a path. private static func appendToPerfLog(_ line: String) { guard let path = perfLogPath else { return } let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644) guard fd >= 0 else { return } defer { close(fd) } let bytes = Array((line + "\n").utf8) bytes.withUnsafeBufferPointer { buffer in _ = write(fd, buffer.baseAddress, buffer.count) } } // MARK: - 1a. Nostr inbound event handling (fresh events) /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events /// (kind 20000): signature verification, dedup record, presence/nickname /// bookkeeping, and public-message ingest scheduling. func testNostrInboundEventHandling_freshEvents() throws { let events = try Self.makeSignedGeohashEvents(count: 500) // A fresh context per measure pass so every event takes the // first-seen path on every iteration. Kept alive so the weakly // captured Task hops stay valid. var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = [] var samples: [TimeInterval] = [] measure { let context = PerfNostrContext() let coordinator = ChatNostrCoordinator(context: context) keepAlive.append((context, coordinator)) let start = Date() for event in events { coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) XCTAssertEqual(context.processedEventCount, events.count) } reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events") } // MARK: - 1b. Nostr inbound event handling (duplicate events) /// The dedup-hit path: identical events replayed. Duplicates dominate /// real relay traffic (the same event arrives from several relays), so /// this path runs hundreds of times a minute in busy geohashes. Note it /// still pays full Schnorr signature verification before the dedup check. func testNostrInboundEventHandling_duplicateEvents() throws { let events = try Self.makeSignedGeohashEvents(count: 500) let context = PerfNostrContext() let coordinator = ChatNostrCoordinator(context: context) // Pre-warm: every event is now recorded as processed. for event in events { coordinator.inbound.handleNostrEvent(event) } XCTAssertEqual(context.processedEventCount, events.count) var samples: [TimeInterval] = [] measure { let start = Date() for event in events { coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) } // Dedup held: nothing was re-processed. XCTAssertEqual(context.processedEventCount, events.count) reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events") } // MARK: - 2. BLE inbound packet pipeline (decode + dedup) /// Binary encode/decode round trip plus `MessageDeduplicator` at /// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each /// dedup-checked twice (first-seen insert, then duplicate hit) the way /// relayed packets arrive on multiple links. func testBLEInboundPacketPipeline() throws { var rng = SeededGenerator(seed: 0xB17C4A7) let baseTimestamp = UInt64(1_700_000_000_000) var packets: [BitchatPacket] = [] var dedupIDs: [String] = [] packets.reserveCapacity(1000) dedupIDs.reserveCapacity(1000) for index in 0..<1000 { let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) }) let payloadSize = Int.random(in: 100...300, using: &rng) let payload = Data((0.. delivered so every call performs a real update (never the /// skip path). func testDeliveryStatusIncrementalUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) let coordinator = ChatDeliveryCoordinator(context: context) let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) XCTAssertEqual(targetIDs.count, 500) let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent let start = Date() var updated = 0 for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) { updated += 1 } samples.append(Date().timeIntervalSince(start)) XCTAssertEqual(updated, targetIDs.count) } reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates") } // MARK: - 4b. Delivery status updates against the store directly /// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same /// scale as 4a, without the coordinator/context wrapping — the store-side /// cost of an ID-only delivery update (map lookup + per-conversation /// ID-index apply + change emission). Replaces the deleted /// `delivery.indexRebuild` benchmark: the positional location index and /// its rebuild path no longer exist; the store's ID indexes are /// maintained inside each mutation, so there is no rebuild to measure. func testDeliveryStatusStoreUpdates() { let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) let store = context.store let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250) XCTAssertEqual(targetIDs.count, 500) let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) var toggle = false var samples: [TimeInterval] = [] measure { toggle.toggle() let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent let start = Date() var updated = 0 for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) { updated += 1 } samples.append(Date().timeIntervalSince(start)) XCTAssertEqual(updated, targetIDs.count) } reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates") } // MARK: - 5. Message formatting /// `MessageFormattingEngine.formatMessage` over 200 messages with /// mentions, hashtags, and URLs. Formatting caches per message, so each /// measure pass consumes a fresh pre-built batch (cache-miss path, which /// is the cost paid when messages first render). func testMessageFormatting() { let context = PerfFormattingContext(nickname: "carol") let batchCount = 16 // > XCTest's default 10 measure iterations let batchSize = 200 let contents: [(String, [String]?)] = [ ("hello mesh, anyone around tonight?", nil), ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), ("checking in from the harbor #bitchat #mesh", nil), ("@bob#0042 ping me when you get this", ["bob#0042"]), ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil) ] let batches: [[BitchatMessage]] = (0..= messageCount && fixture.publicChat.messages.count >= messageCount } samples.append(Date().timeIntervalSince(start)) XCTAssertTrue(consistent, "messages/PublicChatModel never converged") let ingested = fixture.viewModel.messages.filter { expectedIDs.contains($0.id) } XCTAssertEqual(ingested.count, messageCount) } reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages") } // MARK: - 7. ConversationStore append (to-be architecture) /// Core op of the new single-source-of-truth `ConversationStore` /// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one /// conversation, every 10th arriving out of order so the binary-search /// insert path (plus suffix reindex) is part of the measured work. /// Includes per-message ID-index dedup and the per-conversation /// `@Published` array write. func testConversationStoreAppend() { let messageCount = 1000 let base = Date(timeIntervalSince1970: 1_700_000_000) let messages: [BitchatMessage] = (0.. Bool) -> Bool { let deadline = Date().addingTimeInterval(timeout) while !condition() { guard Date() < deadline else { return false } RunLoop.current.run(until: Date().addingTimeInterval(0.005)) } return true } // MARK: - Fixtures /// Builds deterministic signed kind-20000 geohash events. Content cycles /// through realistic chat lines; a handful of sender identities mimics a /// busy channel. private static func makeSignedGeohashEvents(count: Int) throws -> [NostrEvent] { let senders = try (0..<8).map { _ in try NostrIdentity.generate() } let lines = [ "hello from the geohash", "anyone near the station?", "@bob#0042 are you on mesh too?", "check this out https://example.com/p/123 #bitchat", "teleport check, who's local?" ] return try (0.. UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } } // MARK: - Mock ChatNostrContext /// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator` /// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel` /// (mirrors `ChatNostrCoordinatorContextTests`). /// Callbacks are cheap dictionary/array operations so the measured cost is /// the coordinator's own pipeline. @MainActor private final class PerfNostrContext: ChatNostrContext { var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")) var currentGeohash: String? = "u4pruyd" var geoSubscriptionID: String? var geoDmSubscriptionID: String? var geoSamplingSubs: [String: String] = [:] var lastGeoNotificationAt: [String: Date] = [:] var nostrRelayManager: NostrRelayManager? { nil } func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } func clearGeoSamplingSubs() -> [String] { defer { geoSamplingSubs.removeAll() } return Array(geoSamplingSubs.keys) } var messages: [BitchatMessage] = [] func flushPublicMessagePipeline() {} func refreshVisibleMessages(from channel: ChannelID?) {} func addPublicSystemMessage(_ content: String) {} func drainPendingGeohashSystemMessages() -> [String] { [] } func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true } private(set) var handledPublicMessageCount = 0 func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } func checkForMentions(_ message: BitchatMessage) {} func sendHapticFeedback(for message: BitchatMessage) {} func parseMentions(from content: String) -> [String] { MessageFormattingEngine.extractMentions(from: content) } var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func startPrivateChat(with peerID: PeerID) {} private struct NoIdentity: Error {} var geohashIdentities: [String: NostrIdentity] = [:] func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } return identity } func currentNostrIdentity() -> NostrIdentity? { nil } func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false } func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" } private var processedNostrEventIDs: Set = [] var processedEventCount: Int { processedNostrEventIDs.count } func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) } func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() } var geoNicknames: [String: String] = [:] private var teleportedKeys: Set = [] var teleportedGeoCount: Int { teleportedKeys.count } func startGeoParticipantRefreshTimer() {} func stopGeoParticipantRefreshTimer() {} func setActiveParticipantGeohash(_ geohash: String?) {} private(set) var participantRecords = 0 func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 } func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 } func geoParticipantCount(for geohash: String) -> Int { 0 } func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } func clearTeleportedGeo() { teleportedKeys.removeAll() } func clearGeoNicknames() { geoNicknames.removeAll() } func visibleGeohashPeople() -> [GeoPerson] { [] } var isTeleported = false func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false } func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil } func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] } func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {} func postLocalNotification(title: String, body: String, identifier: String) {} func notifyGeohashActivity(geohash: String, bodyPreview: String) {} } // MARK: - Mock ChatDeliveryContext /// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the /// coordinator is a thin mapper onto store intents, so the measured cost is /// the store's ID-map delivery path). @MainActor private final class PerfDeliveryContext: ChatDeliveryContext { let store = ConversationStore() var sentReadReceipts: Set = [] var isStartupPhase: Bool { false } private var publicIDs: [String] = [] private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = [] func notifyUIChanged() {} func markMessageDelivered(_ messageID: String) {} @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { store.setDeliveryStatus(status, forMessageID: messageID) } func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { store.deliveryStatus(forMessageID: messageID) } func privateMessageIDs() -> Set { store.directMessageIDs() } func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { let oldCount = sentReadReceipts.count sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) return oldCount - sentReadReceipts.count } /// `publicCount` public messages (split evenly between mesh and one /// geohash conversation so the corpus stays under the per-conversation /// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into /// the store with deterministic IDs and timestamps. static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { let context = PerfDeliveryContext() let base = Date(timeIntervalSince1970: 1_700_000_000) let geohashID = ConversationID.geohash("u4pruyd") for i in 0.. [String] { var targetIDs: [String] = [] let publicStride = max(1, publicIDs.count / publicTargets) for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets { targetIDs.append(publicIDs[i]) } var privateCount = 0 outer: for (_, messageIDs) in privateIDsByPeer { for i in stride(from: 0, to: messageIDs.count, by: 4) { guard privateCount < privateTargets else { break outer } targetIDs.append(messageIDs[i]) privateCount += 1 } } return targetIDs } } // MARK: - End-to-end pipeline fixture /// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style /// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same /// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover /// the full ingest-to-feature-model chain. Mirrors the construction used by /// `ChatViewModelExtensionsTests`. @MainActor private final class PerfPipelineFixture { let viewModel: ChatViewModel let transport: MockTransport let conversations: ConversationStore let privateInbox: PrivateInboxModel let publicChat: PublicChatModel init() { let keychain = MockKeychain() let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let identityManager = MockIdentityManager(keychain) let transport = MockTransport() let conversations = ConversationStore() self.transport = transport self.conversations = conversations self.viewModel = ChatViewModel( keychain: keychain, idBridge: idBridge, identityManager: identityManager, transport: transport, conversations: conversations ) self.privateInbox = PrivateInboxModel(conversations: conversations) self.publicChat = PublicChatModel(conversations: conversations) } } // MARK: - Mock MessageFormattingContext @MainActor private final class PerfFormattingContext: MessageFormattingContext { let nickname: String init(nickname: String) { self.nickname = nickname } func isSelfMessage(_ message: BitchatMessage) -> Bool { false } func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue } func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") } }