Improve tests' reliability (#763)

* Create publisher extension into the unit test target

* Add ViewModelContext test extension

* Refactor BugReportViewModelTests

* Fix failing UTs

* Idea PublishedClosure

* Refactor RoomDetailsViewModelTests

* Replace more Task.yield/Task.sleep

* Move leaveRoom/ignore/unignore under the @MainActor

* Revert "Idea PublishedClosure"

This reverts commit 4ab25291041f0dbd99083baf9d95bc6647f1fd97.

* Make process(viewAction:) sync

* Refactor BugReportViewModel callback to a publisher

* Fix UTs

* Refactor ReportContentViewModel

* Fix ui test build error

* Try make sonar happy

* Empty commit

* Revert "Try make sonar happy"

This reverts commit 97804b19373a8f55f12174ccbf27f1fd8db583b7.

* Rename ui test identifier

* Cleanup

* Callback -> actions refactor

* Update template

* Add publisher in TemplateCoordinator

* Add env variable in IntegrationTests.xctestplan

* Add async sequence extension

* Amend integration test plan

* Remove env variable from target.yml

* Cleanup

* Fix failing UI tests
This commit is contained in:
Alfonso Grillo
2023-04-05 17:07:12 +02:00
committed by GitHub
parent d24ee73d15
commit 2439431287
55 changed files with 273 additions and 233 deletions
@@ -23,17 +23,3 @@ extension Publisher where Self.Failure == Never {
}
}
}
extension Published.Publisher {
/// Returns the next output from the publisher skipping the current value stored into it (which is readable from the @Published property itself).
/// - Returns: the next output from the publisher
var nextValue: Output? {
get async {
var iterator = values.makeAsyncIterator()
// skips the publisher's current value
_ = await iterator.next()
return await iterator.next()
}
}
}
@@ -89,14 +89,14 @@ class StateStoreViewModel<State: BindableState, ViewAction> {
.sink { [weak self] action in
guard let self else { return }
Task { await self.process(viewAction: action) }
self.process(viewAction: action)
}
.store(in: &cancellables)
}
/// Override to handles incoming `ViewAction`s from the `ViewModel`.
/// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation.
func process(viewAction: ViewAction) async {
func process(viewAction: ViewAction) {
// Default implementation, -no-op
}
}
@@ -32,7 +32,7 @@ class AnalyticsPromptViewModel: AnalyticsPromptViewModelType, AnalyticsPromptVie
// MARK: - Public
override func process(viewAction: AnalyticsPromptViewAction) async {
override func process(viewAction: AnalyticsPromptViewAction) {
switch viewAction {
case .enable:
callback?(.enable)
@@ -28,7 +28,7 @@ class LoginViewModel: LoginViewModelType, LoginViewModelProtocol {
super.init(initialViewState: viewState)
}
override func process(viewAction: LoginViewAction) async {
override func process(viewAction: LoginViewAction) {
switch viewAction {
case .selectServer:
callback?(.selectServer)
@@ -28,7 +28,7 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
isModallyPresented: isModallyPresented))
}
override func process(viewAction: ServerSelectionViewAction) async {
override func process(viewAction: ServerSelectionViewAction) {
switch viewAction {
case .confirm:
callback?(.confirm(homeserverAddress: state.bindings.homeserverAddress))
@@ -33,7 +33,7 @@ class SoftLogoutViewModel: SoftLogoutViewModelType, SoftLogoutViewModelProtocol
super.init(initialViewState: viewState)
}
override func process(viewAction: SoftLogoutViewAction) async {
override func process(viewAction: SoftLogoutViewAction) {
switch viewAction {
case .login:
callback?(.login(state.bindings.password))
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
enum BugReportCoordinatorResult {
@@ -34,6 +35,7 @@ struct BugReportCoordinatorParameters {
final class BugReportCoordinator: CoordinatorProtocol {
private let parameters: BugReportCoordinatorParameters
private var viewModel: BugReportViewModelProtocol
private var cancellables: Set<AnyCancellable> = .init()
var completion: ((BugReportCoordinatorResult) -> Void)?
@@ -50,22 +52,25 @@ final class BugReportCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.callback = { [weak self] result in
guard let self else { return }
MXLog.info("BugReportViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.completion?(.cancel)
case let .submitStarted(progressTracker):
self.startLoading(label: L10n.commonSending, progressPublisher: progressTracker)
case .submitFinished:
self.stopLoading()
self.completion?(.finish)
case .submitFailed(let error):
self.stopLoading()
self.showError(label: error.localizedDescription)
viewModel
.actions
.sink { [weak self] result in
guard let self else { return }
MXLog.info("BugReportViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.completion?(.cancel)
case let .submitStarted(progressTracker):
self.startLoading(label: L10n.commonSending, progressPublisher: progressTracker)
case .submitFinished:
self.stopLoading()
self.completion?(.finish)
case .submitFailed(let error):
self.stopLoading()
self.showError(label: error.localizedDescription)
}
}
}
.store(in: &cancellables)
}
func stop() {
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias BugReportViewModelType = StateStoreViewModel<BugReportViewState, BugReportViewAction>
@@ -22,8 +23,11 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
private let bugReportService: BugReportServiceProtocol
private let userID: String
private let deviceID: String?
private let actionsSubject: PassthroughSubject<BugReportViewModelAction, Never> = .init()
var callback: ((BugReportViewModelAction) -> Void)?
var actions: AnyPublisher<BugReportViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(bugReportService: BugReportServiceProtocol,
userID: String,
@@ -42,12 +46,12 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
// MARK: - Public
override func process(viewAction: BugReportViewAction) async {
override func process(viewAction: BugReportViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
actionsSubject.send(.cancel)
case .submit:
await submitBugReport()
Task { await submitBugReport() }
case .removeScreenshot:
state.screenshot = nil
case let .attachScreenshot(image):
@@ -59,7 +63,7 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
private func submitBugReport() async {
let progressTracker = ProgressTracker()
callback?(.submitStarted(progressTracker: progressTracker))
actionsSubject.send(.submitStarted(progressTracker: progressTracker))
do {
var files: [URL] = []
if let screenshot = context.viewState.screenshot {
@@ -78,10 +82,10 @@ class BugReportViewModel: BugReportViewModelType, BugReportViewModelProtocol {
let result = try await bugReportService.submitBugReport(bugReport,
progressListener: progressTracker)
MXLog.info("SubmitBugReport succeeded, result: \(result.reportUrl)")
callback?(.submitFinished)
actionsSubject.send(.submitFinished)
} catch {
MXLog.error("SubmitBugReport failed: \(error)")
callback?(.submitFailed(error: error))
actionsSubject.send(.submitFailed(error: error))
}
}
}
@@ -14,10 +14,10 @@
// limitations under the License.
//
import Foundation
import Combine
@MainActor
protocol BugReportViewModelProtocol {
var callback: ((BugReportViewModelAction) -> Void)? { get set }
var actions: AnyPublisher<BugReportViewModelAction, Never> { get }
var context: BugReportViewModelType.Context { get }
}
@@ -34,7 +34,7 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
.store(in: &cancellables)
}
override func process(viewAction: DeveloperOptionsScreenViewAction) async {
override func process(viewAction: DeveloperOptionsScreenViewAction) {
switch viewAction {
case .changedShouldCollapseRoomStateEvents:
ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
@@ -32,11 +32,13 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr
// MARK: - Public
override func process(viewAction: EmojiPickerScreenViewAction) async {
override func process(viewAction: EmojiPickerScreenViewAction) {
switch viewAction {
case let .search(searchString: searchString):
let categories = await emojiProvider.getCategories(searchString: searchString)
state.categories = convert(emojiCategories: categories)
Task {
let categories = await emojiProvider.getCategories(searchString: searchString)
state.categories = convert(emojiCategories: categories)
}
case let .emojiTapped(emoji: emoji):
callback?(.emojiSelected(emoji: emoji.value))
case .dismiss:
@@ -25,7 +25,7 @@ class FilePreviewViewModel: FilePreviewViewModelType, FilePreviewViewModelProtoc
super.init(initialViewState: FilePreviewViewState(mediaFile: mediaFile, title: title))
}
override func process(viewAction: FilePreviewViewAction) async {
override func process(viewAction: FilePreviewViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
@@ -133,7 +133,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// MARK: - Public
override func process(viewAction: HomeScreenViewAction) async {
override func process(viewAction: HomeScreenViewAction) {
switch viewAction {
case .selectRoom(let roomIdentifier):
callback?(.presentRoom(roomIdentifier: roomIdentifier))
@@ -25,7 +25,7 @@ class MediaPickerPreviewScreenViewModel: MediaPickerPreviewScreenViewModelType,
super.init(initialViewState: MediaPickerPreviewScreenViewState(url: url, title: title))
}
override func process(viewAction: MediaPickerPreviewScreenViewAction) async {
override func process(viewAction: MediaPickerPreviewScreenViewAction) {
switch viewAction {
case .send:
callback?(.send)
@@ -26,7 +26,7 @@ class OnboardingViewModel: OnboardingViewModelType, OnboardingViewModelProtocol
super.init(initialViewState: OnboardingViewState())
}
override func process(viewAction: OnboardingViewAction) async {
override func process(viewAction: OnboardingViewAction) {
switch viewAction {
case .login:
callback?(.login)
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
struct ReportContentCoordinatorParameters {
@@ -31,6 +32,7 @@ enum ReportContentCoordinatorAction {
final class ReportContentCoordinator: CoordinatorProtocol {
private let parameters: ReportContentCoordinatorParameters
private var viewModel: ReportContentViewModelProtocol
private var cancellables: Set<AnyCancellable> = .init()
var callback: ((ReportContentCoordinatorAction) -> Void)?
@@ -43,21 +45,23 @@ final class ReportContentCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
case .submitStarted:
self.startLoading()
case let .submitFailed(error):
self.stopLoading()
self.showError(description: error.localizedDescription)
case .submitFinished:
self.stopLoading()
self.callback?(.finish)
case .cancel:
self.callback?(.cancel)
viewModel.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .submitStarted:
self.startLoading()
case let .submitFailed(error):
self.stopLoading()
self.showError(description: error.localizedDescription)
case .submitFinished:
self.stopLoading()
self.callback?(.finish)
case .cancel:
self.callback?(.cancel)
}
}
}
.store(in: &cancellables)
}
func stop() {
@@ -14,16 +14,20 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias ReportContentViewModelType = StateStoreViewModel<ReportContentViewState, ReportContentViewAction>
class ReportContentViewModel: ReportContentViewModelType, ReportContentViewModelProtocol {
var callback: ((ReportContentViewModelAction) -> Void)?
private let itemID: String
private let senderID: String
private let roomProxy: RoomProxyProtocol
private let actionsSubject: PassthroughSubject<ReportContentViewModelAction, Never> = .init()
var actions: AnyPublisher<ReportContentViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(itemID: String, senderID: String, roomProxy: RoomProxyProtocol) {
self.itemID = itemID
@@ -35,34 +39,34 @@ class ReportContentViewModel: ReportContentViewModelType, ReportContentViewModel
// MARK: - Public
override func process(viewAction: ReportContentViewAction) async {
override func process(viewAction: ReportContentViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
actionsSubject.send(.cancel)
case .submit:
await submitReport()
Task { await submitReport() }
}
}
// MARK: Private
private func submitReport() async {
callback?(.submitStarted)
actionsSubject.send(.submitStarted)
if case let .failure(error) = await roomProxy.reportContent(itemID, reason: state.bindings.reasonText) {
MXLog.error("Submit Report Content failed: \(error)")
callback?(.submitFailed(error: error))
actionsSubject.send(.submitFailed(error: error))
return
}
// Ignore the sender if the user wants to.
if state.bindings.ignoreUser, case let .failure(error) = await roomProxy.ignoreUser(senderID) {
MXLog.error("Ignore user failed: \(error)")
callback?(.submitFailed(error: error))
actionsSubject.send(.submitFailed(error: error))
return
}
MXLog.info("Submit Report Content succeeded")
callback?(.submitFinished)
actionsSubject.send(.submitFinished)
}
}
@@ -14,10 +14,11 @@
// limitations under the License.
//
import Combine
import Foundation
@MainActor
protocol ReportContentViewModelProtocol {
var callback: ((ReportContentViewModelAction) -> Void)? { get set }
var actions: AnyPublisher<ReportContentViewModelAction, Never> { get }
var context: ReportContentViewModelType.Context { get }
}
@@ -70,7 +70,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
// MARK: - Public
override func process(viewAction: RoomDetailsViewAction) async {
override func process(viewAction: RoomDetailsViewAction) {
switch viewAction {
case .processTapPeople:
callback?(.requestMemberDetailsPresentation(members))
@@ -81,15 +81,15 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: roomProxy.isPublic ? .public : .private)
case .confirmLeave:
await leaveRoom()
Task { await leaveRoom() }
case .processTapIgnore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .ignore)
case .processTapUnignore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .unignore)
case .ignoreConfirmed:
await ignore()
Task { await ignore() }
case .unignoreConfirmed:
await unignore()
Task { await unignore() }
}
}
@@ -32,21 +32,22 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
// MARK: - Public
override func process(viewAction: RoomMemberDetailsViewAction) async {
override func process(viewAction: RoomMemberDetailsViewAction) {
switch viewAction {
case .showUnignoreAlert:
state.bindings.ignoreUserAlert = .init(action: .unignore)
case .showIgnoreAlert:
state.bindings.ignoreUserAlert = .init(action: .ignore)
case .ignoreConfirmed:
await ignoreUser()
Task { await ignoreUser() }
case .unignoreConfirmed:
await unignoreUser()
Task { await unignoreUser() }
}
}
// MARK: - Private
@MainActor
private func ignoreUser() async {
state.isProcessingIgnoreRequest = true
let result = await roomMemberProxy.ignoreUser()
@@ -59,6 +60,7 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
}
}
@MainActor
private func unignoreUser() async {
state.isProcessingIgnoreRequest = true
let result = await roomMemberProxy.unignoreUser()
@@ -35,7 +35,7 @@ class RoomMembersListViewModel: RoomMembersListViewModelType, RoomMembersListVie
// MARK: - Public
override func process(viewAction: RoomMembersListViewAction) async {
override func process(viewAction: RoomMembersListViewAction) {
switch viewAction {
case .selectMember(let id):
guard let member = members.first(where: { $0.userID == id }) else {
@@ -87,33 +87,33 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
var callback: ((RoomScreenViewModelAction) -> Void)?
// swiftlint:disable:next cyclomatic_complexity
override func process(viewAction: RoomScreenViewAction) async {
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .displayRoomDetails:
callback?(.displayRoomDetails)
case .paginateBackwards:
await paginateBackwards()
Task { await paginateBackwards() }
case .itemAppeared(let id):
await timelineController.processItemAppearance(id)
Task { await timelineController.processItemAppearance(id) }
case .itemDisappeared(let id):
await timelineController.processItemDisappearance(id)
Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id):
await itemTapped(with: id)
Task { await itemTapped(with: id) }
case .itemDoubleTapped(let id):
itemDoubleTapped(with: id)
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
await sendCurrentMessage()
Task { await sendCurrentMessage() }
case .sendReaction(let emoji, let itemId):
await timelineController.sendReaction(emoji, to: itemId)
Task { await timelineController.sendReaction(emoji, to: itemId) }
case .cancelReply:
state.composerMode = .default
case .cancelEdit:
state.composerMode = .default
state.bindings.composerText = ""
case .markRoomAsRead:
await markRoomAsRead()
Task { await markRoomAsRead() }
case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID)
case .displayCameraPicker:
@@ -63,7 +63,7 @@ class SessionVerificationViewModel: SessionVerificationViewModelType, SessionVer
.store(in: &cancellables)
}
override func process(viewAction: SessionVerificationViewAction) async {
override func process(viewAction: SessionVerificationViewAction) {
switch viewAction {
case .requestVerification:
stateMachine.processEvent(.requestVerification)
@@ -73,7 +73,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.store(in: &cancellables)
}
override func process(viewAction: SettingsScreenViewAction) async {
override func process(viewAction: SettingsScreenViewAction) {
switch viewAction {
case .close:
callback?(.close)
@@ -36,7 +36,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
// MARK: - Public
override func process(viewAction: StartChatViewAction) async {
override func process(viewAction: StartChatViewAction) {
switch viewAction {
case .close:
callback?(.close)
@@ -322,7 +322,7 @@ class MockScreen: Identifiable {
let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .startChatSearchingNonExistingID:
case .startChatSearchingNonexistentID:
let navigationStackCoordinator = NavigationStackCoordinator()
let clientProxy = MockClientProxy(userID: "@mock:client.com")
clientProxy.searchUsersResult = .success(.init(results: [.mockAlice], limited: true))
@@ -50,7 +50,7 @@ enum UITestsScreenIdentifier: String {
case reportContent
case startChat
case startChatWithSearchResults
case startChatSearchingNonExistingID
case startChatSearchingNonexistentID
}
extension UITestsScreenIdentifier: CustomStringConvertible {