Files
bitchat/bitchatTests/ViewSmokeTests.swift
T
jackandClaude Opus 4.8 7251bc88fa Keep recording-error alerts from ending the open DM
Voice recording happens inside the people/DM sheet, but its error alert
was still presented unconditionally from the root view. A mic-permission
failure therefore force-dismissed the sheet and ended the conversation
through exactly the reconciliation path this branch fixes for the
Bluetooth alert.

Treat it the same way: the sheet-dismiss guard now also ignores
reconciliation while the voice alert is up, the root copy of the alert
defers to any other root modal (people sheet included), and the sheet
presents its own copy gated on the sheet's modal state. The voice alert
keeps priority over the Bluetooth alert on both levels via the existing
isVoiceAlertPresented flag, now mirrored into the people-sheet state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:05:16 +02:00

818 lines
30 KiB
Swift

import Combine
import Testing
import Foundation
import SwiftUI
import CoreGraphics
import AVFoundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat
@MainActor
private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identityManager: MockIdentityManager) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport, identityManager)
}
@MainActor
private struct SmokeFeatureModels {
let publicChatModel: PublicChatModel
let appChromeModel: AppChromeModel
let locationChannelsModel: LocationChannelsModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
}
@MainActor
private func makeSmokeLocationManager() -> LocationChannelManager {
let suiteName = "ViewSmokeTests.\(UUID().uuidString)"
let storage = UserDefaults(suiteName: suiteName) ?? .standard
storage.removePersistentDomain(forName: suiteName)
return LocationChannelManager(storage: storage)
}
@MainActor
private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels {
let locationManager = makeSmokeLocationManager()
let conversations = viewModel.conversations
let publicChatModel = PublicChatModel(conversations: conversations)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateInboxModel = PrivateInboxModel(conversations: conversations)
let appChromeModel = AppChromeModel(
chatViewModel: viewModel,
privateInboxModel: privateInboxModel
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversations: conversations,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversations: conversations
)
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversations: conversations,
locationChannelsModel: locationChannelsModel
)
let boardAlertsModel = BoardAlertsModel(
arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { _ in false },
emitSystemLine: { _, _ in }
)
)
return SmokeFeatureModels(
publicChatModel: publicChatModel,
appChromeModel: appChromeModel,
locationChannelsModel: locationChannelsModel,
privateInboxModel: privateInboxModel,
privateConversationModel: privateConversationModel,
verificationModel: verificationModel,
conversationUIModel: conversationUIModel,
peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel,
sharedContentImportModel: SharedContentImportModel(store: nil)
)
}
@MainActor
private func installSmokeEnvironment<V: View>(
_ view: V,
featureModels: SmokeFeatureModels
) -> some View {
view
.environmentObject(featureModels.publicChatModel)
.environmentObject(featureModels.appChromeModel)
.environmentObject(featureModels.locationChannelsModel)
.environmentObject(featureModels.privateInboxModel)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.verificationModel)
.environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel)
.environmentObject(featureModels.sharedContentImportModel)
}
@MainActor
private struct ContentPeopleSheetHarness: View {
@State private var showSidebar = true
@State private var messageText = ""
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@State private var imagePreviewURL: URL?
@State private var windowCountPublic = 300
@State private var windowCountPrivate: [PeerID: Int] = [:]
@State private var isAtBottomPrivate = true
@State private var autocompleteDebounceTimer: Timer?
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@FocusState private var isTextFieldFocused: Bool
#if os(iOS)
@State private var showImagePicker = false
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary
#else
@State private var showMacImagePicker = false
#endif
var body: some View {
#if os(iOS)
ContentPeopleSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
headerHeight: 44,
onSendMessage: {},
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentPeopleSheetView(
showSidebar: $showSidebar,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
isAtBottomPrivate: $isAtBottomPrivate,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
headerHeight: 44,
onSendMessage: {},
showMacImagePicker: $showMacImagePicker
)
#endif
}
}
@MainActor
@discardableResult
private func mount<V: View>(_ view: V) -> AnyObject {
#if os(iOS)
let host = UIHostingController(rootView: view)
_ = host.view
host.view.setNeedsLayout()
host.view.layoutIfNeeded()
return host
#else
let host = NSHostingView(rootView: view)
host.layoutSubtreeIfNeeded()
_ = host.fittingSize
return host
#endif
}
private func makeSnapshot(
peerID: PeerID,
nickname: String,
connected: Bool = true,
noiseByte: UInt8
) -> TransportPeerSnapshot {
TransportPeerSnapshot(
peerID: peerID,
nickname: nickname,
isConnected: connected,
noisePublicKey: Data(repeating: noiseByte, count: 32),
lastSeen: Date()
)
}
private func makeCGImage() throws -> CGImage {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
let context = try #require(
CGContext(
data: nil,
width: 8,
height: 8,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
)
context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.2, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
return try #require(context.makeImage())
}
private func makeTemporaryAudioURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("caf")
let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1))
let frameCount: AVAudioFrameCount = 1_600
let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount))
buffer.frameLength = frameCount
let channel = try #require(buffer.floatChannelData?[0])
for index in 0..<Int(frameCount) {
channel[index] = sinf(Float(index) * 0.2) * 0.5
}
let file = try AVAudioFile(forWriting: url, settings: format.settings)
try file.write(from: buffer)
return url
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("png")
let image = try makeCGImage()
#if os(iOS)
let data = try #require(UIImage(cgImage: image).pngData())
#else
let rep = NSBitmapImageRep(cgImage: image)
let data = try #require(rep.representation(using: .png, properties: [:]))
#endif
try data.write(to: url)
return url
}
@Suite("View Smoke Tests", .serialized)
@MainActor
struct ViewSmokeTests {
@Test
func fingerprintView_renders_verifiedAndPendingStates() async {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let verifiedPeer = PeerID(str: "0102030405060708")
let pendingPeer = PeerID(str: "1112131415161718")
let verifiedFingerprint = String(repeating: "ab", count: 32)
transport.peerFingerprints[verifiedPeer] = verifiedFingerprint
transport.peerFingerprints[pendingPeer] = nil
transport.updatePeerSnapshots([
makeSnapshot(peerID: verifiedPeer, nickname: "Alice", noiseByte: 0x11),
makeSnapshot(peerID: pendingPeer, nickname: "Bob", noiseByte: 0x22)
])
try? await Task.sleep(nanoseconds: 50_000_000)
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
let verifiedView = FingerprintView(peerID: verifiedPeer)
.environmentObject(featureModels.verificationModel)
let pendingView = FingerprintView(peerID: pendingPeer)
.environmentObject(featureModels.verificationModel)
_ = mount(verifiedView)
_ = mount(pendingView)
#expect(viewModel.verifiedFingerprints.contains(verifiedFingerprint))
}
@Test
func verificationViews_renderCoreBranches() throws {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let peerID = PeerID(str: "2122232425262728")
let fingerprint = String(repeating: "cd", count: 32)
var isPresented = true
transport.peerFingerprints[peerID] = fingerprint
transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)])
featureModels.privateConversationModel.startConversation(with: peerID)
viewModel.verifiedFingerprints.insert(fingerprint)
let image = try makeCGImage()
let myQR = MyQRView(qrString: "bitchat://verify?name=alice&npub=npub1test")
let qrCode = QRCodeImage(data: "bitchat://verify?hello=world", size: 96)
let imageWrapper = ImageWrapper(image: image)
_ = myQR.body
_ = qrCode.body
_ = imageWrapper.body
_ = mount(myQR)
_ = mount(qrCode)
_ = mount(imageWrapper)
_ = mount(
VerificationSheetView(
isPresented: Binding(
get: { isPresented },
set: { isPresented = $0 }
)
)
.environmentObject(featureModels.verificationModel)
)
}
@Test
func meshPeerList_renders_emptyAndPopulatedStates() async {
let (viewModel, transport, identityManager) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let connectedPeer = PeerID(str: "3132333435363738")
let blockedPeer = PeerID(str: "4142434445464748")
let blockedFingerprint = String(repeating: "ef", count: 32)
_ = mount(
MeshPeerList(
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
_ = MeshPeerList(
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
transport.peerFingerprints[blockedPeer] = blockedFingerprint
identityManager.setBlocked(blockedFingerprint, isBlocked: true)
transport.updatePeerSnapshots([
makeSnapshot(peerID: connectedPeer, nickname: "Alice", noiseByte: 0x44),
makeSnapshot(peerID: blockedPeer, nickname: "Mallory", noiseByte: 0x55)
])
try? await Task.sleep(nanoseconds: 50_000_000)
viewModel.markPrivateChatUnread(blockedPeer)
_ = mount(
MeshPeerList(
onTapPeer: { _ in },
onToggleFavorite: { _ in },
onShowFingerprint: { _ in }
)
.environmentObject(featureModels.peerListModel)
)
#expect(viewModel.hasUnreadMessages(for: blockedPeer))
}
@Test
func commandSuggestionsAndLocationViews_render() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let channel = GeohashChannel(level: .city, geohash: "u4pruy")
var messageText = "/f"
featureModels.locationChannelsModel.select(.location(channel))
_ = mount(
CommandSuggestionsView(
messageText: Binding(
get: { messageText },
set: { messageText = $0 }
)
)
.environmentObject(featureModels.privateConversationModel)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
LocationChannelsSheet(isPresented: .constant(true))
.environmentObject(featureModels.locationChannelsModel)
.environmentObject(featureModels.peerListModel)
)
#expect(messageText == "/f")
featureModels.locationChannelsModel.select(.mesh)
featureModels.locationChannelsModel.endLiveRefresh()
}
@Test
func noticesView_rendersNoRelayAndLoadedStates() throws {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
defer { featureModels.locationChannelsModel.select(.mesh) }
let board = BoardManager(
transport: transport,
store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
publishToNostr: { _, _, _, _, _ in nil },
deleteFromNostr: { _, _ in }
)
let noRelayManager = LocationNotesManager(
geohash: "u4pruydq",
dependencies: LocationNotesDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
)
var noteHandler: ((NostrEvent) -> Void)?
var eose: (() -> Void)?
let loadedManager = LocationNotesManager(
geohash: "u4pruydq",
dependencies: LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, handler, onEOSE in
noteHandler = handler
eose = onEOSE
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
)
let identity = try NostrIdentity.generate()
let event = try NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: [["g", "u4pruydq"], ["n", "Builder"]],
content: "hello from a note"
).sign(with: identity.schnorrSigningKey())
noteHandler?(event)
eose?()
_ = mount(
NoticesView(
senderNickname: viewModel.nickname,
board: board,
initialTab: .geo,
notesManager: noRelayManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
NoticesView(
senderNickname: viewModel.nickname,
board: board,
initialTab: .geo,
notesManager: loadedManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
NoticesView(
senderNickname: viewModel.nickname,
board: board,
initialTab: .mesh
)
.environmentObject(featureModels.locationChannelsModel)
)
#expect(loadedManager.notes.count == 1)
#expect(noRelayManager.state == .noRelays)
}
@Test
func appInfoAndComponentViews_render() {
let feature = AppInfoFeatureInfo(
icon: "lock.fill",
title: "app_info.privacy.title",
description: "app_info.features.encryption.description"
)
// AppInfoView's settings pane reads LocationChannelsModel from the
// environment, so it can only render mounted with one installed.
let appInfo = AppInfoView()
.environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager()))
let header = SectionHeader("app_info.features.title")
let featureRow = FeatureRow(info: feature)
let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token"))
let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test"))
_ = header.body
_ = featureRow.body
_ = paymentCashu.body
_ = paymentLightning.body
_ = DeliveryStatusView(status: .sending).body
_ = DeliveryStatusView(status: .sent).body
_ = DeliveryStatusView(status: .delivered(to: "Alice", at: Date())).body
_ = DeliveryStatusView(status: .read(by: "Alice", at: Date())).body
_ = DeliveryStatusView(status: .failed(reason: "offline")).body
_ = DeliveryStatusView(status: .partiallyDelivered(reached: 2, total: 3)).body
_ = mount(appInfo)
_ = mount(header)
_ = mount(featureRow)
_ = mount(paymentCashu)
_ = mount(paymentLightning)
#expect(PaymentChipView.PaymentType.cashu("cashuA_test-token").url?.scheme == "cashu")
#expect(PaymentChipView.PaymentType.cashu("https://example.com/cashu").url?.absoluteString == "https://example.com/cashu")
#expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning")
}
@Test
func contentShellViews_renderPublicAndPrivateBranches() async {
let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let peerID = PeerID(str: "5152535455565758")
transport.updatePeerSnapshots([
makeSnapshot(peerID: peerID, nickname: "Alice", noiseByte: 0x66)
])
try? await Task.sleep(nanoseconds: 50_000_000)
_ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels))
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
featureModels.privateConversationModel.startConversation(with: peerID)
try? await Task.sleep(nanoseconds: 50_000_000)
_ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels))
#expect(featureModels.privateConversationModel.selectedPeerID == peerID)
#expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID)
}
@Test("Root Bluetooth alert waits for location and notices sheets")
func rootBluetoothAlertGuard_includesHeaderSheets() {
#expect(!ContentRootModalPresentationState().hasPresentation)
#expect(
ContentRootModalPresentationState(
isLocationChannelsSheetPresented: true
).hasPresentation
)
#expect(
ContentRootModalPresentationState(
isNoticesSheetPresented: true
).hasPresentation
)
}
@Test("People-sheet Bluetooth alert waits for local verification sheet")
func peopleSheetBluetoothAlertGuard_includesVerificationSheet() {
#expect(!ContentPeopleSheetModalPresentationState().hasPresentation)
#expect(
ContentPeopleSheetModalPresentationState(
isVerificationSheetPresented: true
).hasPresentation
)
}
@Test("Bluetooth alerts wait for the voice recording error alert")
func bluetoothAlertGuards_includeVoiceAlert() {
#expect(
ContentRootModalPresentationState(
isVoiceAlertPresented: true
).hasPresentation
)
#expect(
ContentPeopleSheetModalPresentationState(
isVoiceAlertPresented: true
).hasPresentation
)
}
@Test("Root Bluetooth alert waits for screenshot privacy alert")
@MainActor
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
#expect(
!ContentRootModalPresentationState(
appChromeModel: featureModels.appChromeModel
).hasPresentation
)
featureModels.appChromeModel.showScreenshotPrivacyWarning = true
#expect(
ContentRootModalPresentationState(
appChromeModel: featureModels.appChromeModel
).hasPresentation
)
}
@Test("People-sheet Bluetooth alert waits for legacy media consent")
@MainActor
func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
#expect(
!ContentPeopleSheetModalPresentationState(
legacyPrivateMediaConsentRequest:
featureModels.conversationUIModel
.legacyPrivateMediaConsentRequest
).hasPresentation
)
viewModel.enqueueLegacyPrivateMediaConsent(
for: PeerID(str: "5152535455565758"),
transferId: "legacy-consent-transfer",
messageID: "legacy-consent-message"
) { _ in }
defer { viewModel.cancelAllLegacyPrivateMediaConsents() }
let consentPropagated = await TestHelpers.waitUntil {
featureModels.conversationUIModel
.legacyPrivateMediaConsentRequest != nil
}
#expect(
consentPropagated
)
#expect(
ContentPeopleSheetModalPresentationState(
legacyPrivateMediaConsentRequest:
featureModels.conversationUIModel
.legacyPrivateMediaConsentRequest
).hasPresentation
)
}
@Test
func geohashAndTextMessageViews_renderCoreBranches() {
let (viewModel, _, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
let geohashPeopleList = GeohashPeopleList(
onTapPerson: {}
)
let truncatableMessage = BitchatMessage(
sender: viewModel.nickname,
content: String(repeating: "verylongtoken ", count: 160),
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sent
)
let paymentMessage = BitchatMessage(
sender: viewModel.nickname,
content: "lightning:lnbc1test cashuA_test-token",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Bob",
deliveryStatus: .partiallyDelivered(reached: 1, total: 2)
)
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
_ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel))
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel))
_ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel))
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
}
@Test
func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL()
// Probed directly below. Deliberately a separate file from `audioURL`:
// `WaveformCache.shared` is process-wide and the mounted
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
// width concurrently, so asserting an exact bin count for that URL
// races with the view's own cache write.
let waveformProbeURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL()
defer {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
}
let waveformView = WaveformView(
samples: [0.1, 0.6, 0.3, 0.8],
playbackProgress: 0.25,
sendProgress: 0.75,
onSeek: nil,
isInteractive: false
)
let imageView = BlockRevealImageView(
url: imageURL,
revealProgress: 0.5,
isSending: true,
onCancel: {},
initiallyBlurred: true,
onOpen: {},
onDelete: {}
)
let voiceNoteView = VoiceNoteView(
url: audioURL,
isSending: true,
sendProgress: 0.4,
onCancel: {}
)
let playback = VoiceNotePlaybackController(url: audioURL)
_ = waveformView.body
_ = imageView.body
_ = mount(waveformView)
_ = mount(imageView)
_ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
continuation.resume(returning: values)
}
}
playback.loadDuration()
// loadDuration hops through a background queue and back to main; poll
// instead of a fixed sleep so a loaded runner can't outlast the wait.
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
playback.seek(to: 1.25)
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
await VoiceRecorder.shared.cancelRecording(owner: VoiceRecorder.RecordingOwner())
#expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
#expect(playback.duration > 0)
#expect(playback.progress == 0)
}
@Test
func messageRows_snapshotDeliveryStatusForSwiftUIDiffing() {
// Regression: `BitchatMessage` is a reference type mutated in place
// by `ConversationStore.applyDeliveryStatus`, and SwiftUI compares
// reference-typed view fields by identity — so a status-only change
// (delivered → read) on the SAME instance is invisible to the row's
// structural diff and its body gets skipped even when the list
// re-renders. The row views must therefore snapshot the status as a
// value-typed stored property at init, so a rebuilt row value
// compares different and re-renders.
func deliveryStatusSnapshot(of row: Any) -> DeliveryStatus? {
Mirror(reflecting: row).children
.first { $0.label == "deliveryStatus" }?
.value as? DeliveryStatus
}
let delivered = DeliveryStatus.delivered(to: "builder", at: Date(timeIntervalSince1970: 50))
let message = BitchatMessage(
id: "dm-status-1",
sender: "anon",
content: "hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: PeerID(str: "abcdef1234567890"),
deliveryStatus: delivered
)
#expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == delivered)
// In-place mutation of the shared instance (what the store does on a
// READ ack); a freshly built row must carry the new status value.
let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100))
message.deliveryStatus = read
#expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == read)
let mediaRow = MediaMessageView(
message: message,
media: .image(URL(fileURLWithPath: "/tmp/never-loaded.jpg")),
imagePreviewURL: .constant(nil)
)
#expect(deliveryStatusSnapshot(of: mediaRow) == read)
}
#if os(iOS)
@Test
func cameraScannerView_previewAndCoordinatorSmoke() {
let preview = CameraScannerView.PreviewView(frame: .zero)
let coordinator = CameraScannerView.Coordinator()
_ = CameraScannerView.PreviewView.layerClass
_ = preview.videoPreviewLayer
coordinator.setup(sessionOwner: preview) { _ in }
coordinator.setActive(false)
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
}
#endif
}