diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 8652a41fa..09bdd1f3f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -650,6 +650,7 @@ C0DC02E2B91DC76A4D1A0E7F /* OnboardingScreenBackgroundImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3450F4C32D73532DBBC1A2 /* OnboardingScreenBackgroundImage.swift */; }; C11939FDC40716C4387275A4 /* NotificationSettingsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8544F7058D31DBEB8DBFF0F5 /* NotificationSettingsEditScreenViewModelTests.swift */; }; C13128AAA787A4C2CBE4EE82 /* MessageForwardingScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC10CCC8D68B863E20660DBC /* MessageForwardingScreenViewModelProtocol.swift */; }; + C1429699A6A5BB09A25775C1 /* AudioPlayerStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */; }; C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */; }; C1910A16BDF131FECA77BE22 /* EmojiPickerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA38B9851CFCC4D67F5587D /* EmojiPickerScreenCoordinator.swift */; }; C1A5C386319835FB0C77736B /* ReportContentScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */; }; @@ -1315,6 +1316,7 @@ 8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlignedScrollView.swift; sourceTree = ""; }; 8896CDD20CA2D87EA3B848A1 /* RoomNotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreen.swift; sourceTree = ""; }; 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerProtocol.swift; sourceTree = ""; }; + 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerStateTests.swift; sourceTree = ""; }; 892E29C98C4E8182C9037F84 /* TimelineStyler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyler.swift; sourceTree = ""; }; 893777A4997BBDB68079D4F5 /* ArrayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayTests.swift; sourceTree = ""; }; 8977176AB534AA41630395BC /* LegalInformationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2760,6 +2762,7 @@ 893777A4997BBDB68079D4F5 /* ArrayTests.swift */, AF25E364AE85090A70AE4644 /* AttributedStringBuilderTests.swift */, 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */, + 89233612A8632AD7E2803620 /* AudioPlayerStateTests.swift */, 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */, EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */, 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */, @@ -4550,6 +4553,7 @@ 3EC698F80DDEEFA273857841 /* ArrayTests.swift in Sources */, 90DF83A6A347F7EE7EDE89EE /* AttributedStringBuilderTests.swift in Sources */, 5100F53E6884A15F9BA07CC3 /* AttributedStringTests.swift in Sources */, + C1429699A6A5BB09A25775C1 /* AudioPlayerStateTests.swift in Sources */, 0F9E38A75337D0146652ACAB /* BackgroundTaskTests.swift in Sources */, 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 808616c99..97f0c8ce2 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -115,6 +115,94 @@ class AnalyticsClientMock: AnalyticsClientProtocol { updateUserPropertiesClosure?(userProperties) } } +class AudioPlayerMock: AudioPlayerProtocol { + var actions: AnyPublisher { + get { return underlyingActions } + set(value) { underlyingActions = value } + } + var underlyingActions: AnyPublisher! + var mediaSource: MediaSourceProxy? + var currentTime: TimeInterval { + get { return underlyingCurrentTime } + set(value) { underlyingCurrentTime = value } + } + var underlyingCurrentTime: TimeInterval! + var url: URL? + var state: MediaPlayerState { + get { return underlyingState } + set(value) { underlyingState = value } + } + var underlyingState: MediaPlayerState! + + //MARK: - load + + var loadMediaSourceUsingCallsCount = 0 + var loadMediaSourceUsingCalled: Bool { + return loadMediaSourceUsingCallsCount > 0 + } + var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)? + var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = [] + var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)? + + func load(mediaSource: MediaSourceProxy, using url: URL) { + loadMediaSourceUsingCallsCount += 1 + loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url) + loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url)) + loadMediaSourceUsingClosure?(mediaSource, url) + } + //MARK: - play + + var playCallsCount = 0 + var playCalled: Bool { + return playCallsCount > 0 + } + var playClosure: (() -> Void)? + + func play() { + playCallsCount += 1 + playClosure?() + } + //MARK: - pause + + var pauseCallsCount = 0 + var pauseCalled: Bool { + return pauseCallsCount > 0 + } + var pauseClosure: (() -> Void)? + + func pause() { + pauseCallsCount += 1 + pauseClosure?() + } + //MARK: - stop + + var stopCallsCount = 0 + var stopCalled: Bool { + return stopCallsCount > 0 + } + var stopClosure: (() -> Void)? + + func stop() { + stopCallsCount += 1 + stopClosure?() + } + //MARK: - seek + + var seekToCallsCount = 0 + var seekToCalled: Bool { + return seekToCallsCount > 0 + } + var seekToReceivedProgress: Double? + var seekToReceivedInvocations: [Double] = [] + var seekToClosure: ((Double) async -> Void)? + + func seek(to progress: Double) async { + seekToCallsCount += 1 + seekToReceivedProgress = progress + seekToReceivedInvocations.append(progress) + await seekToClosure?(progress) + } +} class BugReportServiceMock: BugReportServiceProtocol { var isRunning: Bool { get { return underlyingIsRunning } @@ -226,6 +314,89 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol { setSuggestionTriggerClosure?(suggestionTrigger) } } +class MediaPlayerMock: MediaPlayerProtocol { + var mediaSource: MediaSourceProxy? + var currentTime: TimeInterval { + get { return underlyingCurrentTime } + set(value) { underlyingCurrentTime = value } + } + var underlyingCurrentTime: TimeInterval! + var url: URL? + var state: MediaPlayerState { + get { return underlyingState } + set(value) { underlyingState = value } + } + var underlyingState: MediaPlayerState! + + //MARK: - load + + var loadMediaSourceUsingCallsCount = 0 + var loadMediaSourceUsingCalled: Bool { + return loadMediaSourceUsingCallsCount > 0 + } + var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)? + var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = [] + var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)? + + func load(mediaSource: MediaSourceProxy, using url: URL) { + loadMediaSourceUsingCallsCount += 1 + loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url) + loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url)) + loadMediaSourceUsingClosure?(mediaSource, url) + } + //MARK: - play + + var playCallsCount = 0 + var playCalled: Bool { + return playCallsCount > 0 + } + var playClosure: (() -> Void)? + + func play() { + playCallsCount += 1 + playClosure?() + } + //MARK: - pause + + var pauseCallsCount = 0 + var pauseCalled: Bool { + return pauseCallsCount > 0 + } + var pauseClosure: (() -> Void)? + + func pause() { + pauseCallsCount += 1 + pauseClosure?() + } + //MARK: - stop + + var stopCallsCount = 0 + var stopCalled: Bool { + return stopCallsCount > 0 + } + var stopClosure: (() -> Void)? + + func stop() { + stopCallsCount += 1 + stopClosure?() + } + //MARK: - seek + + var seekToCallsCount = 0 + var seekToCalled: Bool { + return seekToCallsCount > 0 + } + var seekToReceivedProgress: Double? + var seekToReceivedInvocations: [Double] = [] + var seekToClosure: ((Double) async -> Void)? + + func seek(to progress: Double) async { + seekToCallsCount += 1 + seekToReceivedProgress = progress + seekToReceivedInvocations.append(progress) + await seekToClosure?(progress) + } +} class NotificationCenterMock: NotificationCenterProtocol { //MARK: - post @@ -1914,4 +2085,32 @@ class UserNotificationCenterMock: UserNotificationCenterProtocol { } } } +class VoiceMessageMediaManagerMock: VoiceMessageMediaManagerProtocol { + + //MARK: - loadVoiceMessageFromSource + + var loadVoiceMessageFromSourceBodyThrowableError: Error? + var loadVoiceMessageFromSourceBodyCallsCount = 0 + var loadVoiceMessageFromSourceBodyCalled: Bool { + return loadVoiceMessageFromSourceBodyCallsCount > 0 + } + var loadVoiceMessageFromSourceBodyReceivedArguments: (source: MediaSourceProxy, body: String?)? + var loadVoiceMessageFromSourceBodyReceivedInvocations: [(source: MediaSourceProxy, body: String?)] = [] + var loadVoiceMessageFromSourceBodyReturnValue: URL! + var loadVoiceMessageFromSourceBodyClosure: ((MediaSourceProxy, String?) async throws -> URL)? + + func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL { + if let error = loadVoiceMessageFromSourceBodyThrowableError { + throw error + } + loadVoiceMessageFromSourceBodyCallsCount += 1 + loadVoiceMessageFromSourceBodyReceivedArguments = (source: source, body: body) + loadVoiceMessageFromSourceBodyReceivedInvocations.append((source: source, body: body)) + if let loadVoiceMessageFromSourceBodyClosure = loadVoiceMessageFromSourceBodyClosure { + return try await loadVoiceMessageFromSourceBodyClosure(source, body) + } else { + return loadVoiceMessageFromSourceBodyReturnValue + } + } +} // swiftlint:enable all diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift b/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift index 1bb249479..e8d0b06ae 100644 --- a/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift +++ b/ElementX/Sources/Services/AudioPlayer/AudioPlayerProtocol.swift @@ -30,3 +30,6 @@ enum AudioPlayerAction { protocol AudioPlayerProtocol: MediaPlayerProtocol { var actions: AnyPublisher { get } } + +// sourcery: AutoMockable +extension AudioPlayerProtocol { } diff --git a/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift b/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift index a5721ec49..1ad07b6ab 100644 --- a/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift +++ b/ElementX/Sources/Services/AudioPlayer/AudioPlayerState.swift @@ -40,6 +40,10 @@ class AudioPlayerState: ObservableObject { var isAttached: Bool { audioPlayer != nil } + + var isPublishingProgress: Bool { + cancellableTimer != nil + } init(duration: Double, waveform: Waveform? = nil, progress: Double = 0.0) { self.duration = duration @@ -133,6 +137,7 @@ class AudioPlayerState: ObservableObject { private func stopPublishProgress() { cancellableTimer?.cancel() + cancellableTimer = nil } private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async { diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift index b961259ee..c45579fa9 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift @@ -37,3 +37,6 @@ protocol MediaPlayerProtocol { func stop() func seek(to progress: Double) async } + +// sourcery: AutoMockable +extension MediaPlayerProtocol { } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift index f802f7571..99f287735 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManagerProtocol.swift @@ -19,3 +19,6 @@ import Foundation protocol VoiceMessageMediaManagerProtocol { func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL } + +// sourcery: AutoMockable +extension VoiceMessageMediaManagerProtocol { } diff --git a/UnitTests/Sources/AudioPlayerStateTests.swift b/UnitTests/Sources/AudioPlayerStateTests.swift new file mode 100644 index 000000000..1a4007df2 --- /dev/null +++ b/UnitTests/Sources/AudioPlayerStateTests.swift @@ -0,0 +1,221 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +@testable import ElementX +import Foundation +import XCTest + +@MainActor +class AudioPlayerStateTests: XCTestCase { + private var audioPlayerState: AudioPlayerState! + private var audioPlayerMock: AudioPlayerMock! + + private var audioPlayerActionsSubject: PassthroughSubject! + private var audioPlayerActions: AnyPublisher { + audioPlayerActionsSubject.eraseToAnyPublisher() + } + + private var audioPlayerSeekCallsSubject: PassthroughSubject! + private var audioPlayerSeekCalls: AnyPublisher { + audioPlayerSeekCallsSubject.eraseToAnyPublisher() + } + + private func buildAudioPlayerMock() -> AudioPlayerMock { + let audioPlayerMock = AudioPlayerMock() + audioPlayerMock.underlyingActions = audioPlayerActions + audioPlayerMock.currentTime = 0.0 + audioPlayerMock.seekToClosure = { [audioPlayerSeekCallsSubject] progress in + audioPlayerSeekCallsSubject?.send(progress) + } + return audioPlayerMock + } + + override func setUp() async throws { + audioPlayerActionsSubject = .init() + audioPlayerSeekCallsSubject = .init() + audioPlayerState = AudioPlayerState(duration: 10.0) + audioPlayerMock = buildAudioPlayerMock() + } + + func testAttach() async throws { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + XCTAssert(audioPlayerState.isAttached) + XCTAssertEqual(audioPlayerState.playbackState, .loading) + } + + func testDetach() async throws { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + audioPlayerState.detachAudioPlayer() + XCTAssert(audioPlayerMock.stopCalled) + XCTAssertFalse(audioPlayerState.isAttached) + XCTAssertEqual(audioPlayerState.playbackState, .stopped) + } + + func testReportError() async throws { + XCTAssertEqual(audioPlayerState.playbackState, .stopped) + audioPlayerState.reportError(AudioPlayerError.genericError) + XCTAssertEqual(audioPlayerState.playbackState, .error) + } + + func testUpdateProgress() async throws { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + // If we try to set a negative progress, the new progress must be 0.0 + do { + await audioPlayerState.updateState(progress: -5.0) + XCTAssertEqual(audioPlayerState.progress, 0.0) + XCTAssertEqual(audioPlayerMock.seekToReceivedProgress, 0.0) + } + + // If we try to set a progress > 1.0, the new progress must be 1.0 + do { + await audioPlayerState.updateState(progress: 1.5) + XCTAssertEqual(audioPlayerState.progress, 1.0) + XCTAssertEqual(audioPlayerMock.seekToReceivedProgress, 1.0) + } + + do { + await audioPlayerState.updateState(progress: 0.4) + XCTAssertEqual(audioPlayerState.progress, 0.4) + XCTAssertEqual(audioPlayerMock.seekToReceivedProgress, 0.4) + } + } + + func testHandlingAudioPlayerActionDidStartLoading() async throws { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didStartLoading: + return true + default: + return false + } + } + + audioPlayerActionsSubject.send(.didStartLoading) + try await deferred.fulfill() + XCTAssertEqual(audioPlayerState.playbackState, .loading) + } + + func testHandlingAudioPlayerActionDidFinishLoading() async throws { + let originalStateProgress = 0.4 + await audioPlayerState.updateState(progress: originalStateProgress) + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didFinishLoading: + return true + default: + return false + } + } + // The progress should be restored + let deferedProgress = deferFulfillment(audioPlayerSeekCalls) { progress in + progress == originalStateProgress + } + + audioPlayerActionsSubject.send(.didFinishLoading) + try await deferred.fulfill() + try await deferedProgress.fulfill() + + // The state is expected to be .readyToPlay + XCTAssertEqual(audioPlayerState.playbackState, .readyToPlay) + } + + func testHandlingAudioPlayerActionDidStartPlaying() async throws { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didStartPlaying: + return true + default: + return false + } + } + + audioPlayerActionsSubject.send(.didStartPlaying) + try await deferred.fulfill() + XCTAssertEqual(audioPlayerState.playbackState, .playing) + XCTAssert(audioPlayerState.isPublishingProgress) + } + + func testHandlingAudioPlayerActionDidPausePlaying() async throws { + await audioPlayerState.updateState(progress: 0.4) + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didPausePlaying: + return true + default: + return false + } + } + + audioPlayerActionsSubject.send(.didPausePlaying) + try await deferred.fulfill() + XCTAssertEqual(audioPlayerState.playbackState, .stopped) + XCTAssertEqual(audioPlayerState.progress, 0.4) + XCTAssertFalse(audioPlayerState.isPublishingProgress) + } + + func testHandlingAudioPlayerActionsidStopPlaying() async throws { + await audioPlayerState.updateState(progress: 0.4) + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didStopPlaying: + return true + default: + return false + } + } + + audioPlayerActionsSubject.send(.didStopPlaying) + try await deferred.fulfill() + XCTAssertEqual(audioPlayerState.playbackState, .stopped) + XCTAssertEqual(audioPlayerState.progress, 0.4) + XCTAssertFalse(audioPlayerState.isPublishingProgress) + } + + func testAudioPlayerActionsDidFinishPlaying() async throws { + await audioPlayerState.updateState(progress: 0.4) + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerActions) { action in + switch action { + case .didFinishPlaying: + return true + default: + return false + } + } + + audioPlayerActionsSubject.send(.didFinishPlaying) + try await deferred.fulfill() + XCTAssertEqual(audioPlayerState.playbackState, .stopped) + // Progress should be reset to 0 + XCTAssertEqual(audioPlayerState.progress, 0.0) + XCTAssertFalse(audioPlayerState.isPublishingProgress) + } +}