Files
letro-ios/UnitTests/Sources/VoiceMessageRecorderTests.swift
Copilot 4834f453ef Finish migration of UnitTests target from XCTestCase to Swift Testing (#5129)
* Initial plan

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadPreviewScreenViewModelTests: @MainActor @Suite struct with init(),
  BundleFinder class for Bundle(for:), mutating test/setup functions,
  [self] capture replacing [weak self] in closures
- NotificationManagerTests: @MainActor @Suite final class with init()/deinit,
  expectation/fulfillment(of:) replaced with confirmation(...), test_ prefix stripped
- NotificationSettingsScreenViewModelTests: @MainActor @Suite struct with
  init() throws, non-optional stored properties, test prefix stripped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 3 XCTest files to Swift Testing

- NotificationSettingsEditScreenViewModelTests: @MainActor @Suite struct with init() throws, mutating test methods
- TimelineViewModelTests: @MainActor @Suite final class with init() async throws + deinit
- AttributedStringBuilderTests: @Suite struct with init() async throws

All XCT assertions replaced with #expect/#require/Issue.record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- TimelineMediaPreviewViewModelTests: @Suite struct, mutating @Test funcs,
  testLoadingItem renamed to loadingItem (called internally by other tests)
- ServerConfirmationScreenViewModelTests: @Suite final class with init()/deinit
- CompletionSuggestionServiceTests: @Suite struct with init()
- RoomFlowCoordinatorTests: @Suite final class with deinit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- VoiceMessageRecorderTests: @Suite struct with init() async throws,
  added BundleFinder class for Bundle lookup, migrated all assertions
- SpaceScreenViewModelTests: @Suite struct, private mutating setupViewModel,
  all test funcs mutating, XCTestExpectation → confirmation
- RoomNotificationSettingsScreenViewModelTests: @Suite struct with
  init() throws, cancellable tests marked mutating
- JoinRoomScreenViewModelTests: @Suite final class with init()/deinit,
  XCTestExpectation → confirmation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 6 test files from XCTestCase to Swift Testing

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Fix trailing blank line in RoomPollsHistoryScreenViewModelTests

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadingPreprocessorTests: @Suite final class with init()/deinit,
  removed executionTimeAllowance, XCTAssertEqual(accuracy:) → abs(Double)
- SecurityAndPrivacyScreenViewModelTests: @MainActor @Suite final class,
  5 expectation+fulfillment → await confirmation(...)
- CreateRoomViewModelTests: @MainActor @Suite final class,
  4 expectation+fulfillment → await confirmation(...)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate RoomScreenViewModelTests and RoomDetailsScreenViewModelTests to Swift Testing

- Replace XCTest with Testing framework
- RoomScreenViewModelTests: final class with init() async throws + deinit
- RoomDetailsScreenViewModelTests: struct with init() and mutating funcs
- Convert XCT assertions to #expect / Issue.record
- Convert XCTestExpectation patterns to confirmation { confirm in }
- Strip 'test' prefix from all test function names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate ComposerToolbarViewModelTests from XCTest to Swift Testing

- Replace import XCTest with import Testing
- Convert XCTestCase class to @MainActor @Suite final class
- Replace setUp()/tearDown() with init()/deinit
- Strip 'test' prefix from all 41 test method names and add @Test
- Replace XCTAssert* with #expect()/#require()
- Replace try XCTUnwrap() with try #require()
- Convert expectation+wait patterns to deferFulfillment with PassthroughSubject
- Convert isInverted expectation to boolean flag checked after await
- Use deferFulfillment on $viewState for state-transition tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address comments with Copilot.

* Fix the failing tests.

* Fixed flaky tests (#5137)

resolved flaky tests

* Tweaks and fixes.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>
Co-authored-by: Doug <douglase@element.io>
Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
2026-02-24 12:20:01 +00:00

395 lines
16 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@testable import ElementX
import Foundation
import Testing
@Suite
@MainActor
struct VoiceMessageRecorderTests {
private var voiceMessageRecorder: VoiceMessageRecorder!
private var audioRecorder: AudioRecorderMock!
private var audioRecorderActionsSubject: PassthroughSubject<AudioRecorderAction, Never> = .init()
private var audioRecorderActions: AnyPublisher<AudioRecorderAction, Never> {
audioRecorderActionsSubject.eraseToAnyPublisher()
}
private var mediaPlayerProvider: MediaPlayerProviderMock!
private var audioConverter: AudioConverterMock!
private var voiceMessageCache: VoiceMessageCacheMock!
private var audioPlayer: AudioPlayerMock!
private var audioPlayerActionsSubject: PassthroughSubject<AudioPlayerAction, Never> = .init()
private var audioPlayerActions: AnyPublisher<AudioPlayerAction, Never> {
audioPlayerActionsSubject.eraseToAnyPublisher()
}
private let recordingURL = URL("/some/url")
init() async throws {
audioRecorder = AudioRecorderMock()
audioRecorder.underlyingCurrentTime = 0
audioRecorder.averagePowerReturnValue = 0
audioRecorder.actions = audioRecorderActions
audioPlayer = AudioPlayerMock()
audioPlayer.actions = audioPlayerActions
audioPlayer.state = .stopped
audioPlayer.playbackSpeed = 1.0
mediaPlayerProvider = MediaPlayerProviderMock()
mediaPlayerProvider.player = audioPlayer
audioConverter = AudioConverterMock()
voiceMessageCache = VoiceMessageCacheMock()
voiceMessageCache.urlForRecording = FileManager.default.temporaryDirectory.appendingPathComponent("test-voice-message").appendingPathExtension("m4a")
voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: audioRecorder,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageCache: voiceMessageCache)
}
private func setRecordingComplete() async throws {
audioRecorder.audioFileURL = recordingURL
audioRecorder.currentTime = 5
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStopRecording(_, let url) where url == recordingURL:
return true
default:
return false
}
}
audioRecorderActionsSubject.send(.didStopRecording)
try await deferred.fulfill()
}
@Test
func recorderRecordingURL() {
audioRecorder.audioFileURL = recordingURL
#expect(voiceMessageRecorder.recordingURL == recordingURL)
}
@Test
func recorderRecordingDuration() {
audioRecorder.currentTime = 10.3
#expect(voiceMessageRecorder.recordingDuration == 10.3)
}
@Test
func startRecording() async {
_ = await voiceMessageRecorder.startRecording()
#expect(audioRecorder.recordAudioFileURLCalled)
}
@Test
func stopRecording() async {
_ = await voiceMessageRecorder.stopRecording()
// Internal audio recorder must have been stopped
#expect(audioRecorder.stopRecordingCalled)
}
@Test
func cancelRecording() async {
await voiceMessageRecorder.cancelRecording()
// Internal audio recorder must have been stopped
#expect(audioRecorder.stopRecordingCalled)
// The recording audio file must have been deleted
#expect(audioRecorder.deleteRecordingCalled)
}
@Test
func deleteRecording() async {
await voiceMessageRecorder.deleteRecording()
// The recording audio file must have been deleted
#expect(audioRecorder.deleteRecordingCalled)
}
@Test
func startPlaybackNoPreview() async {
guard case .failure(.previewNotAvailable) = await voiceMessageRecorder.startPlayback() else {
Issue.record("An error is expected")
return
}
}
@Test
func startPlayback() async throws {
try await setRecordingComplete()
guard case .success = await voiceMessageRecorder.startPlayback() else {
Issue.record("Playback should start")
return
}
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.sourceURL == recordingURL)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.playbackURL == recordingURL)
#expect(audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedArguments?.autoplay == true)
#expect(!audioPlayer.playCalled)
}
@Test
func pausePlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
voiceMessageRecorder.pausePlayback()
#expect(audioPlayer.pauseCalled)
}
@Test
func resumePlayback() async throws {
try await setRecordingComplete()
audioPlayer.playbackURL = recordingURL
guard case .success = await voiceMessageRecorder.startPlayback() else {
Issue.record("Playback should start")
return
}
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
// The media must not have been reloaded
#expect(!audioPlayer.loadSourceURLPlaybackURLAutoplayCalled)
#expect(audioPlayer.playCalled)
}
@Test
func stopPlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
await voiceMessageRecorder.stopPlayback()
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == false)
#expect(audioPlayer.stopCalled)
}
@Test
func seekPlayback() async throws {
try await setRecordingComplete()
_ = await voiceMessageRecorder.startPlayback()
#expect(voiceMessageRecorder.previewAudioPlayerState?.isAttached == true)
await voiceMessageRecorder.seekPlayback(to: 0.4)
#expect(audioPlayer.seekToReceivedProgress == 0.4)
}
@Test
func buildRecordedWaveform() async throws {
// If there is no recording file, an error is expected
audioRecorder.audioFileURL = nil
guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.buildRecordingWaveform() else {
Issue.record("An error is expected")
return
}
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_audio", withExtension: "mp3"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else {
Issue.record("A waveform is expected")
return
}
#expect(!data.isEmpty)
}
@Test
func sendVoiceMessage_NoRecordingFile() async {
let timelineController = MockTimelineController()
// If there is no recording file, an error is expected
audioRecorder.audioFileURL = nil
guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
Issue.record("An error is expected")
return
}
}
@Test
func sendVoiceMessage_ConversionError() async {
audioRecorder.audioFileURL = recordingURL
// If the converter returns an error
audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil)
let timelineController = MockTimelineController()
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
Issue.record("An error is expected")
return
}
}
@Test
func sendVoiceMessage_InvalidFile() async throws {
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination)
}
let timelineProxy = TimelineProxyMock()
let timelineController = MockTimelineController(timelineProxy: timelineProxy)
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
Issue.record("An error is expected")
return
}
}
@Test
func sendVoiceMessage_WaveformAnlyseFailed() async throws {
let imageFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_image", withExtension: "png"), "Test image file is missing")
audioRecorder.audioFileURL = imageFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination)
try? FileManager.default.copyItem(at: imageFileURL, to: destination)
}
let timelineProxy = TimelineProxyMock()
let timelineController = MockTimelineController(timelineProxy: timelineProxy)
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
Issue.record("An error is expected")
return
}
}
@Test
func sendVoiceMessage_SendError() async throws {
let audioFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in
try? FileManager.default.removeItem(at: destination)
let internalConverter = AudioConverter()
try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination)
}
// If the media upload fails
let timelineProxy = TimelineProxyMock()
let timelineController = MockTimelineController(timelineProxy: timelineProxy)
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter) else {
Issue.record("An error is expected")
return
}
}
@Test
func sendVoiceMessage() async throws {
let imageFileURL = try #require(Bundle(for: UnitTestsAppCoordinator.self).url(forResource: "test_voice_message", withExtension: "m4a"), "Test audio file is missing")
let timelineProxy = TimelineProxyMock()
let timelineController = MockTimelineController(timelineProxy: timelineProxy)
audioRecorder.currentTime = 42
audioRecorder.audioFileURL = imageFileURL
_ = await voiceMessageRecorder.startRecording()
_ = await voiceMessageRecorder.stopRecording()
var convertedFileURL: URL?
var convertedFileSize: UInt64?
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in
convertedFileURL = destination
try? FileManager.default.removeItem(at: destination)
let internalConverter = AudioConverter()
try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination)
convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination))
// the source URL must be the recorded file
#expect(source == imageFileURL)
// check the converted file extension
#expect(destination.pathExtension == "ogg")
}
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { url, audioInfo, waveform, _ in
#expect(url == convertedFileURL)
#expect(audioInfo.duration == audioRecorder.currentTime)
#expect(audioInfo.size == convertedFileSize)
#expect(audioInfo.mimetype == "audio/ogg")
#expect(!waveform.isEmpty)
return .success(())
}
guard case .success = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else {
Issue.record("A success is expected")
return
}
#expect(audioConverter.convertToOpusOggSourceURLDestinationURLCalled)
#expect(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled)
// the converted file must have been deleted
if let convertedFileURL {
#expect(!FileManager.default.fileExists(atPath: convertedFileURL.path()))
} else {
Issue.record("converted file URL is missing")
}
}
@Test
func audioRecorderActionHandling_didStartRecording() async throws {
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStartRecording:
return true
default:
return false
}
}
audioRecorderActionsSubject.send(.didStartRecording)
try await deferred.fulfill()
}
@Test
func audioRecorderActionHandling_didStopRecording() async throws {
audioRecorder.audioFileURL = recordingURL
audioRecorder.currentTime = 5
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didStopRecording(_, let url) where url == recordingURL:
return true
default:
return false
}
}
audioRecorderActionsSubject.send(.didStopRecording)
try await deferred.fulfill()
}
@Test
func audioRecorderActionHandling_didFailed() async throws {
audioRecorder.audioFileURL = recordingURL
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action {
case .didFailWithError:
return true
default:
return false
}
}
audioRecorderActionsSubject.send(.didFailWithError(error: .audioEngineFailure))
try await deferred.fulfill()
}
}
private enum SDKError: Error {
case generic
}