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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 273 additions and 233 deletions

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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))

View File

@ -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,7 +52,9 @@ final class BugReportCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.callback = { [weak self] result in
viewModel
.actions
.sink { [weak self] result in
guard let self else { return }
MXLog.info("BugReportViewModel did complete with result: \(result).")
switch result {
@ -66,6 +70,7 @@ final class BugReportCoordinator: CoordinatorProtocol {
self.showError(label: error.localizedDescription)
}
}
.store(in: &cancellables)
}
func stop() {

View File

@ -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))
}
}
}

View File

@ -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 }
}

View File

@ -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

View File

@ -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):
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:

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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,7 +45,8 @@ final class ReportContentCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.callback = { [weak self] action in
viewModel.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .submitStarted:
@ -58,6 +61,7 @@ final class ReportContentCoordinator: CoordinatorProtocol {
self.callback?(.cancel)
}
}
.store(in: &cancellables)
}
func stop() {

View File

@ -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)
}
}

View File

@ -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 }
}

View File

@ -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() }
}
}

View File

@ -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()

View File

@ -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 {

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -50,7 +50,7 @@ enum UITestsScreenIdentifier: String {
case reportContent
case startChat
case startChatWithSearchResults
case startChatSearchingNonExistingID
case startChatSearchingNonexistentID
}
extension UITestsScreenIdentifier: CustomStringConvertible {

View File

@ -9,6 +9,25 @@
}
],
"defaultOptions" : {
"environmentVariableEntries" : [
{
"key" : "INTEGRATION_TESTS_HOST",
"value" : "${INTEGRATION_TESTS_HOST}"
},
{
"key" : "INTEGRATION_TESTS_PASSWORD",
"value" : "${INTEGRATION_TESTS_PASSWORD}"
},
{
"key" : "INTEGRATION_TESTS_USERNAME",
"value" : "${INTEGRATION_TESTS_USERNAME}"
}
],
"targetForVariableExpansion" : {
"containerPath" : "container:ElementX.xcodeproj",
"identifier" : "D3DB351B7FBE0F49649171FC",
"name" : "IntegrationTests"
},
"testTimeoutsEnabled" : true
},
"testTargets" : [

View File

@ -14,10 +14,6 @@ schemes:
run:
config: Debug
disableMainThreadChecker: false
environmentVariables:
INTEGRATION_TESTS_HOST: ${INTEGRATION_TESTS_HOST}
INTEGRATION_TESTS_USERNAME: ${INTEGRATION_TESTS_USERNAME}
INTEGRATION_TESTS_PASSWORD: ${INTEGRATION_TESTS_PASSWORD}
test:
config: Debug
disableMainThreadChecker: false

View File

@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
struct TemplateCoordinatorParameters {
@ -30,8 +31,12 @@ enum TemplateCoordinatorAction {
final class TemplateCoordinator: CoordinatorProtocol {
private let parameters: TemplateCoordinatorParameters
private var viewModel: TemplateViewModelProtocol
private let actionsSubject: PassthroughSubject<TemplateCoordinatorAction, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
var callback: ((TemplateCoordinatorAction) -> Void)?
var actions: AnyPublisher<TemplateCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: TemplateCoordinatorParameters) {
self.parameters = parameters
@ -40,16 +45,17 @@ final class TemplateCoordinator: CoordinatorProtocol {
}
func start() {
viewModel.callback = { [weak self] action in
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .accept:
MXLog.info("User accepted the prompt.")
self.callback?(.accept)
self.actionsSubject.send(.accept)
case .cancel:
self.callback?(.cancel)
self.actionsSubject.send(.cancel)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {

View File

@ -14,12 +14,17 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias TemplateViewModelType = StateStoreViewModel<TemplateViewState, TemplateViewAction>
class TemplateViewModel: TemplateViewModelType, TemplateViewModelProtocol {
var callback: ((TemplateViewModelAction) -> Void)?
private var actionsSubject: PassthroughSubject<TemplateViewModelAction, Never> = .init()
var actions: AnyPublisher<TemplateViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(promptType: TemplatePromptType, initialCount: Int = 0) {
super.init(initialViewState: TemplateViewState(promptType: promptType, count: 0))
@ -27,12 +32,12 @@ class TemplateViewModel: TemplateViewModelType, TemplateViewModelProtocol {
// MARK: - Public
override func process(viewAction: TemplateViewAction) async {
override func process(viewAction: TemplateViewAction) {
switch viewAction {
case .accept:
callback?(.accept)
actionsSubject.send(.accept)
case .cancel:
callback?(.cancel)
actionsSubject.send(.cancel)
case .incrementCount:
state.count += 1
case .decrementCount:

View File

@ -14,10 +14,10 @@
// limitations under the License.
//
import Foundation
import Combine
@MainActor
protocol TemplateViewModelProtocol {
var callback: ((TemplateViewModelAction) -> Void)? { get set }
var actions: AnyPublisher<TemplateViewModelAction, Never> { get }
var context: TemplateViewModelType.Context { get }
}

View File

@ -38,15 +38,12 @@ class TemplateScreenViewModelTests: XCTestCase {
func testCounter() async throws {
context.send(viewAction: .incrementCount)
await Task.yield()
XCTAssertEqual(context.viewState.count, 1)
context.send(viewAction: .incrementCount)
await Task.yield()
XCTAssertEqual(context.viewState.count, 2)
context.send(viewAction: .decrementCount)
await Task.yield()
XCTAssertEqual(context.viewState.count, 1)
}
}

View File

@ -25,21 +25,6 @@ class BugReportUITests: XCTestCase {
app.assertScreenshot(.bugReport, step: 0)
}
func testToggleSendingLogs() {
let app = Application.launch(.bugReport)
// Don't know why, but there's an issue on CI where the toggle is tapped but doesn't respond. Waiting for
// it fixes this (even it it already exists). Reproducible by running the test after quitting the simulator.
let sendingLogsToggle = app.switches[A11yIdentifiers.bugReportScreen.sendLogs]
XCTAssertTrue(sendingLogsToggle.waitForExistence(timeout: 1))
XCTAssertTrue(sendingLogsToggle.isOn)
sendingLogsToggle.tap()
XCTAssertFalse(sendingLogsToggle.isOn)
app.assertScreenshot(.bugReport, step: 1)
}
func testReportText() {
let app = Application.launch(.bugReport)

View File

@ -22,19 +22,4 @@ class ReportContentScreenUITests: XCTestCase {
let app = Application.launch(.reportContent)
app.assertScreenshot(.reportContent, step: 0)
}
func testToggleIgnoreUser() {
let app = Application.launch(.reportContent)
// Don't know why, but there's an issue on CI where the toggle is tapped but doesn't respond. Waiting for
// it fixes this (even it it already exists). Reproducible by running the test after quitting the simulator.
let sendingLogsToggle = app.switches[A11yIdentifiers.reportContent.ignoreUser]
XCTAssertTrue(sendingLogsToggle.waitForExistence(timeout: 1))
XCTAssertFalse(sendingLogsToggle.isOn)
sendingLogsToggle.tap()
XCTAssertTrue(sendingLogsToggle.isOn)
app.assertScreenshot(.reportContent, step: 1)
}
}

View File

@ -50,7 +50,7 @@ class StartChatScreenUITests: XCTestCase {
}
func testSearchExactNotExistingMatrixID() {
let app = Application.launch(.startChatSearchingInexistingID)
let app = Application.launch(.startChatSearchingNonexistentID)
let searchField = app.searchFields.firstMatch
searchField.clearAndTypeText("@a:b.com")
XCTAssertFalse(app.staticTexts[A11yIdentifiers.startChatScreen.searchNoResults].waitForExistence(timeout: 1.0))

View File

@ -46,7 +46,6 @@ class BugReportViewModelTests: XCTestCase {
let context = viewModel.context
context.send(viewAction: .removeScreenshot)
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertNil(context.viewState.screenshot)
}
@ -58,7 +57,6 @@ class BugReportViewModelTests: XCTestCase {
let context = viewModel.context
XCTAssertNil(context.viewState.screenshot)
context.send(viewAction: .attachScreenshot(UIImage.actions))
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssert(context.viewState.screenshot == UIImage.actions)
}
@ -70,19 +68,20 @@ class BugReportViewModelTests: XCTestCase {
deviceID: nil,
screenshot: nil, isModallyPresented: false)
let context = viewModel.context
var isSuccess = false
viewModel.callback = { result in
switch result {
case .submitFinished:
isSuccess = true
default: break
}
}
context.send(viewAction: .submit)
try await Task.sleep(for: .milliseconds(100))
_ = await viewModel
.actions
.values
.first {
guard case .submitFinished = $0 else {
return false
}
return true
}
XCTAssert(mockService.submitBugReportProgressListenerCallsCount == 1)
XCTAssert(mockService.submitBugReportProgressListenerReceivedArguments?.bugReport == BugReport(userID: "@mock.client.com", deviceID: nil, text: "", includeLogs: true, includeCrashLog: true, githubLabels: [], files: []))
XCTAssertTrue(isSuccess)
}
func testSendReportWithError() async throws {
@ -95,20 +94,19 @@ class BugReportViewModelTests: XCTestCase {
deviceID: nil,
screenshot: nil, isModallyPresented: false)
let context = viewModel.context
var isFailure = false
viewModel.callback = { result in
switch result {
case .submitFailed:
isFailure = true
default: break
}
}
context.send(viewAction: .submit)
try await Task.sleep(for: .milliseconds(100))
_ = await viewModel
.actions
.values
.first {
guard case .submitFailed = $0 else {
return false
}
return true
}
XCTAssert(mockService.submitBugReportProgressListenerCallsCount == 1)
XCTAssert(mockService.submitBugReportProgressListenerReceivedArguments?.bugReport == BugReport(userID: "@mock.client.com", deviceID: nil, text: "", includeLogs: true, includeCrashLog: true, githubLabels: [], files: []))
XCTAssertTrue(isFailure)
}
}

View File

@ -0,0 +1,21 @@
//
// 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.
//
extension AsyncSequence {
func first() async rethrows -> Self.Element? {
try await first { _ in true }
}
}

View File

@ -0,0 +1,31 @@
//
// 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
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()
}
}
}

View File

@ -0,0 +1,24 @@
//
// 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.
//
@testable import ElementX
extension ViewModelContext {
@discardableResult
func nextViewState() async -> ViewState? {
await $viewState.nextValue
}
}

View File

@ -23,12 +23,12 @@ class FilePreviewScreenViewModelTests: XCTestCase {
var viewModel: FilePreviewViewModelProtocol!
var context: FilePreviewViewModelType.Context!
@MainActor override func setUpWithError() throws {
override func setUpWithError() throws {
viewModel = FilePreviewViewModel(mediaFile: .unmanaged(url: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf")))
context = viewModel.context
}
@MainActor func testCancel() async throws {
func testCancel() async throws {
var correctResult = false
viewModel.callback = { result in
switch result {

View File

@ -18,18 +18,19 @@ import XCTest
@testable import ElementX
@MainActor
class HomeScreenViewModelTests: XCTestCase {
var viewModel: HomeScreenViewModelProtocol!
var context: HomeScreenViewModelType.Context!
@MainActor override func setUpWithError() throws {
override func setUpWithError() throws {
viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"),
mediaProvider: MockMediaProvider()),
attributedStringBuilder: AttributedStringBuilder())
context = viewModel.context
}
@MainActor func testSelectRoom() async throws {
func testSelectRoom() async throws {
let mockRoomId = "mock_room_id"
var correctResult = false
var selectedRoomId = ""
@ -49,7 +50,7 @@ class HomeScreenViewModelTests: XCTestCase {
XCTAssertEqual(mockRoomId, selectedRoomId)
}
@MainActor func testTapUserAvatar() async throws {
func testTapUserAvatar() async throws {
var correctResult = false
viewModel.callback = { result in
switch result {

View File

@ -36,7 +36,8 @@ class ReportContentScreenViewModelTests: XCTestCase {
viewModel.state.bindings.reasonText = reportReason
viewModel.state.bindings.ignoreUser = false
viewModel.context.send(viewAction: .submit)
await Task.yield()
_ = await viewModel.actions.values.first()
// Then the content should be reported, but the user should not be included.
XCTAssertEqual(roomProxy.reportContentReasonCallsCount, 1, "The content should always be reported.")
@ -59,7 +60,8 @@ class ReportContentScreenViewModelTests: XCTestCase {
viewModel.state.bindings.reasonText = reportReason
viewModel.state.bindings.ignoreUser = true
viewModel.context.send(viewAction: .submit)
await Task.yield()
_ = await viewModel.actions.values.first()
// Then the content should be reported, and the user should be ignored.
XCTAssertEqual(roomProxy.reportContentReasonCallsCount, 1, "The content should always be reported.")

View File

@ -34,24 +34,21 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: true, members: mockedMembers))
viewModel = RoomDetailsViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .processTapLeave)
await Task.yield()
XCTAssertEqual(context.leaveRoomAlertItem?.state, .public)
XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertSubtitle)
}
func testLeavRoomTappedWhenRoomNotPublic() async {
func testLeaveRoomTappedWhenRoomNotPublic() async {
let mockedMembers: [RoomMemberProxyMock] = [.mockBob, .mockAlice]
roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: false, members: mockedMembers))
viewModel = RoomDetailsViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .processTapLeave)
await Task.yield()
XCTAssertEqual(context.leaveRoomAlertItem?.state, .private)
XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertPrivateSubtitle)
}
func testLeaveRoomTappedWithLessThanTwoMembers() async {
context.send(viewAction: .processTapLeave)
await Task.yield()
XCTAssertEqual(context.leaveRoomAlertItem?.state, .empty)
XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, L10n.leaveRoomAlertEmptySubtitle)
}
@ -78,7 +75,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
.failure(.failedLeavingRoom)
}
context.send(viewAction: .confirmLeave)
await Task.yield()
await context.nextViewState()
XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1)
XCTAssertNotNil(context.alertInfo)
}
@ -103,10 +100,10 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient))
context.send(viewAction: .ignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssert(context.viewState.dmRecipient?.isIgnored == true)
}
@ -123,10 +120,10 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient))
context.send(viewAction: .ignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssert(context.viewState.dmRecipient?.isIgnored == false)
XCTAssertNotNil(context.alertInfo)
@ -144,10 +141,10 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient))
context.send(viewAction: .unignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssert(context.viewState.dmRecipient?.isIgnored == false)
}
@ -164,10 +161,10 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
XCTAssertEqual(context.viewState.dmRecipient, RoomMemberDetails(withProxy: recipient))
context.send(viewAction: .unignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssert(context.viewState.dmRecipient?.isIgnored == true)
XCTAssertNotNil(context.alertInfo)

View File

@ -42,14 +42,13 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .showIgnoreAlert)
await Task.yield()
XCTAssertEqual(context.ignoreUserAlert, .init(action: .ignore))
context.send(viewAction: .ignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
XCTAssertFalse(context.viewState.details.isIgnored)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssertTrue(context.viewState.details.isIgnored)
}
@ -61,16 +60,14 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
return .failure(.ignoreUserFailed)
}
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .showIgnoreAlert)
await Task.yield()
XCTAssertEqual(context.ignoreUserAlert, .init(action: .ignore))
context.send(viewAction: .ignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
XCTAssertFalse(context.viewState.details.isIgnored)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssertNotNil(context.errorAlert)
XCTAssertFalse(context.viewState.details.isIgnored)
@ -85,14 +82,13 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .showUnignoreAlert)
await Task.yield()
XCTAssertEqual(context.ignoreUserAlert, .init(action: .unignore))
context.send(viewAction: .unignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
XCTAssertTrue(context.viewState.details.isIgnored)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssertFalse(context.viewState.details.isIgnored)
}
@ -106,14 +102,13 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
context.send(viewAction: .showUnignoreAlert)
await Task.yield()
XCTAssertEqual(context.ignoreUserAlert, .init(action: .unignore))
context.send(viewAction: .unignoreConfirmed)
await Task.yield()
await context.nextViewState()
XCTAssertTrue(context.viewState.isProcessingIgnoreRequest)
XCTAssertTrue(context.viewState.details.isIgnored)
try await Task.sleep(for: .milliseconds(10))
await context.nextViewState()
XCTAssertFalse(context.viewState.isProcessingIgnoreRequest)
XCTAssertTrue(context.viewState.details.isIgnored)
XCTAssertNotNil(context.errorAlert)

View File

@ -25,7 +25,6 @@ class SessionVerificationViewModelTests: XCTestCase {
var context: SessionVerificationViewModelType.Context!
var sessionVerificationController: SessionVerificationControllerProxyMock!
@MainActor
override func setUpWithError() throws {
sessionVerificationController = SessionVerificationControllerProxyMock.configureMock()
viewModel = SessionVerificationViewModel(sessionVerificationControllerProxy: sessionVerificationController)
@ -49,18 +48,14 @@ class SessionVerificationViewModelTests: XCTestCase {
context.send(viewAction: .close)
await Task.yield()
XCTAssertEqual(context.viewState.verificationState, .cancelling)
try await Task.sleep(for: .milliseconds(100))
await context.nextViewState()
XCTAssertEqual(context.viewState.verificationState, .cancelled)
context.send(viewAction: .restart)
await Task.yield()
XCTAssertEqual(context.viewState.verificationState, .initial)
XCTAssert(sessionVerificationController.requestVerificationCallsCount == 1)

View File

@ -50,4 +50,3 @@ targets:
- path: ../../Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit
- path: ../Resources
- path: ../../ElementX/Sources/Other/InfoPlistReader.swift
- path: ../../ElementX/Sources/Other/Extensions/Publisher.swift