mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
ChatTransportEventCoordinator, ChatPeerIdentityCoordinator, ChatMediaTransferCoordinator, ChatVerificationCoordinator, and ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for narrow @MainActor contexts (20-36 members each), reusing shared witnesses across protocols. The two remaining raw writers of sentReadReceipts now route through owner intent ops (unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the gaps noted in the previous commit. NoiseEncryptionService stays fully out of the verification coordinator via installNoiseSessionCallbacks. 26 new mock-context tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
8.4 KiB
Swift
207 lines
8.4 KiB
Swift
//
|
|
// ChatMediaTransferCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatMediaTransferCoordinator` against a mock
|
|
// `ChatMediaTransferContext` — proving the coordinator works without a
|
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
|
//
|
|
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
|
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
|
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
|
// bookkeeping, and the blocked-context guards.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatMediaTransferContext` proving that
|
|
/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|
// Composition state
|
|
var canSendMediaInCurrentContext = true
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var nickname = "me"
|
|
var myPeerID = PeerID(str: "0011223344556677")
|
|
var activeChannel: ChannelID = .mesh
|
|
var nicknamesByPeerID: [PeerID: String] = [:]
|
|
|
|
func nicknameForPeer(_ peerID: PeerID) -> String {
|
|
nicknamesByPeerID[peerID] ?? "user"
|
|
}
|
|
|
|
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
|
(nickname, myPeerID)
|
|
}
|
|
|
|
// Message state
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
var meshTimeline: [BitchatMessage] = []
|
|
private(set) var refreshedChannels: [ChannelID?] = []
|
|
private(set) var trimCount = 0
|
|
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
|
private(set) var systemMessages: [String] = []
|
|
private(set) var notifyUIChangedCount = 0
|
|
|
|
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) {
|
|
meshTimeline.append(message)
|
|
}
|
|
|
|
func refreshVisibleMessages(from channel: ChannelID?) {
|
|
refreshedChannels.append(channel)
|
|
}
|
|
|
|
func trimMessagesIfNeeded() { trimCount += 1 }
|
|
|
|
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
|
removedMessages.append((messageID, cleanupFile))
|
|
}
|
|
|
|
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
|
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
|
|
|
// Delivery status & dedup
|
|
private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = []
|
|
private(set) var recordedContentKeys: [String] = []
|
|
|
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
deliveryStatusUpdates.append((messageID, status))
|
|
}
|
|
|
|
func normalizedContentKey(_ content: String) -> String { content.lowercased() }
|
|
|
|
func recordContentKey(_ key: String, timestamp: Date) {
|
|
recordedContentKeys.append(key)
|
|
}
|
|
|
|
// Mesh file transfer
|
|
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
|
private(set) var broadcastFileSends: [String] = []
|
|
private(set) var cancelledTransfers: [String] = []
|
|
|
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
|
privateFileSends.append((peerID, transferId))
|
|
}
|
|
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
broadcastFileSends.append(transferId)
|
|
}
|
|
|
|
func cancelTransfer(_ transferId: String) {
|
|
cancelledTransfers.append(transferId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatMediaTransferCoordinator` against
|
|
/// `MockChatMediaTransferContext` with no `ChatViewModel`.
|
|
struct ChatMediaTransferCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async {
|
|
let context = MockChatMediaTransferContext()
|
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
let peerID = PeerID(str: "1122334455667788")
|
|
context.nicknamesByPeerID[peerID] = "alice"
|
|
|
|
let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID)
|
|
|
|
#expect(context.privateChats[peerID]?.map(\.id) == [message.id])
|
|
#expect(message.isPrivate)
|
|
#expect(message.recipientNickname == "alice")
|
|
#expect(message.senderPeerID == context.myPeerID)
|
|
#expect(message.deliveryStatus == .sending)
|
|
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
|
|
#expect(context.trimCount == 1)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
#expect(context.meshTimeline.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async {
|
|
let context = MockChatMediaTransferContext()
|
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
|
|
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
|
|
|
|
#expect(context.meshTimeline.map(\.id) == [message.id])
|
|
#expect(!message.isPrivate)
|
|
#expect(message.sender == "me")
|
|
#expect(context.refreshedChannels.count == 1)
|
|
#expect(context.privateChats.isEmpty)
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func transferEvents_driveDeliveryStatusAndMappingCleanup() async {
|
|
let context = MockChatMediaTransferContext()
|
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
|
|
|
coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10))
|
|
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10))
|
|
coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10))
|
|
// After completion the mapping is gone: further events are ignored.
|
|
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10))
|
|
|
|
#expect(context.deliveryStatusUpdates.count == 3)
|
|
#expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10))
|
|
#expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10))
|
|
#expect(context.deliveryStatusUpdates[2].status == .sent)
|
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
|
|
|
// A cancelled transfer removes the message (with file cleanup).
|
|
coordinator.registerTransfer(transferId: "t2", messageID: "m2")
|
|
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
|
|
#expect(context.removedMessages.count == 1)
|
|
#expect(context.removedMessages.first?.messageID == "m2")
|
|
#expect(context.removedMessages.first?.cleanupFile == true)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async {
|
|
let context = MockChatMediaTransferContext()
|
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
// Two messages share a transfer queue; only the active head cancels
|
|
// the underlying transfer.
|
|
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
|
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
|
|
|
coordinator.cancelMediaSend(messageID: "m2")
|
|
#expect(context.cancelledTransfers.isEmpty)
|
|
#expect(context.removedMessages.map(\.messageID) == ["m2"])
|
|
|
|
coordinator.cancelMediaSend(messageID: "m1")
|
|
#expect(context.cancelledTransfers == ["t1"])
|
|
#expect(context.removedMessages.map(\.messageID) == ["m2", "m1"])
|
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
|
let context = MockChatMediaTransferContext()
|
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
|
context.canSendMediaInCurrentContext = false
|
|
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a")
|
|
try Data([0x01, 0x02]).write(to: url)
|
|
|
|
coordinator.sendVoiceNote(at: url)
|
|
|
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
|
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
|
|
#expect(context.privateChats.isEmpty)
|
|
#expect(context.meshTimeline.isEmpty)
|
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
|
}
|
|
}
|