mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:05:18 +00:00
All private-message mutations now flow through store intents: coordinators, PrivateChatManager (its @Published dicts deleted - now read-only views over the store), outbound sends, delivery status, and chat migration. The O(1) store dedup replaces the full-scan duplicate check; insertion order is maintained by the store so sanitizeChat's re-sort is a documented no-op. Both bootstrapper Combine bridges and the Task.yield store synchronization are deleted. ChatViewModel.privateChats/unreadPrivateMessages become get-only derived views (measured: naive rebuild equals a change-invalidated cache within noise, so the simpler form stays). Feature models still read the legacy store, fed by a coalescing LegacyConversationStoreBridge (one mirror per burst, marked for step-5 deletion). pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
8.7 KiB
Swift
217 lines
8.7 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]] = [:]
|
|
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
|
|
var chat = privateChats[peerID] ?? []
|
|
guard !chat.contains(where: { $0.id == message.id }) else { return false }
|
|
chat.append(message)
|
|
privateChats[peerID] = chat
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|